diff --git a/AGENTS.md b/AGENTS.md index 1229840c..690530eb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -144,11 +144,12 @@ substitute for source inspection or a second roadmap. - `desktop_host`: the Linux Settings/service snapshot-and-action contract. macOS chooses its navigation directly from the developer-feature flag and uses the shared presentation primitives while committing its own settings. -- `desktop_ui`: platform-neutral GPUI visual tokens and controls shared by both - desktop roots, including the mandatory pane scaffold: `pane_header` / - `pane_header_with_action`, `pane_body`, `pane_content`, the shared - `header_button` action chip, and the single `PANE_CONTENT_WIDTH` and - `PANE_LIST_WIDTH` layout constants. +- `design_tokens`: the desktop design tokens (surface, text and role colours, + the five-step type scale, spacing, radii, and control geometry) plus the + `Typography` trait and the test that rejects raw styles in desktop sources. +- `desktop_ui`: the platform-neutral GPUI components shared by both desktop + roots: the pane scaffold, cards and rows, the one button system, tags, menus, + segmented controls, icons, and empty and error states. - `text_input`: the shared GPUI single- and multi-line text input with editing, selection, clipboard, and input-method support. - `desktop_transcription_picker`: the single GPUI language/model picker used by @@ -397,26 +398,15 @@ CoreAudio formats, AppleScript details, or event serialization. paste output has completed, including ordered post-processing and insertion. - The HUD is observational and click-through. It must not alter capture boundaries, steal focus, or block controls in the foreground application. -- Every desktop pane renders the shared scaffold from `desktop_ui`: the - bounded pane header plus one centered content column at `PANE_CONTENT_WIDTH`, - with list+detail panes using the fixed `PANE_LIST_WIDTH` list column (`pane_list_card` - and `compact_panel` detail cards with rounded `list_row` items). Panes - must not introduce their own header treatments, ad-hoc row helpers, or content - widths, and text columns beside a fixed column carry `flex_1` with a zero - min-width so long lines wrap instead of widening the pane. Any `.truncate()` - element must also set `.overflow_x_hidden()` so GPUI shapes an ellipsis (`…`) - rather than clipping mid-glyph. -- Desktop UI follows the OpenCode V2 dark surface hierarchy (`SIDEBAR` recessed - at `#111111`, `CANVAS` base at `#161616`, elevated `SURFACE` cards at `#1d1d1d`, - and recessed `CANVAS` controls with `LINE` borders inside cards). Interactive - controls inside `SURFACE` cards must use bordered chips (`canvas_button`, - `header_button`, or `disclosure_button`) rather than borderless `SURFACE` - buttons, and dropdown/picker triggers must include a visible disclosure - affordance. Keep copy to one direct line per control; never add intro - paragraphs above setting cards, repeated per-row field labels, or redundant - "cannot be undone" warnings beside two-step confirmation buttons. Render - modal/list errors at the top of the scrollable container so they are never - hidden below the fold. +- Desktop UI is governed by [`docs/design-system.md`](docs/design-system.md); + read it before changing any GPUI pane, capsule, or picker. Use only + `design_tokens` values and `desktop_ui` components: no raw colours, type + sizes, line heights, or radii (`desktop_sources_use_tokens_instead_of_raw_styles` + enforces this). Use sentence case and the product name "Hex" in all copy. + Reserve the accent (Primary) fill for at most one main action per screen; + states such as recording or an available update use a Secondary button with + a status dot. Extend a token or component instead of adding a parallel helper, and verify + visual changes with `scripts/capture-preview.sh`. - Public app updates are Developer ID signed, notarized, stapled, EdDSA signed, published artifact-first/feed-last, and installed through Sparkle. - Linux direct-install updates accept only a strictly newer signed stable @@ -515,14 +505,15 @@ cd sdk/typescript && bun run check && bun run test && bun run build Use `scripts/capture-preview.sh` as the default desktop-UI iteration loop. It builds the release binary, launches one isolated deterministic preview, waits -for that process's `HEX` window, captures only that window, and terminates the +for that process's `Hex` window, captures only that window, and terminates the preview. Targets include `settings`, `modes`, `voice-action`, `commands`, `meetings`, `activity`, `onboarding`, `transcription-picker`, `hud-lab`, `dictation-hud`, and `meeting-offer` (with `--meeting-panel call-ended|ready`). Pass `--meetings-enabled` and `--screen-recording-missing` to Settings for the Meetings section. The `meetings` preview has sample meetings with notes and a transcript; add `--meeting-recording` for the live state, -`--confirm-meeting-delete` for the delete confirmation, or `--meetings-empty`. The Modes preview includes representative activation, +`--confirm-meeting-delete` for the delete confirmation, `--open-meeting-actions` +for the overflow menu, or `--meetings-empty`. The Modes preview includes representative activation, correction, model-variant, and transformation data so it exercises the complete editor. Pass `--collapse-mode-processing` to expose the lower transformation and deletion states without scrolling. Use `--open-transformation-picker`, diff --git a/README.md b/README.md index d0e79241..4728910b 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ Or install manually: - **Accessibility** lets HEX paste into the foreground app. Drag the HEX tile into the list. - **Transcription model** downloads the speech model that runs locally. -HEX checks for signed updates automatically. Use **HEX > Check for Updates...** +HEX checks for signed updates automatically. Use **Hex > Check for Updates…** to check immediately. Organizations that manage app versions themselves can turn off in-app updates, diff --git a/docs/design-system.md b/docs/design-system.md new file mode 100644 index 00000000..3f7c68f2 --- /dev/null +++ b/docs/design-system.md @@ -0,0 +1,285 @@ +# Hex Design System + +The GPUI desktop app (Settings window, meeting capsules, transcription picker, +and the Linux Settings window) is built from two modules: + +- [`src/design_tokens.rs`](../src/design_tokens.rs) holds every raw value: + colours, the type scale, spacing, radii, and control geometry. +- [`src/desktop_ui.rs`](../src/desktop_ui.rs) holds every shared component. + +Panes compose components and name tokens. They never write a raw colour, type +size, line height, font family, or radius. The test +`design_tokens::tests::desktop_sources_use_tokens_instead_of_raw_styles` fails on +`rgb(0x…)`, `rgba(0x…)`, `.text_size(`, `.line_height(px(`, `.font_family(`, +`.rounded(px(`, `.rounded_sm/md/lg()`, `.text_xs/sm/base/lg/xl()`, and +`.truncate()` in +`app_window`, `desktop_ui`, `desktop_transcription_picker`, `linux_app`, +`meeting_watcher`, and `text_input`. + +The dictation HUD ([`dictation_indicator.rs`](../src/dictation_indicator.rs)) is +outside the system: its glossy capsule is hand-tuned in HUD Lab. + +## Rules + +1. **Tokens only.** If a value is missing, add a named token to + `design_tokens.rs` with a one-line purpose. Don't inline it. +2. **One component per job.** Use the table below. If no component fits, + extend one in `desktop_ui.rs` and delete whatever it replaces. Never keep two + helpers for the same job. +3. **Five type steps.** Pick a step by role, not by look. Don't set weights a + step already owns. +4. **Sentence case, one name.** Labels, buttons, section labels, and titles use + sentence case. The product is "Hex" everywhere in the UI. +5. **One icon set.** Use `Icon` / `NavigationIcon`: line-weight SF Symbols at + medium weight on macOS, never `.fill` variants. Never draw an icon with text + glyphs or letters. +6. **One accent per screen.** The accent fill (`ButtonKind::Primary`) marks at + most one action per screen: the thing the person came to do (see + [Colour](#colour)). Everything else is neutral. +7. **Few visible actions.** A detail header shows its one to three most common + actions; the rest go in an `Icon::Ellipsis` overflow menu, with the + destructive choice last after a separator. +8. **Verify by looking.** Capture every pane you touched with + `scripts/capture-preview.sh` (see [Verification](#verification)). + +## Tokens + +### Surfaces + +These are the OpenCode V2 dark hierarchy, from most recessed to most elevated. + +```ts +SIDEBAR #111111 // navigation column, picker language list +SIDEBAR_HOVER #1b1b1b // hover on SIDEBAR rows +CANVAS #161616 // window base, recessed controls inside cards +SURFACE #1d1d1d // cards (panel), menus +SURFACE_HOVER #252525 // hover on SURFACE rows and secondary buttons +SURFACE_SELECTED #2e2e2e // selected row, segment, nav item +LINE #2a2a2a // card borders, dividers, control borders +LINE_STRONG #3d3d3d // menu borders, keycaps, toggle track, capsule edge +SCRIM #000000cc // every modal backdrop +``` + +### Text + +```ts +TEXT_PRIMARY #f2f2f2 // titles, values, selected items +TEXT_SECONDARY #d0d0d0 // body copy, unselected list titles, button labels +TEXT_TERTIARY #969696 // descriptions, metadata, section labels, placeholders +TEXT_DISABLED #6e6e6e // unavailable controls and idle status dots only +ACCENT_TEXT #8390ff // accent text on ACCENT_SURFACE, links +DANGER #f17471 // errors, destructive labels +SUCCESS #69d89f // ready and live states +``` + +`TEXT_TERTIARY` is the weakest colour allowed for readable text. It measures at +least 4.5:1 on every surface (`readable_text_meets_aa_contrast_on_every_surface`). + +### Roles + +Each role has a text or fill colour, a tinted surface, and, where needed, a +border. + +```ts +ACCENT #3b5cf6 ACCENT_SURFACE #1c2030 ACCENT_BORDER #2c3560 SELECTION #3b5cf655 +SUCCESS #69d89f SUCCESS_SURFACE #17231a +DANGER #f17471 DANGER_SURFACE #2b1718 DANGER_BORDER #66292c +``` + +`ACCENT` is the only primary-action fill, the toggle "on" colour, and the +focus ring. + +### Colour + +Saturated colour is spent on meaning, never decoration: + +- **Accent fill:** at most one Primary button per screen, for the main action + (Meetings "Start recording", setup "Choose model", a dialog's confirm). A + meeting capsule is its own screen: "Record", "Stop & transcribe" after the + call ends, "Reveal" when ready. Never in persistent chrome like the sidebar. +- **States are not actions:** an ongoing state uses a Secondary button with a + `status_dot` in the state's colour, matching its `tag`. "Stop & transcribe" + while recording is Secondary with a red dot and the elapsed time; the + sidebar's "Update" is Secondary with an accent dot. +- **Danger** marks destructive choices and armed confirmations only; **success** + marks ready and live states only. + +### Type scale + +The window frame sets `BODY`, so text with no step inherits 13 px. + +| Step | Size / line | Weight | Use | +|---|---|---|---| +| `CAPTION` | 11 / 14 | inherited | metadata, timestamps, tags, section labels, small buttons | +| `FOOTNOTE` | 12 / 16 | inherited | descriptions, control and button labels, menu items | +| `BODY` | 13 / 18 | inherited | window default, values, inputs, list and row titles, reading text | +| `HEADLINE` | 15 / 20 | semibold | detail, card, and dialog titles | +| `TITLE` | 20 / 26 | semibold | pane titles only (`pane_header`) | + +Apply steps with the `Typography` trait: `.text_caption()`, +`.text_footnote()`, `.text_body()`, `.text_headline()`, `.text_title()`, or +`.text_step(STEP)`. Each step sets its own line height. Use `.text_monospace()` +for code, logs, and configuration paths. + +Emphasis inside a step uses `FontWeight::MEDIUM` (row titles, labels, buttons) +or `FontWeight::SEMIBOLD` (card titles at `BODY`). The only text below the scale +is `KEYCAP_SIDE_BADGE` (7 / 8), the L/R badge on a sided modifier keycap. + +### Spacing, radii, geometry + +Use GPUI spacing utilities on the 4 px grid: `_1` 4, `_2` 8, `_3` 12, `_4` 16, +`_5` 20, `_6` 24, `_8` 32. `PANE_GUTTER` (32) is the one horizontal inset for +pane headers and bodies, so cards line up with the header rule. + +Smaller text beside a larger line (a timestamp beside a row title, a +`detail_row` label beside its value) shares its baseline: inset it from the top +by `baseline_offset(LARGER, SMALLER)`, never `items_center`. + +```ts +RADIUS_KNOB 2 · RADIUS_SMALL 4 · RADIUS_CONTROL 6 · RADIUS_PANEL 10 · RADIUS_MODAL 12 +icon_radius(size) // app icons and mode icons: 22% of the size +rounded_full // dots, avatars, meeting capsules and their buttons + +CONTROL_HEIGHT 30 // buttons, inputs, disclosures, segmented controls +CONTROL_HEIGHT_SMALL 24 // small buttons, keycaps +TAG_HEIGHT 18 · MENU_ITEM_HEIGHT 28 · NAV_ITEM_HEIGHT 32 +MENU_WIDTH 180 · MENU_OFFSET 4 // menu width; gap below its trigger +PANEL_HEADER_HEIGHT 38 · SETTINGS_ROW_MIN_HEIGHT 56 · PANE_HEADER_HEIGHT 70 +SIDEBAR_WIDTH 220 · PANE_CONTENT_WIDTH 940 +PANE_LIST_WIDTH 320 · PANE_LIST_WIDTH_COMPACT 220 (Modes) +DISCLOSURE_WIDTH 220 · SEARCH_FIELD_WIDTH 220 · MODE_FIELD_WIDTH 300 +ICON_SIZE 14 · ICON_SIZE_SMALL 11 · TOGGLE 28×16 · DISABLED_OPACITY 0.4 +``` + +## Components + +| Job | Component | +|---|---| +| Window root (canvas + `BODY`) | `window_frame` | +| Navigation column / item | `sidebar_frame`, `navigation_item(NavigationIcon, selected)` | +| Pane title bar, optional action | `pane_header`, `pane_header_with_action` | +| Fixed pane body / scrolling pane body | `pane_body` + `pane_content`, `pane_scroll_body(id, pane_content())` | +| List + detail | `pane_list(id, empty, error_title, error, rows)` beside `pane_detail(content)` | +| Row in a list | `list_row(selected)` with `list_row_title`, `list_row_meta` | +| Group heading in a list | `list_section_label(label, first)` | +| Top of a detail card | `detail_header(detail_title(…), meta, actions)` over `detail_body(id)` | +| Any text that must fit one line | `truncated_line(STEP, text)` (ends in "…") | +| Card | `panel()`; titled card: `panel_header(title, action)` | +| Settings | `settings_section_label` → `panel` → `settings_row(title, description, control)` | +| Label above a group | `section_label` (caption, semibold, tertiary); above a card, inset `px_4` to the card's row text | +| Label / value in a detail card | `detail_row` | +| Main action of a view (at most one) | `button(ButtonKind::Primary, …)` | +| Any other action | `button(ButtonKind::Secondary, …)` | +| Destructive or armed confirmation | `button(ButtonKind::Danger, …)` | +| Action inside a row or card | `small_button(kind, …)` | +| Icon-only action | `icon_button(ButtonKind::Ghost, Icon)` in cards and rows; `Secondary` when it stands alone | +| Work in progress where a button was | `busy_label` | +| Dropdown trigger | `disclosure_button(label)`; custom content: `disclosure_frame()` + `Icon::ChevronDown` | +| Popover / menu and its choices | `menu()`, `menu_item(selected)`; under a control: `dropdown_menu()` at `MENU_WIDTH` | +| Destructive / unavailable menu choice | `danger_menu_item()` after `menu_separator()`; `disabled_menu_item()` | +| Overflow of less common actions | `icon_button(ButtonKind::Secondary, Icon::Ellipsis)` opening a `dropdown_menu()` | +| On/off | `toggle(position)`; never repeat "On"/"Off" beside it | +| A few exclusive choices | `segmented_control` + `segmented_item`, or the sliding variants | +| Status (never clickable) | `tag(Tone::{Neutral, Accent, Success, Danger}, label)` | +| Live / recorded state | `status_dot(colour)` | +| Shortcut | `hotkey_keycaps` | +| Text entry | `TextInput` ([`text_input.rs`](../src/text_input.rs)): `BODY`, `CONTROL_HEIGHT`, `RADIUS_CONTROL` | +| Nothing to show | `empty_state`; centred in a card: `detail_placeholder` (`pane_list` centres its own) | +| Errors | `error_panel` (card), `error_message` (inside a card), `inline_error` (under a control) | + +A destructive menu choice ends in "…" ("Delete…") and keeps its two-step +confirmation: choosing it closes the menu and replaces the action row with the +armed Danger button and Cancel until one is chosen. + +Buttons are always filled or bordered. Only icon buttons and menu items are +borderless. Tags never have a border or hover state, so they can't be mistaken +for buttons. Put errors at the top of their scroll area, never below the fold. + +Meeting capsules are pill-shaped. They use the shared buttons with +`.rounded_full()` and the same colours, type, and dots as the window. + +## Copy + +- Sentence case for every label, button, title, and section label: + "Open settings", "Stop & transcribe", "Permissions needed". Proper nouns keep + their capitals: Hex, OpenCode, Apple Intelligence, Finder, Zoom. +- One product name: "Hex". Never "HEX" in UI strings, window titles, or menus. + File and artifact names (`HEX.app`, `HEX-…`) and `HEX_*` environment + variables are identifiers, not copy. +- Use "…" (one character) for ellipses, for both progress ("Saving…") and for + buttons that open a further choice ("Choose from Finder…"). +- Settings and permission row descriptions are one phrase with no closing + period ("Hold to dictate, release to transcribe"). Two sentences keep their + periods. +- Keep one direct line per control. Don't add intro paragraphs above cards, + repeat field labels per row, put state words next to toggles, or warn "cannot + be undone" beside a two-step confirmation. +- Exception: native macOS menus (`status_item.rs` and the app menu in + `meeting_watcher.rs`) follow the macOS title-case convention ("Check for + Updates…", "Paste Last Dictation", "Stop & Transcribe"). + +## Icons + +- Use `Icon` (`ArrowRight`, `ChevronDown`, `Close`, `DragHandle`, `Ellipsis`, + `Globe`, `Plus`) via `icon(Icon, colour)` at `ICON_SIZE_SMALL`. `Ellipsis` + draws at `ICON_SIZE` so its dots match the other glyphs optically. Use + `NavigationIcon` for the sidebar. +- On macOS these are line-weight SF Symbols (no `.fill` variants) at medium + weight, monochrome. On Linux they fall back to Heroicons (navigation) and + matching glyphs (inline). +- Colour icons with a text token: tertiary at rest, primary when selected or + hovered. +- A web page or other item without an app icon shows `Icon::Globe` inside the + same rounded tile, never a letter monogram. +- To add an icon, add an `Icon` variant with both its SF Symbol and its Linux + fallback. + +## Verification + +```sh +cargo test --bin voice-control design_tokens desktop_ui # tokens, contrast, layout, truncation +cargo clippy --all-targets --all-features -- -D warnings +./scripts/capture-preview.sh /tmp/hex-settings.png settings +HEX_PREVIEW_SKIP_BUILD=1 ./scripts/capture-preview.sh /tmp/hex-modes.png modes +``` + +Capture every pane and state you touched. The preview targets and flags are +listed in [AGENTS.md](../AGENTS.md#development). Check at least these: +alignment with the header rule, section labels on their card's text edge, +shared baselines, no clipped text without "…", at most one accent button per +screen, and sentence case. + +## Audit: before → after + +This is the consolidation from `origin/main` 1c3dacf, measured across the six +desktop sources. + +```ts +Text sizes 11 explicit (7 8 9 10 11 12 13 14 15 18 20) + GPUI's inherited 16 + -> 5 steps (11 12 13 15 20) + the 7 px keycap badge + uses before: 11×44 12×41 10×33 13×8 14×4 15×3 18×3 8×2 20×2 9×1 7×1 + mapping: 7–11 -> CAPTION, 12 -> FOOTNOTE, 13–14 -> BODY, + 15–18 -> HEADLINE, 20 -> TITLE (pane titles only) + inputs 14, unsized text 16 -> BODY 13 from window_frame +Line heights 8 distinct explicit (16 17 18 19 20 24 26) -> owned by each step +Weights SEMIBOLD×40 MEDIUM×6 NORMAL×3 + -> MEDIUM for emphasis (15), SEMIBOLD owned by HEADLINE/TITLE + and card titles (7), NORMAL for unselected items (2) +Text colours TEXT TEXT_SOFT MUTED FAINT + 3 raw greys (f1f1f1 8f8f8f 171717) + -> TEXT_PRIMARY TEXT_SECONDARY TEXT_TERTIARY (+ TEXT_DISABLED) + FAINT #6e6e6e was below 4.5:1 on SURFACE; merged into TEXT_TERTIARY +Raw colours 35 literals, 29 distinct, beside 18 tokens -> 0 literals, 23 tokens +Radii 2 4 5 6 8 10 + rounded_sm/md/lg (60 call sites) + -> RADIUS_KNOB 2, SMALL 4, CONTROL 6, PANEL 10, MODAL 12 +Buttons 7 looks (header_button, canvas_button, compact_button, blue fill, + white fill, danger tint, capsule grey) + -> 4 kinds (primary, secondary, ghost, danger) × 2 sizes + 1 tag +Control height 24 26 28 30 32 34 36 -> 30 (CONTROL_HEIGHT) / 24 (small) +Section labels 16 ALL CAPS -> sentence case +Product name "HEX" in window title, menus, copy -> "Hex" +Icons mixed filled and line SF Symbols, "×" "+" "≡" "→" glyphs, letter + monograms -> one outline Icon set, globe for web pages +Truncation .truncate() clipped mid-glyph or kept a stale width -> truncated_line +Pane geometry header inset 32, list+detail bodies inset 20 (cards overhung the + header rule by 12 px) -> one PANE_GUTTER owned by pane_body +``` diff --git a/docs/features/README.md b/docs/features/README.md index d2322973..4040b4c6 100644 --- a/docs/features/README.md +++ b/docs/features/README.md @@ -469,7 +469,7 @@ Show Dock icon off // maintain.dock-policy ```ts Settings > Launch at login // maintain.login-item ├── Open -> Checking until macOS returns the initial state -├── Refresh / toggle / Open Settings -> One persistent background worker +├── Refresh / toggle / Open settings -> One persistent background worker │ └── One active request; latest pending user action precedes the next refresh ├── Failed toggle -> Show error and reconcile with actual macOS state when available └── Close Settings -> Release channels without waiting for an active native call diff --git a/docs/features/commands.md b/docs/features/commands.md index 9f29ec27..8bef31f8 100644 --- a/docs/features/commands.md +++ b/docs/features/commands.md @@ -13,7 +13,7 @@ previews, providers, or native actions were run for this split. Hex > Commands // commands; off by default ├── Enable -> Prepare command model // needs setup permissions ├── Search / select a catalog row -> Inspect, not execute -└── Create Config / Edit Config / Copy agent prompt // personal workspace +└── Create config / Edit config / Copy agent prompt // personal workspace hex commands init -> Create or refresh ~/.config/hex // does not start listening hex listen -> Use saved command opt-in // never beside another listener @@ -31,7 +31,7 @@ changes the running app's opt-in; it is not a release CLI route. ```ts Enable Commands // commands.opt-in -├── Release when idle active -> Confirm Keep Microphone Ready & Enable Commands +├── Release when idle active -> Confirm Keep microphone ready & enable commands └── Load separate Moonshine model in background ├── Ready -> Recognize commands, initially awake └── Failure -> Keep opt-in; show Retry / Turn off commands @@ -127,7 +127,7 @@ is discoverability, not proof that its scope is active or its action succeeded. ### Personal Config ```ts -Create Config / hex commands init // commands.personal-config +Create config / hex commands init // commands.personal-config -> Managed Bun workspace + @hex/commands SDK -> ~/.config/hex/hex.config.ts // trusted executable user code diff --git a/docs/features/meetings.md b/docs/features/meetings.md index 704e3c00..3b6083f3 100644 --- a/docs/features/meetings.md +++ b/docs/features/meetings.md @@ -10,9 +10,9 @@ Linked checks are locators unless an executed result is recorded below. ## How To Get To It ```ts -Settings > MEETINGS > Meetings (beta) // meetings.opt-in; off by default +Settings > Meetings > Meetings (beta) // meetings.opt-in; off by default ├── On -> Meetings pane, menu-bar items, call detection, Option-Control-V -│ ├── Screen & System Audio Recording missing -> "Open Settings" row +│ ├── Screen & System Audio Recording missing -> "Open settings" row │ ├── Transcript folder -> ~/Documents/Hex Meetings by default; Reveal / choose │ └── Summarize with Apple Intelligence -> on by default; one-line availability └── Off -> Controller idles; no microphone-activity inspection, pane, or shortcut @@ -35,8 +35,8 @@ Zoom / Teams / Slack huddle / FaceTime / browser call uses the microphone -> 4 stable observations (about 2 s) // meetings.detect -> Capsule below the menu bar, focus-free // meetings.offer ├── Record -> Start recording - ├── Not Now -> Close - └── 20 s countdown -> Close // never records on timeout + ├── Not now -> Close + └── 20 s timeout -> Close // never records on timeout -> Same call is snoozed until its app is off the microphone for about 60 s ``` @@ -64,7 +64,7 @@ Record (offer, menu bar "Record Meeting", Meetings pane, or voice command) -> Settings window may stay closed Detected app leaves the microphone for about 3 s // meetings.call-ended - -> "Zoom left the call" capsule: Keep Recording / Stop & Transcribe + -> "Zoom left the call" capsule: Keep recording / Stop & transcribe -> No timeout; nothing stops automatically ``` @@ -197,14 +197,18 @@ Option-Control-V -> Paste meeting turns added since the last paste ### Meetings Pane ```ts -Header: Search meetings (title, app, notes, final transcript) + Start / Stop +Header: Search meetings (title, app, notes, final transcript) + Start recording + (the pane's one Primary button) / Stop & transcribe (Secondary, red dot, + elapsed time) List (grouped Today / Yesterday / date) └── Row: title (generated when present) · time · length · app · status chip └── Recording (red dot) / Transcribing / Interrupted / Failed; Ready in detail Detail ├── Title, status chip, date · time · duration · app -├── Actions: Copy Summary, Copy Transcript, Reveal Markdown, Summarize / -│ Regenerate Summary, Rename, Delete (finished meetings only for the last four) +├── Actions: Copy transcript, Rename (finished only), "…" overflow menu +│ └── Copy summary (stays open to show "Copied"), Reveal Markdown, Summarize / +│ Regenerate summary ("Summarizing…" disabled while running), separator, +│ Delete… (finished meetings only for the last three) ├── Errors at the top of the scroll area ├── Summary: one paragraph, at most two sentences └── Transcript: Them left, You right in accent; merged turns; timestamps; @@ -213,7 +217,8 @@ Detail Rename -> inline field; Enter or Save // meetings.rename -> manifest title; Markdown heading only while it reads the old title -> file name unchanged; empty title refused -Delete -> "Trash Recording & Markdown" (or "Trash Recording") / Cancel +Delete… (overflow menu) -> "Trash recording & Markdown" (or "Trash recording") + / Cancel, which replace the action row until answered -> exported Markdown, then Application Support/meetings/, to the Trash -> refused while recording, transcribing, or writing notes // meetings.delete ``` @@ -226,10 +231,11 @@ both stay recoverable from the Trash. Sources: `render_meetings` in `renaming_updates_the_manifest_and_an_unedited_markdown_heading`, `retitling_replaces_only_a_matching_first_heading`, `meeting_search_delete_and_rename_work_on_the_selected_meeting`, and the GPUI -layout check `meeting_turns_alternate_sides_inside_the_detail`. +layout checks `meeting_turns_alternate_sides_inside_the_detail` and +`meeting_overflow_menu_opens_beside_the_visible_actions`. Preview: `scripts/capture-preview.sh OUT meetings [--meeting-recording] -[--confirm-meeting-delete] [--meetings-empty]`. +[--confirm-meeting-delete] [--open-meeting-actions] [--meetings-empty]`. Checks: `markdown_lists_metadata_and_labeled_turns` and `export_never_overwrites_and_uses_safe_names`. @@ -270,7 +276,7 @@ Final publication complete // meetings.notes Unavailable (not eligible / Apple Intelligence off / model not ready / macOS < 26) -> Settings row says why; nothing runs Meetings pane -> title in list and detail; Summary paragraph above the transcript; - "Summarize" / "Regenerate Summary" / "Summarizing..." + "Summarize" / "Regenerate summary" / "Summarizing…" in the overflow menu Transcript ready capsule -> "Ready · " once a provisional or final title exists; subtitle "Summarizing with Apple Intelligence…", then the summary ``` diff --git a/scripts/capture-preview.sh b/scripts/capture-preview.sh index b0928606..4dce0b85 100755 --- a/scripts/capture-preview.sh +++ b/scripts/capture-preview.sh @@ -53,7 +53,7 @@ for _ in 0..<200 { } let title = window[kCGWindowName as String] as? String let bounds = window[kCGWindowBounds as String] as? [String: CGFloat] - return (title == nil || title == "" || title == "HEX") + return (title == nil || title == "" || title == "Hex") && (bounds?["Width"] ?? 0) >= 800 && (bounds?["Height"] ?? 0) >= 400 } diff --git a/scripts/test-wayland-paste.sh b/scripts/test-wayland-paste.sh index 832f8fd5..2e4e1d5b 100755 --- a/scripts/test-wayland-paste.sh +++ b/scripts/test-wayland-paste.sh @@ -59,10 +59,10 @@ if [[ "${1:-}" == --inside ]]; then app=$! for ((attempt = 0; attempt < 200; attempt++)); do kill -0 "$app" - if swaymsg -t get_tree -r | grep '"name": "HEX"' >/dev/null; then break; fi + if swaymsg -t get_tree -r | grep '"name": "Hex"' >/dev/null; then break; fi sleep 0.05 done - swaymsg -t get_tree -r | grep '"name": "HEX"' >/dev/null + swaymsg -t get_tree -r | grep '"name": "Hex"' >/dev/null # Keep GPUI connected while the paste helper creates a virtual keyboard. export HEX_WAYLAND_PASTE_OUTPUT="$HEX_WAYLAND_PASTE_OUTPUT-coexist" diff --git a/src/app_window.rs b/src/app_window.rs index de0b8874..4126475e 100644 --- a/src/app_window.rs +++ b/src/app_window.rs @@ -21,6 +21,7 @@ use crate::app_settings::{ }; use crate::application_catalog::InstalledApplication; use crate::commands::{CommandConfig, CommandInfo, CommandScope}; +use crate::design_tokens::*; use crate::desktop_activity::DesktopActivity; use crate::desktop_transcription_picker::{ ModelDeletion, TranscriptionPickerDelegate, TranscriptionPickerModel, @@ -29,16 +30,16 @@ use crate::desktop_transcription_picker::{ transcription_selection_is_active, }; use crate::desktop_ui::{ - ACCENT, ACCENT_SOFT, ACCENT_SURFACE, CANVAS, CONTROL_HEIGHT, DANGER_BORDER, DANGER_SURFACE, - FAINT, LINE, MUTED, NEGATIVE, NavigationIcon, PANE_CONTENT_WIDTH, PANE_LIST_WIDTH, - PANEL_RADIUS, POSITIVE, POSITIVE_SURFACE, SECTION_GAP, SIDEBAR_WIDTH, SURFACE, SURFACE_HOVER, - SURFACE_SELECTED, TEXT, TEXT_INPUT_HEIGHT, TEXT_SOFT, canvas_button, compact_button, - compact_header_plus_button, compact_panel, compact_panel_header, compact_plus_button, - compact_section_label, disclosure_button, empty_message, error_message, header_button, - hotkey_keycaps, list_row, listener_status, mix_color, navigation_item, pane_body, pane_content, - pane_header, pane_header_with_action, pane_list, section_label, segmented_control, - segmented_item, settings_copy, settings_panel, settings_row, settings_section_label, - sidebar_frame, sliding_segmented_control, sliding_segmented_item, toggle, window_frame, + ButtonKind, Icon, NavigationIcon, Tone, busy_label, button, danger_menu_item, detail_body, + detail_header, detail_placeholder, detail_row, detail_title, disabled_menu_item, + disclosure_button, disclosure_frame, dropdown_menu, empty_state, error_message, error_panel, + hotkey_keycaps, icon, icon_button, inline_error, list_row, list_row_meta, list_row_title, + list_section_label, listener_status, menu, menu_item, menu_separator, mix_color, + navigation_item, pane_body, pane_content, pane_detail, pane_header, pane_header_with_action, + pane_list, pane_scroll_body, panel, panel_header, section_label, segmented_control, + segmented_item, settings_copy, settings_row, settings_section_label, sidebar_frame, + sliding_segmented_control, sliding_segmented_item, small_button, status_dot, tag, toggle, + truncated_line, window_frame, }; use crate::developer_control::DeveloperPane; use crate::dictation_indicator::{DictationIndicatorEvent, DictationIndicatorSender, HudTuning}; @@ -71,7 +72,11 @@ const WINDOW_HEIGHT: f32 = 700.0; const MINIMUM_WIDTH: f32 = 860.0; const MINIMUM_HEIGHT: f32 = 560.0; const HOTKEY_MIN_WIDTH: f32 = 148.0; -const HOTKEY_SIDE_SELECTOR_WIDTH: f32 = 116.0; +/// Left / Either / Right segment widths. +const HOTKEY_SIDE_WIDTHS: [f32; 3] = [40.0, 50.0, 40.0]; +/// The side segments plus the control's 2 px padding and 1 px border per side. +const HOTKEY_SIDE_SELECTOR_WIDTH: f32 = + HOTKEY_SIDE_WIDTHS[0] + HOTKEY_SIDE_WIDTHS[1] + HOTKEY_SIDE_WIDTHS[2] + 6.0; const OPENCODE_INSTALL_RETRY_INTERVAL: Duration = Duration::from_secs(60); const PERMISSION_REFRESH_INTERVAL: Duration = Duration::from_secs(5); const MAX_OPENCODE_ERROR_BYTES: usize = 4 * 1024; @@ -83,21 +88,9 @@ fn sidebar_update_button( preview: bool, ) -> Option<gpui::Stateful<Div>> { (status == crate::sparkle::UpdateStatus::UpdateAvailable).then(|| { - div() - .id("check-for-updates-sidebar") - .h(px(30.0)) - .px_3() - .flex() - .items_center() - .rounded_sm() - .text_size(px(12.0)) - .flex_none() - .bg(rgb(ACCENT)) - .text_color(rgb(TEXT)) - .font_weight(FontWeight::SEMIBOLD) - .cursor_pointer() - .hover(|button| button.bg(mix_color(rgb(ACCENT), rgb(TEXT), 0.12))) + button(ButtonKind::Secondary, status_dot(ACCENT_TEXT)) .child("Update") + .id("check-for-updates-sidebar") .on_click(move |_, window, cx| { if !preview { window.dispatch_action(Box::new(CheckForUpdates), cx); @@ -118,92 +111,42 @@ fn microphone_picker_menu( dismiss: impl Fn(&MouseDownEvent, &mut Window, &mut App) + 'static, ) -> gpui::Stateful<Div> { let choose = Rc::new(choose); - div() + dropdown_menu() .id("microphone-picker") .debug_selector(|| "microphone-picker".into()) - .absolute() - .top(px(CONTROL_HEIGHT + 4.0)) .right_0() - .w(px(220.0)) + .w(px(DISCLOSURE_WIDTH)) .max_h(px(240.0)) - .p_2() .overflow_y_scroll() - .rounded_sm() - .border_1() - .border_color(rgb(LINE)) - .bg(rgb(SURFACE)) - .shadow_lg() - .occlude() .on_mouse_down_out(dismiss) .children(choices.into_iter().enumerate().map(|(index, device)| { let is_selected = selected == device; let label = device.clone().unwrap_or_else(|| "Automatic".into()); let choose = choose.clone(); - div() + menu_item(is_selected) .id(("microphone-choice", index)) .debug_selector(move || format!("microphone-choice-{index}")) - .w_full() - .h(px(34.0)) - .px_3() - .flex() - .items_center() - .justify_between() - .rounded_sm() - .text_size(px(12.0)) - .text_color(if is_selected { - rgb(TEXT) - } else { - rgb(TEXT_SOFT) - }) - .when(is_selected, |row| row.bg(rgb(SURFACE_SELECTED))) - .hover(|row| row.bg(rgb(SURFACE_HOVER))) - .child(label) + .child(truncated_line(FOOTNOTE, label)) .on_click(move |_, window, cx| { cx.stop_propagation(); choose(&device, window, cx); }) })) - .when_some(error, |picker, error| { - picker.child( - div() - .px_3() - .pt_2() - .text_size(px(11.0)) - .text_color(rgb(NEGATIVE)) - .child(error), - ) - }) + .when_some(error, |picker, error| picker.child(inline_error(error))) } -fn settings_pane(content: Div) -> AnyElement { - settings_pane_titled("Settings", "settings-scroll", content) -} - -fn settings_pane_titled(title: &'static str, scroll_id: &'static str, content: Div) -> AnyElement { +fn settings_pane(title: &'static str, scroll_id: &'static str, content: Div) -> AnyElement { div() .size_full() .flex() .flex_col() .child(pane_header(title)) - .child( - div() - .id(scroll_id) - .flex_1() - .overflow_y_scroll() - .px_8() - .pt_1() - .pb_7() - .child( - div().w_full().flex().justify_center().child( - content - .w_full() - .max_w(px(PANE_CONTENT_WIDTH)) - .min_w_0() - .relative() - .debug_selector(|| "settings-content".into()), - ), - ), - ) + .child(pane_scroll_body( + scroll_id, + content + .relative() + .debug_selector(|| "settings-content".into()), + )) .into_any_element() } @@ -378,7 +321,7 @@ fn open_new( WindowOptions { window_bounds: Some(WindowBounds::Windowed(bounds)), titlebar: Some(TitlebarOptions { - title: Some("HEX".into()), + title: Some("Hex".into()), appears_transparent: true, ..Default::default() }), @@ -439,6 +382,7 @@ pub struct AppWindowPreview { pub screen_recording_missing: bool, pub meeting_recording: bool, pub confirm_meeting_delete: bool, + pub open_meeting_actions: bool, pub meetings_empty: bool, } @@ -611,6 +555,7 @@ struct TranscriptLine { struct MeetingPreview { recording: bool, confirm_delete: bool, + open_actions: bool, empty: bool, } @@ -697,16 +642,17 @@ impl Render for TransformationDrag { .child( div() .w(px(180.0)) - .h(px(34.0)) + .h(px(CONTROL_HEIGHT)) .px_3() .flex() .items_center() - .rounded(px(6.0)) + .rounded(RADIUS_CONTROL) .border_1() - .border_color(rgb(0x505050)) + .border_color(rgb(LINE_STRONG)) .bg(rgb(SURFACE_SELECTED)) - .text_size(px(11.0)) - .text_color(rgb(TEXT)) + .shadow_lg() + .text_body() + .text_color(rgb(TEXT_PRIMARY)) .child(self.name.clone()), ) } @@ -766,10 +712,10 @@ fn opencode_unavailable_copy( title: "Checking for OpenCode", description: match feature { OpenCodeFeature::VoiceAction => { - "Voice Action will be available after HEX connects to OpenCode." + "Voice Action will be available after Hex connects to OpenCode." } OpenCodeFeature::Transformation => { - "OpenCode transformation will be available after HEX connects to OpenCode." + "OpenCode transformation will be available after Hex connects to OpenCode." } }, error: None, @@ -797,7 +743,7 @@ fn opencode_unavailable_copy( }), ModelCatalogState::Failed(error) => Some(OpenCodeUnavailableCopy { title: "OpenCode is unavailable", - description: "HEX could not load the OpenCode model catalog.", + description: "Hex could not load the OpenCode model catalog.", error: Some(error.clone()), can_retry: true, retry_label: "Retry", @@ -1071,6 +1017,8 @@ pub struct AppWindow { meeting_search_text: HashMap<String, String>, meeting_rename: Option<(String, ProcessingInput)>, meeting_delete_armed: Option<String>, + /// The detail's overflow menu of less common meeting actions is open. + meeting_actions_open: bool, meeting_action_error: Option<String>, meeting_copied: Option<(String, &'static str)>, /// Deterministic transcripts for the Meetings preview. @@ -1185,6 +1133,7 @@ impl AppWindow { .map(|preview| MeetingPreview { recording: preview.meeting_recording, confirm_delete: preview.confirm_meeting_delete, + open_actions: preview.open_meeting_actions, empty: preview.meetings_empty, }); let preview_choices = crate::transcription_models::choices_for_runtime; @@ -1624,6 +1573,7 @@ impl AppWindow { meeting_search_text: HashMap::new(), meeting_rename: None, meeting_delete_armed: None, + meeting_actions_open: false, meeting_action_error: None, meeting_copied: None, meeting_preview_transcripts: None, @@ -2390,6 +2340,7 @@ impl AppWindow { if self.selected_meeting.as_deref() != Some(id.as_str()) { self.meeting_rename = None; self.meeting_delete_armed = None; + self.meeting_actions_open = false; self.meeting_action_error = None; self.meeting_copied = None; } @@ -2521,6 +2472,7 @@ impl AppWindow { if preview.confirm_delete { self.meeting_delete_armed = Some(id); } + self.meeting_actions_open = preview.open_actions; } } @@ -2642,41 +2594,36 @@ impl AppWindow { fn render_history(&mut self, cx: &mut Context<Self>) -> AnyElement { let retention = self.settings.history_retention; - let search = div().w(px(220.0)).child(self.history_search.entity.clone()); - let retention_control = header_button(format!("Keep: {}", retention.label())) - .id("history-retention") - .on_click(cx.listener(|this, _, _, cx| { - this.history_retention_open = true; - cx.notify(); - })); + let search = div() + .w(px(SEARCH_FIELD_WIDTH)) + .child(self.history_search.entity.clone()); + let retention_control = button( + ButtonKind::Secondary, + format!("Keep: {}", retention.label()), + ) + .id("history-retention") + .child(icon(Icon::ChevronDown, TEXT_TERTIARY)) + .on_click(cx.listener(|this, _, _, cx| { + this.history_retention_open = true; + cx.notify(); + })); let retention_control = div().relative().child(retention_control).when( self.history_retention_open, |control| { control.child(deferred( - div() + dropdown_menu() .id("history-retention-menu") - .absolute() - .top(px(36.0)) .right_0() - .w(px(160.0)) - .p_1() - .rounded_md() - .border_1() - .border_color(rgb(LINE)) - .bg(rgb(SURFACE)) - .occlude() + .w(px(MENU_WIDTH)) .on_mouse_down_out(cx.listener(|this, _, _, cx| { this.history_retention_open = false; cx.notify(); })) .children(HistoryRetention::ALL.into_iter().enumerate().map( |(index, choice)| { - compact_button(choice.label()) + menu_item(choice == retention) .id(("history-retention-choice", index)) - .w_full() - .when(choice == retention, |item| { - item.bg(rgb(SURFACE_SELECTED)) - }) + .child(choice.label()) .on_click(cx.listener(move |this, _, _, cx| { cx.stop_propagation(); this.history_retention_open = false; @@ -2688,24 +2635,18 @@ impl AppWindow { )) }, ); - let clear = header_button(if self.history_clear_armed { - "Really clear all?" + let clear = if self.history_clear_armed { + button(ButtonKind::Danger, "Really clear all?") } else { - "Clear all" - }) + button(ButtonKind::Secondary, "Clear all") + } .id("history-clear") - .when(self.history_clear_armed, |button| { - button - .border_color(rgb(DANGER_BORDER)) - .bg(rgb(DANGER_SURFACE)) - .text_color(rgb(NEGATIVE)) - }) .on_click(cx.listener(|this, _, _, cx| this.clear_history(cx))) .into_any_element(); let header_action = div() .flex() .items_center() - .gap_3() + .gap_2() .child(search) .child(retention_control) .child(clear) @@ -2733,24 +2674,9 @@ impl AppWindow { .min_w(px(0.0)) .flex() .flex_col() - .gap_1() - .child( - div() - .w_full() - .overflow_x_hidden() - .text_size(px(12.0)) - .font_weight(FontWeight::SEMIBOLD) - .text_color(rgb(if selected { TEXT } else { TEXT_SOFT })) - .line_height(px(18.0)) - .truncate() - .child(preview), - ) - .child( - div() - .text_size(px(10.0)) - .text_color(rgb(if selected { TEXT_SOFT } else { FAINT })) - .child(meta), - ), + .gap_0p5() + .child(list_row_title(preview, selected)) + .child(list_row_meta(meta, selected)), ) .on_click(cx.listener(move |this, _, _, cx| { this.selected_history = Some(id); @@ -2767,12 +2693,13 @@ impl AppWindow { .flex_col() .child(pane_header_with_action("History", Some(header_action))) .child( - pane_body().p_5().child( + pane_body().child( pane_content() .flex_row() .gap_5() - .child(pane_list_card( + .child(pane_list( "history-list", + PANE_LIST_WIDTH, if retention_off { Some("History is off. New dictations are not retained.") } else if self.history_entries.is_empty() @@ -2786,15 +2713,7 @@ impl AppWindow { self.history_error.clone(), rows, )) - .child( - compact_panel() - .flex_1() - .min_w(px(0.0)) - .h_full() - .flex() - .flex_col() - .child(self.render_history_detail(cx)), - ), + .child(pane_detail(self.render_history_detail(cx))), ), ) .into_any_element() @@ -2811,12 +2730,8 @@ impl AppWindow { let copied = self.history_copied == Some(id); let show_raw = entry.raw_text.trim() != entry.final_text.trim(); let action_button = |label: &'static str, id_suffix: &'static str| { - canvas_button() + button(ButtonKind::Secondary, label) .id(SharedString::from(format!("history-action-{id_suffix}"))) - .h(px(30.0)) - .px_3() - .text_size(px(12.0)) - .child(label) }; let mut latency = format!( "{} ms audio · {} ms inference", @@ -2825,97 +2740,70 @@ impl AppWindow { if entry.total_ms > 0 { latency.push_str(&format!(" · {} ms total", entry.total_ms)); } + let section = |label: &'static str, first: bool| { + div() + .w_full() + .when(!first, |section| { + section.mt_5().pt_5().border_t_1().border_color(rgb(LINE)) + }) + .child(section_label(label)) + }; div() - .id("history-detail") .flex_1() .min_w(px(0.0)) .h_full() - .overflow_y_scroll() - .px_6() - .py_6() + .flex() + .flex_col() + .child(detail_header( + detail_title(entry.kind.label()), + vec![event_age(entry.timestamp_ms).into_any_element()], + vec![ + action_button(if copied { "Copied" } else { "Copy" }, "copy") + .on_click( + cx.listener(move |this, _, _, cx| this.copy_history_entry(id, cx)), + ) + .into_any_element(), + action_button("Delete", "delete") + .on_click( + cx.listener(move |this, _, _, cx| this.delete_history_entry(id, cx)), + ) + .into_any_element(), + ], + )) .child( - div() - .w_full() - .flex() - .flex_col() - .child( - div() - .flex() - .items_center() - .gap_3() - .child( - div() - .text_size(px(18.0)) - .font_weight(FontWeight::SEMIBOLD) - .child(entry.kind.label()), - ) - .child( - div() - .text_size(px(11.0)) - .text_color(rgb(FAINT)) - .child(event_age(entry.timestamp_ms)), - ), - ) - .child( - div() - .pt_4() - .flex() - .items_center() - .gap_3() - .child( - action_button(if copied { "Copied" } else { "Copy" }, "copy") - .on_click(cx.listener(move |this, _, _, cx| { - this.copy_history_entry(id, cx) - })), - ) - .child(action_button("Delete", "delete").on_click(cx.listener( - move |this, _, _, cx| this.delete_history_entry(id, cx), - ))), - ) + detail_body("history-detail") .child( - div() - .mt_5() - .pt_5() - .border_t_1() - .border_color(rgb(LINE)) - .child(section_label("Final text")) - .child( - div() - .pt_3() - .text_size(px(12.0)) - .line_height(px(19.0)) - .text_color(rgb(TEXT)) - .child(entry.final_text.clone()), - ), + section("Final text", true).child( + div() + .pt_2() + .text_body() + .text_color(rgb(TEXT_PRIMARY)) + .child(entry.final_text.clone()), + ), ) .when(show_raw, |detail| { detail.child( - div() - .mt_5() - .pt_5() - .border_t_1() - .border_color(rgb(LINE)) - .child(section_label("Raw transcript")) - .child( - div() - .pt_3() - .text_size(px(12.0)) - .line_height(px(19.0)) - .text_color(rgb(MUTED)) - .child(entry.raw_text.clone()), - ), + section("Raw transcript", false).child( + div() + .pt_2() + .text_body() + .text_color(rgb(TEXT_TERTIARY)) + .child(entry.raw_text.clone()), + ), ) }) .child( div() + .w_full() .mt_5() - .pt_5() + .flex() + .flex_col() .border_t_1() .border_color(rgb(LINE)) - .when_some(entry.application.clone(), |detail, application| { - detail.child(detail_row("Application", application)) + .when_some(entry.application.clone(), |rows, application| { + rows.child(detail_row("Application", application)) }) - .when_some(entry.processing.clone(), |detail, processing| { + .when_some(entry.processing.clone(), |rows, processing| { let mut summary = format!( "{} · {} ms", processing.profile, processing.latency_ms @@ -2923,7 +2811,7 @@ impl AppWindow { if let Some(fallback) = &processing.fallback { summary.push_str(&format!(" · fell back: {fallback}")); } - detail.child(detail_row("Processing", summary)) + rows.child(detail_row("Processing", summary)) }) .child(detail_row("Latency", latency)), ), @@ -2949,13 +2837,7 @@ impl AppWindow { }); sidebar_frame() - .w(px(SIDEBAR_WIDTH)) - .px(px(14.0)) - .pt(px(52.0)) - .pb_4() - .flex() - .flex_col() - .child(div().flex().flex_col().gap(px(2.0)).children(items)) + .child(div().flex().flex_col().gap_0p5().children(items)) .child(div().flex_1()) .child( div() @@ -2964,14 +2846,14 @@ impl AppWindow { .items_center() .justify_between() .gap_2() - .h(px(30.0)) + .h(px(CONTROL_HEIGHT)) .mt_3() .child( div() .flex_none() - .pl_2() - .text_size(px(11.0)) - .text_color(rgb(MUTED)) + .pl_3() + .text_caption() + .text_color(rgb(TEXT_TERTIARY)) .child(format!("Version {}", env!("CARGO_PKG_VERSION"))), ) .children(sidebar_update_button(self.update_status, self.preview)), @@ -3014,7 +2896,7 @@ impl AppWindow { self.hotkey_capture, HotkeyCaptureState::Saved { kind: active, .. } if active == kind ) { - rgb(POSITIVE_SURFACE) + rgb(SUCCESS_SURFACE) } else { rgb(DANGER_SURFACE) }; @@ -3056,7 +2938,7 @@ impl AppWindow { .size(px(5.0 + pulse * 3.0)) .ml_2() .rounded_full() - .bg(rgb(NEGATIVE)) + .bg(rgb(DANGER)) .opacity(0.6 + pulse * 0.4), ) .when(!modifiers.is_empty() && message.is_none(), |content| { @@ -3071,29 +2953,18 @@ impl AppWindow { }) .child( div() - .text_size(px(11.0)) - .text_color(rgb(TEXT_SOFT)) + .text_footnote() + .text_color(rgb(TEXT_SECONDARY)) .child(label), ) .child( - div() + small_button(ButtonKind::Secondary, "Cancel") .id(match kind { HotkeyKind::Dictation => "cancel-dictation-hotkey-capture", HotkeyKind::Edit => "cancel-edit-hotkey-capture", HotkeyKind::PasteLast => "cancel-paste-last-hotkey-capture", }) - .h(px(26.0)) .ml_1() - .px_2() - .flex() - .items_center() - .rounded(px(4.0)) - .text_size(px(11.0)) - .text_color(rgb(MUTED)) - .hover(|button| { - button.bg(rgb(SURFACE_HOVER)).text_color(rgb(TEXT_SOFT)) - }) - .child("Cancel") .on_click(cx.listener(|this, _, _, cx| { cx.stop_propagation(); this.cancel_hotkey_capture(cx); @@ -3105,13 +2976,7 @@ impl AppWindow { .flex() .items_center() .gap_2() - .child( - div() - .text_size(px(10.0)) - .font_weight(FontWeight::SEMIBOLD) - .text_color(rgb(TEXT_SOFT)) - .child("Saved"), - ) + .child(tag(Tone::Success, "Saved")) .child(hotkey_keycaps(binding_keycaps.clone(), 1.0)) .into_any_element(), HotkeyCaptureState::Idle @@ -3123,8 +2988,8 @@ impl AppWindow { .child(hotkey_keycaps(binding_keycaps, 1.0)) .child( div() - .text_size(px(11.0)) - .text_color(rgb(TEXT_SOFT)) + .text_footnote() + .text_color(rgb(TEXT_SECONDARY)) .child("Change shortcut"), ) .into_any_element(), @@ -3138,16 +3003,16 @@ impl AppWindow { .track_focus(&self.hotkey_focus) .w(px(control_width)) .min_w(px(HOTKEY_MIN_WIDTH)) - .h(px(32.0)) - .px(px(4.0)) + .h(px(CONTROL_HEIGHT)) + .px(px(2.0)) .flex() .items_center() .overflow_hidden() - .rounded(px(6.0)) + .rounded(RADIUS_CONTROL) .border_1() .border_color(mix_color( rgb(LINE), - rgb(TEXT_SOFT), + rgb(TEXT_SECONDARY), control_intensity * 0.55, )) .bg(mix_color( @@ -3187,7 +3052,7 @@ impl AppWindow { side_animation.set_enabled(side.is_some()); let side_position = side_animation.render_position(window).clamp(0.0, 1.0); let selected = side.unwrap_or(ModifierSide::Either); - let side_widths = [34.0, 44.0, 34.0]; + let side_widths = HOTKEY_SIDE_WIDTHS; let side_selection_spring = &mut self.hotkey_side_selection_springs[hotkey_kind_index(kind)]; side_selection_spring.set_target(hotkey_side_index(selected) as f32); @@ -3213,8 +3078,7 @@ impl AppWindow { let candidate = hotkey_side_binding(&self.settings, kind, side); sliding_segmented_item(side_widths[index], selected == side) .id(("hotkey-side", hotkey_kind_index(kind) * 3 + index)) - .text_size(px(10.0)) - .when(candidate.is_none(), |item| item.opacity(0.35)) + .when(candidate.is_none(), |item| item.opacity(DISABLED_OPACITY)) .child(label) .on_click(cx.listener(move |this, _, _, cx| { let Some(candidate) = @@ -3410,7 +3274,7 @@ impl AppWindow { div() .id(("text-replacement", index)) .w_full() - .px_3() + .px_4() .py_2() .flex() .items_center() @@ -3424,13 +3288,7 @@ impl AppWindow { .min_w(px(0.0)) .child(inputs.matched_phrase.entity.clone()), ) - .child( - div() - .px_1() - .text_size(px(11.0)) - .text_color(rgb(FAINT)) - .child("→"), - ) + .child(icon(Icon::ArrowRight, TEXT_TERTIARY)) .child( div() .flex_1() @@ -3438,14 +3296,8 @@ impl AppWindow { .child(inputs.output.entity.clone()), ) .child( - compact_button("×") + icon_button(ButtonKind::Ghost, Icon::Close) .id(("remove-text-replacement", index)) - .size(px(30.0)) - .px_0() - .justify_center() - .flex_none() - .text_size(px(14.0)) - .text_color(rgb(MUTED)) .on_click(cx.listener(move |this, _, _, cx| { if this.update_mode_settings(selection, cx, |mode| { mode.replacements.remove(index); @@ -3458,7 +3310,8 @@ impl AppWindow { }) .collect::<Vec<_>>(); - let add = compact_header_plus_button() + let add = icon_button(ButtonKind::Ghost, Icon::Plus) + .mr(px(-SPACE_2)) .id("add-mode-replacement") .on_click(cx.listener(move |this, _, window, cx| { let replacement = TextReplacement::default(); @@ -3473,15 +3326,15 @@ impl AppWindow { })) .into_any_element(); - compact_panel() - .child(compact_panel_header("Corrections", Some(add))) + panel() + .child(panel_header("Corrections", Some(add))) .when(replacement_count == 0, |panel| { panel.child( div() .px_3() .py_3() - .text_size(px(11.0)) - .text_color(rgb(MUTED)) + .text_footnote() + .text_color(rgb(TEXT_TERTIARY)) .child("No corrections in this mode."), ) }) @@ -3610,23 +3463,23 @@ impl AppWindow { let warning_count = warnings.len(); Some( div() - .child(settings_section_label("PERMISSIONS NEEDED")) - .child( - settings_panel().children(warnings.into_iter().enumerate().map( - |(index, warning)| { - let (name, description) = permission_warning_copy(warning.kind); - let action = header_button(permission_action_label(warning.action)) - .id(permission_warning_id(warning.kind)) - .bg(rgb(CANVAS)) - .on_click(cx.listener(move |this, _, _, cx| { - this.perform_permission_action(warning); - cx.notify(); - })); - settings_row(name, description, action) - .when(index + 1 == warning_count, |row| row.border_b_0()) - }, - )), - ) + .child(settings_section_label("Permissions needed")) + .child(panel().children(warnings.into_iter().enumerate().map( + |(index, warning)| { + let (name, description) = permission_warning_copy(warning.kind); + let action = button( + ButtonKind::Secondary, + permission_action_label(warning.action), + ) + .id(permission_warning_id(warning.kind)) + .on_click(cx.listener(move |this, _, _, cx| { + this.perform_permission_action(warning); + cx.notify(); + })); + settings_row(name, description, action) + .when(index + 1 == warning_count, |row| row.border_b_0()) + }, + ))) .into_any_element(), ) } @@ -3644,11 +3497,8 @@ impl AppWindow { .flatten() })?; let action = if dictation_notice.is_some() { - compact_button("Choose model") + button(ButtonKind::Primary, "Choose model") .id("choose-required-model") - .bg(rgb(ACCENT)) - .text_color(rgb(TEXT)) - .font_weight(FontWeight::MEDIUM) .on_click(cx.listener(|this, _, _, cx| { this.transcription_picker_language = Some(this.settings.transcription.language.clone()); @@ -3664,9 +3514,8 @@ impl AppWindow { matches!(self.command_model_status, CommandModelStatus::Failed), |controls| { controls.child( - header_button("Retry") + button(ButtonKind::Secondary, "Retry") .id("retry-command-model") - .bg(rgb(CANVAS)) .on_click(cx.listener(|this, _, _, cx| { if !this.preview { cx.dispatch_action(&RetryCommands); @@ -3676,9 +3525,8 @@ impl AppWindow { }, ) .child( - header_button("Turn off commands") + button(ButtonKind::Secondary, "Turn off commands") .id("disable-unavailable-commands") - .bg(rgb(CANVAS)) .on_click(cx.listener(|this, _, _, cx| { if let Err(error) = this.developer_set_commands_enabled(false, cx) { tracing::warn!(%error, "could not disable voice commands"); @@ -3694,21 +3542,16 @@ impl AppWindow { .flex() .justify_center() .flex_shrink_0() - .px_8() + .px(px(PANE_GUTTER)) .pt_6() .child( - div() - .w_full() + panel() .max_w(px(PANE_CONTENT_WIDTH)) .px_4() - .py_3p5() + .py_3() .flex() .items_center() .gap_4() - .rounded(px(PANEL_RADIUS)) - .border_1() - .border_color(rgb(LINE)) - .bg(rgb(SURFACE)) .child( div() .flex_1() @@ -3788,7 +3631,6 @@ impl AppWindow { .map(|(index, (label, volume))| { sliding_segmented_item(34.0, selected_volume == index) .id(("sound-volume", index)) - .text_size(px(10.0)) .child(label) .on_click(cx.listener(move |this, _, _, cx| { if !this.update_settings(cx, |settings| { @@ -3808,7 +3650,8 @@ impl AppWindow { ); let launch_at_login_control = if self.launch_at_login_status.is_none() { div() - .text_color(rgb(MUTED)) + .text_footnote() + .text_color(rgb(TEXT_TERTIARY)) .child(if self.login_item_worker.is_some() { "Checking…" } else { @@ -3816,7 +3659,7 @@ impl AppWindow { }) .into_any_element() } else if self.launch_at_login_status == Some(LoginItemStatus::RequiresApproval) { - compact_button("Open Settings") + button(ButtonKind::Secondary, "Open settings") .id("launch-at-login-approval") .on_click(cx.listener(|this, _, _, cx| { this.request_login_item(LoginItemRequest::OpenSettings); @@ -3910,7 +3753,7 @@ impl AppWindow { .items_center() .gap_2() .child( - header_button("Cancel") + button(ButtonKind::Secondary, "Cancel") .id("cancel-release-microphone") .on_click(cx.listener(|this, _, _, cx| { this.pending_microphone_policy = None; @@ -3918,10 +3761,8 @@ impl AppWindow { })), ) .child( - header_button("Release microphone") + button(ButtonKind::Primary, "Release microphone") .id("confirm-release-microphone") - .bg(rgb(SURFACE_SELECTED)) - .text_color(rgb(TEXT)) .on_click(cx.listener(|this, _, _, cx| { if let Err(error) = this.update_microphone_policy( MicrophonePolicyChange::DisableCommandsAndRelease, @@ -3934,214 +3775,204 @@ impl AppWindow { ) }); settings_pane( + "Settings", + "settings-scroll", div() - .children(permission_warnings) - .child(settings_section_label("DICTATION")) - .child( - settings_panel() - .child(settings_row( - "Local transcription", - "Language and on-device speech model", - transcription_control, - )) - .child(settings_row( - "Microphone", - "Input device for dictation and commands", - div() - .relative() - .flex_none() - .child( - disclosure_button(microphone_label) - .id("microphone-setting") - .on_click(cx.listener(|this, _, _, cx| { - this.toggle_microphone_picker(cx) - })), - ) - .children(microphone_picker.map(deferred)), - )) - .when( - transcription_model.supports_recognition_hints, - |settings| { - settings.child(settings_row( - "Recognition hints", - "Names and terms to prime the speech model", - div() - .w(px(320.0)) - .flex_none() - .child( - self.transcription_hints.entity.clone(), - ), - )) - }, - ) - .child( - settings_row( - "Dictation shortcut", - "Hold to dictate, release to transcribe", - hotkey_control, - ) - .border_b_0() - .id("dictation-hotkey-setting"), - ), - ) - .child(settings_section_label("BEHAVIOR")) - .child( - settings_panel() - .child( - settings_row( - "While dictating", - "Mute or pause other audio during dictation", - audio_behavior, - ) - .id("recording-audio-setting"), - ) - .child(settings_row( - "Microphone mode", - if self.settings.release_microphone_while_idle { - "Opens only while holding the shortcut. Disables pre-roll and commands." - } else { - "Keeps the microphone open for instant start and pre-roll." - }, - microphone_mode, - )) - .children(microphone_confirmation) - .child( - settings_row( - "Double-tap to lock", - "Double-tap the shortcut for hands-free dictation", - toggle(double_tap_position), - ) - .id("double-tap-setting") + .children(permission_warnings) + .child(settings_section_label("Dictation")) + .child( + panel() + .child(settings_row( + "Local transcription", + "Language and on-device speech model", + transcription_control, + )) + .child(settings_row( + "Microphone", + "Input device for dictation and commands", + div() + .relative() + .flex_none() + .child( + disclosure_button(microphone_label) + .id("microphone-setting") .on_click(cx.listener(|this, _, _, cx| { - let enabled = !this.settings.double_tap_lock; - this.set_double_tap_lock(enabled, cx); + this.toggle_microphone_picker(cx) })), + ) + .children(microphone_picker.map(deferred)), + )) + .when( + transcription_model.supports_recognition_hints, + |settings| { + settings.child(settings_row( + "Recognition hints", + "Names and terms to prime the speech model", + div() + .w(px(320.0)) + .flex_none() + .child( + self.transcription_hints.entity.clone(), + ), + )) + }, + ) + .child( + settings_row( + "Dictation shortcut", + "Hold to dictate, release to transcribe", + hotkey_control, + ) + .border_b_0() + .id("dictation-hotkey-setting"), + ), + ) + .child(settings_section_label("Behavior")) + .child( + panel() + .child( + settings_row( + "While dictating", + "Mute or pause other audio during dictation", + audio_behavior, + ) + .id("recording-audio-setting"), + ) + .child(settings_row( + "Microphone mode", + if self.settings.release_microphone_while_idle { + "Opens only while holding the shortcut. Disables pre-roll and commands." + } else { + "Keeps the microphone open for instant start and pre-roll" + }, + microphone_mode, + )) + .children(microphone_confirmation) + .child( + settings_row( + "Double-tap to lock", + "Double-tap the shortcut for hands-free dictation", + toggle(double_tap_position), + ) + .id("double-tap-setting") + .on_click(cx.listener(|this, _, _, cx| { + let enabled = !this.settings.double_tap_lock; + this.set_double_tap_lock(enabled, cx); + })), + ) + .child( + div() + .h(px(SETTINGS_ROW_MIN_HEIGHT * double_tap_only_visibility)) + .overflow_hidden() + .opacity(double_tap_only_visibility) + .child( + settings_row( + "Double-tap only", + "Wait for two complete shortcut taps before recording", + toggle(if self.settings.double_tap_only { 1.0 } else { 0.0 }), ) - .child( - div() - .h(px(72.0 * double_tap_only_visibility)) - .overflow_hidden() - .opacity(double_tap_only_visibility) - .child( - settings_row( - "Double-tap only", - "Wait for two complete shortcut taps before recording", - toggle(if self.settings.double_tap_only { 1.0 } else { 0.0 }), - ) - .id("double-tap-only-setting") + .id("double-tap-only-setting") + .on_click(cx.listener(|this, _, _, cx| { + let enabled = !this.settings.double_tap_only; + this.set_double_tap_only(enabled, cx); + })), + ), + ) + .child( + settings_row( + "Transcribe during pauses", + "Finish long dictations sooner by transcribing at each pause", + toggle(incremental_position), + ) + .id("incremental-transcription-setting") + .on_click(cx.listener(|this, _, _, cx| { + let enabled = !this.settings.incremental_transcription; + if this.update_settings(cx, |settings| { + settings.incremental_transcription = enabled + }) { + this.incremental_toggle.set_enabled(enabled); + } + })), + ) + .child( + settings_row( + "Paste last dictation", + "Re-paste the most recent dictation", + div() + .flex() + .items_center() + .gap_2() + .child(paste_last_control) + .when(self.settings.paste_last_hotkey.is_some(), |row| { + row.child( + button(ButtonKind::Secondary, "Clear") + .id("disable-paste-last-hotkey") .on_click(cx.listener(|this, _, _, cx| { - let enabled = !this.settings.double_tap_only; - this.set_double_tap_only(enabled, cx); + this.update_settings(cx, |settings| settings.paste_last_hotkey = None); })), - ), - ) - .child( - settings_row( - "Transcribe during pauses", - "Finish long dictations sooner by transcribing at each pause", - toggle(incremental_position), - ) - .id("incremental-transcription-setting") - .on_click(cx.listener(|this, _, _, cx| { - let enabled = !this.settings.incremental_transcription; - if this.update_settings(cx, |settings| { - settings.incremental_transcription = enabled - }) { - this.incremental_toggle.set_enabled(enabled); - } - })), - ) - .child( - settings_row( - "Paste last dictation", - "Re-paste the most recent dictation", - div() - .flex() - .items_center() - .gap_2() - .child(paste_last_control) - .when(self.settings.paste_last_hotkey.is_some(), |row| { - row.child( - canvas_button() - .id("disable-paste-last-hotkey") - .h(px(32.0)) - .px_2p5() - .text_size(px(11.0)) - .text_color(rgb(MUTED)) - .child("Clear") - .on_click(cx.listener(|this, _, _, cx| { - this.update_settings(cx, |settings| settings.paste_last_hotkey = None); - })), - ) - }), ) - .border_b_0() - .id("paste-last-hotkey-setting"), - ) + }), ) - .child(settings_section_label("MEETINGS")) - .child(meetings_section) - .child(settings_section_label("APPLICATION")) - .child( - settings_panel() - .child( - settings_row( - "Launch at login", - "Start HEX when you log in", - launch_at_login_control, - ) - .id("launch-at-login-setting") - .when( - matches!(self.launch_at_login_status, Some(LoginItemStatus::Enabled | LoginItemStatus::Disabled)) - && (self.preview || self.login_item_worker.is_some()), - |row| { - row.on_click(cx.listener( - |this, _, _, cx| { - let enabled = !this.launch_at_login_toggle.enabled(); - this.set_launch_at_login(enabled, cx); - }, - )) - }, - ), - ) - .when_some( - self.launch_at_login_error.clone(), - |settings, error| { - settings.child( - div() - .px_4() - .py_2() - .text_size(px(11.0)) - .text_color(rgb(NEGATIVE)) - .child(error), - ) + .border_b_0() + .id("paste-last-hotkey-setting"), + ) + ) + .child(settings_section_label("Meetings")) + .child(meetings_section) + .child(settings_section_label("Application")) + .child( + panel() + .child( + settings_row( + "Launch at login", + "Start Hex when you log in", + launch_at_login_control, + ) + .id("launch-at-login-setting") + .when( + matches!(self.launch_at_login_status, Some(LoginItemStatus::Enabled | LoginItemStatus::Disabled)) + && (self.preview || self.login_item_worker.is_some()), + |row| { + row.on_click(cx.listener( + |this, _, _, cx| { + let enabled = !this.launch_at_login_toggle.enabled(); + this.set_launch_at_login(enabled, cx); }, - ) - .child( - settings_row( - "Show Dock icon", - "Keep HEX in the menu bar when Settings is closed", - toggle(dock_icon_position), - ) - .id("dock-icon-setting") - .on_click(cx.listener(|this, _, _, cx| { - if !this.update_settings(cx, |settings| settings.show_dock_icon = !settings.show_dock_icon) { - return; - } - this.dock_icon_toggle - .set_enabled(this.settings.show_dock_icon); - })), - ) - .child( - settings_row( - "Sound volume", - "Recording, cancellation, and error tones", - sound_volume, - ) - .border_b_0() - .id("sound-effects-setting"), - ), + )) + }, ), + ) + .when_some( + self.launch_at_login_error.clone(), + |settings, error| { + settings.child(inline_error(error)) + }, + ) + .child( + settings_row( + "Show Dock icon", + "Keep Hex in the menu bar when Settings is closed", + toggle(dock_icon_position), + ) + .id("dock-icon-setting") + .on_click(cx.listener(|this, _, _, cx| { + if !this.update_settings(cx, |settings| settings.show_dock_icon = !settings.show_dock_icon) { + return; + } + this.dock_icon_toggle + .set_enabled(this.settings.show_dock_icon); + })), + ) + .child( + settings_row( + "Sound volume", + "Recording, cancellation, and error tones", + sound_volume, + ) + .border_b_0() + .id("sound-effects-setting"), + ), + ), ) } @@ -4155,11 +3986,10 @@ impl AppWindow { .map(|relative| format!("~/{}", relative.display())) .unwrap_or_else(|_| folder.display().to_string()); let permission_control = if self.screen_recording_permitted { - setup_ready_badge() + tag(Tone::Success, "Ready").into_any_element() } else { - header_button("Open Settings") + button(ButtonKind::Secondary, "Open settings") .id("meeting-screen-recording") - .bg(rgb(CANVAS)) .on_click(cx.listener(|this, _, _, cx| { if !crate::meeting::request_screen_recording() { crate::meeting::open_screen_recording_settings(); @@ -4169,7 +3999,7 @@ impl AppWindow { })) .into_any_element() }; - settings_panel() + panel() .child( settings_row( "Meetings (beta)", @@ -4201,7 +4031,7 @@ impl AppWindow { if self.screen_recording_permitted { "Captures the other participants' audio" } else { - "Required to hear the other participants. Allow HEX, then reopen it." + "Required to hear the other participants. Allow Hex, then reopen it." }, permission_control, )) @@ -4213,13 +4043,8 @@ impl AppWindow { .items_center() .gap_2() .child( - canvas_button() + button(ButtonKind::Secondary, "Reveal") .id("reveal-meeting-folder") - .h(px(32.0)) - .px_2p5() - .text_size(px(11.0)) - .text_color(rgb(MUTED)) - .child("Reveal") .on_click(cx.listener(|this, _, _, cx| { let folder = this.settings.meetings.resolved_output_folder(); @@ -4258,14 +4083,7 @@ impl AppWindow { })), ) .when_some(self.meeting_folder_error.clone(), |panel, error| { - panel.child( - div() - .px_4() - .py_2() - .text_size(px(11.0)) - .text_color(rgb(NEGATIVE)) - .child(error), - ) + panel.child(inline_error(error)) }) }) } @@ -4318,18 +4136,16 @@ impl AppWindow { fn render_setup(&mut self, cx: &mut Context<Self>) -> AnyElement { let status = self.setup_status; let microphone = match status.microphone { - PermissionState::Ready => setup_ready_badge(), - PermissionState::NeedsRequest => header_button("Allow") + PermissionState::Ready => tag(Tone::Success, "Ready").into_any_element(), + PermissionState::NeedsRequest => button(ButtonKind::Secondary, "Allow") .id("setup-microphone") - .bg(rgb(CANVAS)) .on_click(cx.listener(|_this, _, _, cx| { crate::onboarding::request_microphone(); cx.notify(); })) .into_any_element(), - PermissionState::NeedsSettings => header_button("Open Settings") + PermissionState::NeedsSettings => button(ButtonKind::Secondary, "Open settings") .id("setup-microphone") - .bg(rgb(CANVAS)) .on_click(cx.listener(|_this, _, _, cx| { crate::onboarding::open_permission_settings("microphone"); cx.notify(); @@ -4343,15 +4159,14 @@ impl AppWindow { if self.settings.commands_enabled { "Hear commands and dictation." } else { - "Record dictation while you hold the shortcut." + "Record dictation while you hold the shortcut" }, microphone, )); } if status.input_monitoring != PermissionState::Ready { - let input_monitoring = header_button("Grant Access") + let input_monitoring = button(ButtonKind::Secondary, "Grant access") .id("setup-input-monitoring") - .bg(rgb(CANVAS)) .on_click(cx.listener(|_this, _, _, cx| { crate::permission_guide::show_input_monitoring(); cx.notify(); @@ -4359,14 +4174,13 @@ impl AppWindow { .into_any_element(); permission_items.push(( "Input Monitoring", - "Recognize the dictation shortcut anywhere.", + "Recognize the dictation shortcut anywhere", input_monitoring, )); } if status.accessibility != PermissionState::Ready { - let accessibility = header_button("Grant Access") + let accessibility = button(ButtonKind::Secondary, "Grant access") .id("setup-accessibility") - .bg(rgb(CANVAS)) .on_click(cx.listener(|_this, _, _, cx| { crate::permission_guide::show_accessibility(); cx.notify(); @@ -4377,7 +4191,7 @@ impl AppWindow { if self.settings.commands_enabled { "Paste text and run keyboard commands." } else { - "Paste completed dictation into the foreground app." + "Paste completed dictation into the foreground app" }, accessibility, )); @@ -4395,13 +4209,10 @@ impl AppWindow { let model_notice = dictation_model_notice(status, self.transcription_status.model.is_some()); let models = if models_ready { - setup_ready_badge() + tag(Tone::Success, "Ready").into_any_element() } else { - compact_button("Choose model") + button(ButtonKind::Primary, "Choose model") .id("setup-models") - .bg(rgb(ACCENT)) - .text_color(rgb(TEXT)) - .font_weight(FontWeight::SEMIBOLD) .on_click(cx.listener(|this, _, _, cx| { this.transcription_picker_language = Some(this.settings.transcription.language.clone()); @@ -4421,7 +4232,7 @@ impl AppWindow { .flex() .items_center() .justify_center() - .bg(rgba(0x000000dd)) + .bg(rgba(SCRIM)) .child( div() .id("setup") @@ -4430,7 +4241,7 @@ impl AppWindow { .flex() .flex_col() .gap_4() - .rounded(px(PANEL_RADIUS + 2.0)) + .rounded(RADIUS_MODAL) .border_1() .border_color(rgb(LINE)) .bg(rgb(CANVAS)) @@ -4439,16 +4250,11 @@ impl AppWindow { .flex() .flex_col() .gap_1() + .child(div().text_headline().child("Set up Hex")) .child( div() - .text_size(px(18.0)) - .font_weight(FontWeight::SEMIBOLD) - .child("Set up HEX"), - ) - .child( - div() - .text_size(px(12.0)) - .text_color(rgb(MUTED)) + .text_footnote() + .text_color(rgb(TEXT_TERTIARY)) .child("Grant permissions and choose a local model to start dictating."), ), ) @@ -4458,8 +4264,8 @@ impl AppWindow { .flex() .flex_col() .gap_2() - .child(setup_group_label("PERMISSIONS NEEDED")) - .child(settings_panel().children(permission_rows)), + .child(section_label("Permissions needed").px_4()) + .child(panel().children(permission_rows)), ) }) .child( @@ -4467,9 +4273,9 @@ impl AppWindow { .flex() .flex_col() .gap_2() - .child(setup_group_label("LOCAL MODELS")) + .child(section_label("Local models").px_4()) .child( - settings_panel().child( + panel().child( settings_row( if let Some((title, _)) = model_notice { title @@ -4490,9 +4296,8 @@ impl AppWindow { .when(crate::DEVELOPER_FEATURES_ENABLED, |setup| { setup.child( div() - .text_size(px(10.0)) - .line_height(px(16.0)) - .text_color(rgb(FAINT)) + .text_footnote() + .text_color(rgb(TEXT_TERTIARY)) .child("Meeting recording requests Screen & System Audio Recording separately when you use it."), ) }), @@ -4502,7 +4307,7 @@ impl AppWindow { fn render_voice_action(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement { let enabled = self.settings.voice_action.enabled; - let (status, description) = voice_action_status_copy(enabled); + let description = voice_action_description(enabled); let position = self .voice_action_inputs .enabled_toggle @@ -4511,13 +4316,6 @@ impl AppWindow { .id("voice-action-enabled") .flex() .items_center() - .gap_3() - .child( - div() - .text_size(px(11.0)) - .text_color(rgb(MUTED)) - .child(status), - ) .child(toggle(position)) .on_click(cx.listener(|this, _, _, cx| { this.cancel_hotkey_capture(cx); @@ -4556,19 +4354,11 @@ impl AppWindow { ) .mt_4(); Some( - settings_panel() + panel() .p_4() .child(settings_copy(copy.title, copy.description)) .when_some(copy.error, |card, error| { - card.child( - div() - .mt_3() - .rounded_sm() - .border_1() - .border_color(rgb(DANGER_BORDER)) - .bg(rgb(DANGER_SURFACE)) - .child(error_message("OpenCode reported:", error)), - ) + card.child(error_panel("OpenCode reported:", error).mt_3()) }) .when(show_actions, |card| card.child(actions)) .into_any_element(), @@ -4576,26 +4366,25 @@ impl AppWindow { } else { Some(self.render_voice_action_processing(window, cx)) }; - settings_pane_titled( + settings_pane( "Voice Action", "voice-action-scroll", div() - .child(settings_section_label("VOICE ACTION")) - .child(settings_panel().child( + .child(panel().mt_6().child( settings_row("Enable Voice Action", description, enabled_control).border_b_0(), )) .when_some(self.voice_action_inputs.error.clone(), |column, error| { column.child(error_message("Could not update Voice Action:", error)) }) - .child(settings_section_label("CAPTURE")) + .child(settings_section_label("Capture")) .child( - settings_panel().child( + panel().child( settings_row( "Shortcut", if enabled { - "Hold to speak; selected text is included automatically." + "Hold to speak; selected text is included automatically" } else { - "Saved for when Voice Action is enabled." + "Saved for when Voice Action is enabled" }, hotkey, ) @@ -4605,7 +4394,7 @@ impl AppWindow { ) .when_some(processing, |column, processing| { column - .child(settings_section_label("PROCESSING")) + .child(settings_section_label("Processing")) .child(processing) }), ) @@ -4719,7 +4508,7 @@ impl AppWindow { let entity = cx.new(|cx| { TextInput::multiline( cx, - "Names and terms Whisper should expect, e.g. OpenCode, Effect...", + "Names and terms Whisper should expect, e.g. OpenCode, Effect…", initial, ) }); @@ -4848,7 +4637,7 @@ impl AppWindow { cx, ), prompt: Self::multiline_processing_input( - "Describe how OpenCode should rewrite your dictation...", + "Describe how OpenCode should rewrite your dictation…", &processing.prompt, cx, ), @@ -4964,13 +4753,8 @@ impl AppWindow { } fn render_modes(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement { - let compact = window.viewport_size().width < px(980.0); - let mode_list_width = if compact { 196.0 } else { 220.0 }; - let add = compact_header_plus_button() + let add = icon_button(ButtonKind::Secondary, Icon::Plus) .id("add-dictation-mode") - .border_1() - .border_color(rgb(LINE)) - .bg(rgb(SURFACE)) .on_click(cx.listener(|this, _, window, cx| { if let Some(selection) = this.add_mode(cx) { this.select_mode(selection, window, cx); @@ -5036,28 +4820,18 @@ impl AppWindow { Some(add.into_any_element()), )) .child( - pane_body().p_5().child( + pane_body().child( pane_content() .flex_row() .gap_5() - .child( - div() - .id("modes-list") - .w(px(mode_list_width)) - .h_full() - .flex_none() - .flex() - .flex_col() - .gap_2() - .child( - compact_panel() - .id("mode-list-card") - .overflow_y_scroll() - .child( - div().p_2().flex().flex_col().gap_1().children(rows), - ), - ), - ) + .child(pane_list( + "modes-list", + PANE_LIST_WIDTH_COMPACT, + None, + "", + None, + rows, + )) .child(detail), ), ) @@ -5067,27 +4841,14 @@ impl AppWindow { fn render_mode_context_menu(&self, cx: &mut Context<Self>) -> Option<AnyElement> { let position = self.mode_context_menu?; Some( - div() + menu() .absolute() .left(position.x) .top(position.y) - .w(px(148.0)) - .p_1() - .rounded(px(8.0)) - .border_1() - .border_color(rgb(0x444444)) - .bg(rgb(0x252525)) + .w(px(MENU_WIDTH)) .child( - div() + danger_menu_item() .id("context-delete-mode") - .h(px(30.0)) - .px_2() - .flex() - .items_center() - .rounded(px(5.0)) - .text_size(px(11.0)) - .text_color(rgb(NEGATIVE)) - .hover(|item| item.bg(rgb(SURFACE_HOVER))) .child("Delete mode…") .on_mouse_down( MouseButton::Left, @@ -5126,11 +4887,11 @@ impl AppWindow { let application_picker = (!is_default).then(|| self.render_application_picker(selection, cx)); let basics = if is_default { - compact_panel() + panel() .child( div() - .min_h(px(64.0)) - .px_3() + .min_h(px(SETTINGS_ROW_MIN_HEIGHT)) + .px_4() .py_2() .flex() .flex_col() @@ -5142,11 +4903,11 @@ impl AppWindow { ) .into_any_element() } else { - compact_panel() + panel() .child( div() - .min_h(px(54.0)) - .px_3() + .min_h(px(SETTINGS_ROW_MIN_HEIGHT)) + .px_4() .py_2() .flex() .items_center() @@ -5156,20 +4917,19 @@ impl AppWindow { .border_color(rgb(LINE)) .child( div() - .text_size(px(13.0)) - .font_weight(FontWeight::SEMIBOLD) - .text_color(rgb(TEXT)) + .text_body() + .font_weight(FontWeight::MEDIUM) .child("Name"), ) - .child(div().w(px(300.0)).flex_none().child(name)), + .child(div().max_w(px(MODE_FIELD_WIDTH)).flex_1().child(name)), ) .when_some(application_picker, |panel, picker| { panel.child(div().border_b_1().border_color(rgb(LINE)).child(picker)) }) .child( div() - .min_h(px(64.0)) - .px_3() + .min_h(px(SETTINGS_ROW_MIN_HEIGHT)) + .px_4() .py_2() .flex() .items_center() @@ -5179,18 +4939,25 @@ impl AppWindow { "Web pages", "Switch when a domain is active in Brave", )) - .child(div().max_w(px(300.0)).flex_1().child(browser_hosts)), + .child( + div() + .max_w(px(MODE_FIELD_WIDTH)) + .flex_1() + .child(browser_hosts), + ), ) .into_any_element() }; let processing_toggle = div() .id("mode-processing-toggle") - .h(px(28.0)) + .h(px(CONTROL_HEIGHT)) .flex() .items_center() .child(toggle(processing_position)) - .when(!processing_can_toggle, |control| control.opacity(0.42)) + .when(!processing_can_toggle, |control| { + control.opacity(DISABLED_OPACITY) + }) .when(processing_can_toggle, |control| { control.on_click(cx.listener(move |this, _, _, cx| { let enabled = !this.selected_mode_settings().post_processing.enabled; @@ -5225,7 +4992,7 @@ impl AppWindow { ) .items_center(); div() - .px_3() + .px_4() .py_3() .flex() .items_start() @@ -5239,37 +5006,35 @@ impl AppWindow { .flex_1() .flex() .flex_col() - .gap_1() + .gap_0p5() .child( div() - .text_size(px(11.0)) - .font_weight(FontWeight::SEMIBOLD) - .text_color(rgb(TEXT_SOFT)) + .text_body() + .font_weight(FontWeight::MEDIUM) .child(copy.title), ) .child( div() - .text_size(px(10.0)) - .line_height(px(16.0)) + .text_footnote() .text_color(rgb(if copy.error.is_some() { - NEGATIVE + DANGER } else { - FAINT + TEXT_TERTIARY })) .child(copy.error.unwrap_or_else(|| copy.description.into())), ), ) .when(show_actions, |notice| notice.child(actions)) }); - let processing = compact_panel() + let processing = panel() .child( - compact_panel_header("OpenCode transformation", Some(processing_toggle)).when( + panel_header("OpenCode transformation", Some(processing_toggle)).when( !processing_enabled && processing_unavailable.is_none(), |header| header.border_b_0(), ), ) .when_some(processing_settings, |panel, settings| { - panel.child(div().px_3().pb_3().child(settings)) + panel.child(div().px_4().pb_4().child(settings)) }) .when_some(processing_unavailable, |panel, unavailable| { panel.child(unavailable) @@ -5277,46 +5042,39 @@ impl AppWindow { .into_any_element(); let remove = (!is_default).then(|| { - let action = - if self.mode_delete_armed { - div() - .flex() - .items_center() - .gap_1() - .child(compact_button("Cancel").id("cancel-remove-mode").on_click( - cx.listener(|this, _, _, cx| { + let action = if self.mode_delete_armed { + div() + .flex() + .items_center() + .gap_2() + .child( + button(ButtonKind::Secondary, "Cancel") + .id("cancel-remove-mode") + .on_click(cx.listener(|this, _, _, cx| { this.mode_delete_armed = false; cx.notify(); - }), - )) - .child( - compact_button("Confirm delete") - .id("confirm-remove-mode") - .border_1() - .border_color(rgb(DANGER_BORDER)) - .bg(rgb(DANGER_SURFACE)) - .text_color(rgb(NEGATIVE)) - .on_click(cx.listener(|this, _, _, cx| { - let ModeSelection::Custom(index) = this.selected_mode else { - return; - }; - this.delete_mode(index, cx); - })), - ) - .into_any_element() - } else { - compact_button("Delete mode") - .id("remove-selected-mode") - .border_1() - .border_color(rgb(DANGER_BORDER)) - .bg(rgb(DANGER_SURFACE)) - .text_color(rgb(NEGATIVE)) - .on_click(cx.listener(|this, _, _, cx| { - this.mode_delete_armed = true; - cx.notify(); - })) - .into_any_element() - }; + })), + ) + .child( + button(ButtonKind::Danger, "Confirm delete") + .id("confirm-remove-mode") + .on_click(cx.listener(|this, _, _, cx| { + let ModeSelection::Custom(index) = this.selected_mode else { + return; + }; + this.delete_mode(index, cx); + })), + ) + .into_any_element() + } else { + button(ButtonKind::Danger, "Delete mode") + .id("remove-selected-mode") + .on_click(cx.listener(|this, _, _, cx| { + this.mode_delete_armed = true; + cx.notify(); + })) + .into_any_element() + }; div() .pt_2() .flex() @@ -5335,14 +5093,11 @@ impl AppWindow { .child( div() .w_full() - .max_w(px(700.0)) .flex() .flex_col() - .gap(px(SECTION_GAP)) - .pb_5() + .gap_2() .child(basics) - .child(div().h(px(4.0))) - .child(compact_section_label("TEXT PROCESSING")) + .child(section_label("Text processing").pt_2().px_4()) .child(corrections) .child(processing) .child(transformations) @@ -5378,8 +5133,9 @@ impl AppWindow { let drop_id = id.clone(); div() .id(("mode-transformation", target_index)) - .h(px(36.0)) - .px_2() + .h(px(CONTROL_HEIGHT + SPACE_2)) + .pl_2() + .pr_1() .flex() .items_center() .gap_2() @@ -5406,37 +5162,26 @@ impl AppWindow { .child( div() .id(("drag-mode-transformation", target_index)) - .w(px(20.0)) + .w_5() .h_full() .flex_none() .flex() .items_center() .justify_center() - .text_size(px(12.0)) - .text_color(rgb(FAINT)) - .child("⠿") + .cursor_grab() + .child(icon(Icon::DragHandle, TEXT_DISABLED)) .on_drag(drag, |drag, position, _, cx| { cx.new(|_| drag.clone().at(position)) }), ) .child( - div() + truncated_line(BODY, name) .flex_1() - .min_w(px(0.0)) - .overflow_x_hidden() - .text_size(px(11.0)) - .text_color(rgb(TEXT_SOFT)) - .truncate() - .child(name), + .text_color(rgb(TEXT_SECONDARY)), ) .child( - compact_button("×") + icon_button(ButtonKind::Ghost, Icon::Close) .id(("remove-mode-transformation", target_index)) - .size(px(28.0)) - .px_0() - .justify_center() - .text_size(px(14.0)) - .text_color(rgb(MUTED)) .on_click(cx.listener(move |this, _, _, cx| { this.update_mode_settings(selection, cx, |mode| { mode.transformations @@ -5459,8 +5204,8 @@ impl AppWindow { let description = transformation.description.clone(); div() .id(("available-mode-transformation", index)) - .min_h(px(40.0)) - .px_3() + .min_h(px(SETTINGS_ROW_MIN_HEIGHT)) + .px_4() .py_2() .flex() .items_center() @@ -5475,28 +5220,22 @@ impl AppWindow { .min_w(px(0.0)) .child( div() - .text_size(px(11.0)) - .font_weight(FontWeight::SEMIBOLD) - .text_color(rgb(TEXT_SOFT)) + .text_body() + .font_weight(FontWeight::MEDIUM) + .text_color(rgb(TEXT_SECONDARY)) .child(name), ) .when_some(description, |copy, description| { copy.child( div() - .pt_1() - .text_size(px(10.0)) - .text_color(rgb(FAINT)) + .pt_0p5() + .text_footnote() + .text_color(rgb(TEXT_TERTIARY)) .child(description), ) }), ) - .child( - canvas_button() - .h(px(24.0)) - .px_2() - .text_size(px(10.0)) - .child("Add"), - ) + .child(small_button(ButtonKind::Secondary, "Add")) .on_click(cx.listener(move |this, _, _, cx| { this.update_mode_settings(selection, cx, |mode| { mode.transformations.push(id.clone()); @@ -5511,15 +5250,12 @@ impl AppWindow { let available_empty = available_rows.is_empty(); let catalog_empty = catalog.is_empty(); let add = (!catalog_empty).then(|| { - let button = if self.transformation_picker_open { - compact_button("Done") - .h(px(28.0)) - .mr(px(-7.0)) - .text_size(px(10.0)) + let control = if self.transformation_picker_open { + small_button(ButtonKind::Secondary, "Done") } else { - compact_header_plus_button() + icon_button(ButtonKind::Ghost, Icon::Plus).mr(px(-SPACE_2)) }; - button + control .id("toggle-transformation-picker") .on_click(cx.listener(|this, _, _, cx| { this.transformation_picker_open = !this.transformation_picker_open; @@ -5528,8 +5264,8 @@ impl AppWindow { .into_any_element() }); - compact_panel() - .child(compact_panel_header("Transformations", add).when( + panel() + .child(panel_header("Transformations", add).when( selected.is_empty() && !self.transformation_picker_open, |header| header.border_b_0(), )) @@ -5541,10 +5277,10 @@ impl AppWindow { div() .border_t_1() .border_color(rgb(LINE)) - .px_3() + .px_4() .py_3() - .text_size(px(10.0)) - .text_color(rgb(MUTED)) + .text_footnote() + .text_color(rgb(TEXT_TERTIARY)) .child("Every registered transformation is already included."), ) }) @@ -5564,35 +5300,26 @@ impl AppWindow { let name = name.clone(); div() .id(("selected-application", index)) - .h(px(30.0)) + .h(px(CONTROL_HEIGHT_SMALL)) .pl_1() - .pr_1() .flex() .items_center() - .gap_1() - .rounded_sm() + .gap_1p5() + .rounded(RADIUS_SMALL) .bg(rgb(SURFACE_SELECTED)) - .border_1() - .border_color(rgb(LINE)) - .child(application_icon(application, Some(name.as_str()), 20.0)) - .child( - div() - .text_size(px(10.0)) - .text_color(rgb(TEXT_SOFT)) - .child(name.clone()), - ) + .text_caption() + .font_weight(FontWeight::MEDIUM) + .text_color(rgb(TEXT_SECONDARY)) + .child(application_icon( + application, + Some(name.as_str()), + ICON_SIZE + 2.0, + )) + .child(name.clone()) .child( - div() + icon_button(ButtonKind::Ghost, Icon::Close) .id(("remove-selected-application", index)) - .size(px(18.0)) - .flex() - .items_center() - .justify_center() - .rounded_sm() - .text_size(px(14.0)) - .text_color(rgb(FAINT)) - .hover(|button| button.bg(rgb(SURFACE_HOVER)).text_color(rgb(TEXT_SOFT))) - .child("×") + .size(px(CONTROL_HEIGHT_SMALL)) .on_click(cx.listener(move |this, _, _, cx| { this.remove_mode_application(selection, &name, cx); })), @@ -5622,42 +5349,25 @@ impl AppWindow { .path .parent() .map_or_else(String::new, |path| path.display().to_string()); - div() + list_row(index == self.application_picker_highlight) .id(("available-application", index)) - .w_full() - .px_2() - .py_2() - .flex() + .flex_row() .items_center() .gap_3() - .border_b_1() - .border_color(rgb(LINE)) - .when(index == self.application_picker_highlight, |row| { - row.bg(rgb(SURFACE_SELECTED)) - }) - .hover(|row| row.bg(rgb(SURFACE_HOVER))) - .child(application_icon(Some(&application), Some(&name), 30.0)) + .child(application_icon( + Some(&application), + Some(&name), + CONTROL_HEIGHT, + )) .child( div() .flex_1() .min_w(px(0.0)) .flex() .flex_col() - .gap_1() - .child( - div() - .text_size(px(12.0)) - .font_weight(FontWeight::SEMIBOLD) - .text_color(rgb(TEXT_SOFT)) - .child(name.clone()), - ) - .child( - div() - .text_size(px(10.0)) - .text_color(rgb(FAINT)) - .overflow_hidden() - .child(location), - ), + .gap_0p5() + .child(list_row_title(name.clone(), false)) + .child(list_row_meta(location, false)), ) .on_mouse_down( MouseButton::Left, @@ -5667,7 +5377,7 @@ impl AppWindow { ) }); let picker_status = match &self.application_catalog { - ApplicationCatalogState::Loading => Some("Finding applications on this Mac...".into()), + ApplicationCatalogState::Loading => Some("Finding applications on this Mac…".into()), ApplicationCatalogState::Loaded(applications) if applications.is_empty() => { Some("No applications found.".into()) } @@ -5678,20 +5388,24 @@ impl AppWindow { }; let tray = self.application_picker_open.then(|| { div() - .mt_2() + .mx_4() + .mb_4() .p_2() - .rounded_sm() + .flex() + .flex_col() + .gap_1() + .rounded(RADIUS_CONTROL) .border_1() .border_color(rgb(LINE)) - .bg(rgb(SURFACE)) + .bg(rgb(CANVAS)) .child(self.application_search.entity.clone()) .when_some(picker_status, |tray, status| { tray.child( div() .px_2() .py_3() - .text_size(px(11.0)) - .text_color(rgb(MUTED)) + .text_footnote() + .text_color(rgb(TEXT_TERTIARY)) .child(status), ) }) @@ -5709,33 +5423,15 @@ impl AppWindow { .items_center() .justify_between() .child( - div() + small_button(ButtonKind::Secondary, "Choose from Finder…") .id("choose-application-bundle") - .h(px(28.0)) - .px_2() - .flex() - .items_center() - .rounded_sm() - .text_size(px(11.0)) - .text_color(rgb(TEXT_SOFT)) - .hover(|button| button.bg(rgb(SURFACE_HOVER))) - .child("Choose from Finder...") .on_click(cx.listener(move |this, _, _, cx| { this.choose_application(selection, cx); })), ) .child( - div() + small_button(ButtonKind::Secondary, "Done") .id("close-application-picker") - .h(px(28.0)) - .px_2() - .flex() - .items_center() - .rounded_sm() - .text_size(px(11.0)) - .text_color(rgb(MUTED)) - .hover(|button| button.bg(rgb(SURFACE_HOVER))) - .child("Done") .on_click(cx.listener(|this, _, window, cx| { this.close_application_picker(cx); window.blur(); @@ -5749,8 +5445,8 @@ impl AppWindow { .flex_col() .child( div() - .min_h(px(64.0)) - .px_3() + .min_h(px(SETTINGS_ROW_MIN_HEIGHT)) + .px_4() .py_2() .flex() .items_center() @@ -5762,10 +5458,9 @@ impl AppWindow { )) .child( div() - .max_w(px(300.0)) + .max_w(px(MODE_FIELD_WIDTH)) .min_h(px(CONTROL_HEIGHT)) .flex_1() - .p_1() .flex() .flex_wrap() .items_center() @@ -5773,12 +5468,8 @@ impl AppWindow { .gap_1() .children(selected_rows) .child( - compact_plus_button() + icon_button(ButtonKind::Secondary, Icon::Plus) .id("open-application-picker") - .size(px(30.0)) - .border_1() - .border_color(rgb(LINE)) - .bg(rgb(CANVAS)) .on_click(cx.listener(|this, _, window, cx| { this.application_picker_highlight = 0; this.application_picker_open = true; @@ -5797,12 +5488,7 @@ impl AppWindow { ), ) .when_some(self.application_picker_error.clone(), |picker, error| { - picker.child( - div() - .text_size(px(10.0)) - .text_color(rgb(NEGATIVE)) - .child(error), - ) + picker.child(inline_error(error)) }) .when_some(tray, |picker, tray| picker.child(tray)) .into_any_element() @@ -5972,7 +5658,7 @@ impl AppWindow { let presentation = model_presentation(&self.model_catalog, selected_model); let (catalog_status, default_label, matches, variants) = match &self.model_catalog { ModelCatalogState::Loading => { - ("Loading OpenCode...".into(), None, Vec::new(), Vec::new()) + ("Loading OpenCode…".into(), None, Vec::new(), Vec::new()) } ModelCatalogState::Missing => ( "OpenCode beta required".into(), @@ -6022,12 +5708,8 @@ impl AppWindow { }; let suggestions = (focused && (!matches.is_empty() || default_label.is_some())).then(|| { - div() + menu() .mt_1() - .rounded_sm() - .border_1() - .border_color(rgb(LINE)) - .bg(rgb(SURFACE)) .when_some(default_label.clone(), |list, label| { list.child( model_choice_row( @@ -6077,28 +5759,13 @@ impl AppWindow { let choices = std::iter::once(None) .chain(variants.iter().cloned().map(Some)) .collect::<Vec<_>>(); - div() + menu() .mt_1() - .p_1() - .rounded_sm() - .border_1() - .border_color(rgb(LINE)) - .bg(rgb(SURFACE)) .children(choices.into_iter().enumerate().map(|(index, variant)| { let selected = effective_variant == variant.as_deref(); let label = variant.clone().unwrap_or_else(|| "Default".into()); - div() + menu_item(selected) .id(("model-variant", index)) - .w_full() - .h(px(28.0)) - .px_2() - .flex() - .items_center() - .rounded_sm() - .text_size(px(11.0)) - .text_color(if selected { rgb(TEXT) } else { rgb(TEXT_SOFT) }) - .when(selected, |row| row.bg(rgb(SURFACE_SELECTED))) - .hover(|row| row.bg(rgb(SURFACE_HOVER))) .child(label) .on_click(cx.listener(move |this, _, _, cx| { this.variant_picker_open = None; @@ -6125,58 +5792,27 @@ impl AppWindow { .when_some(variant_list, |control, list| control.child(list)); let model_control = if let Some(presentation) = presentation.filter(|_| !focused) { let model_input = model.clone(); - canvas_button() + disclosure_frame() .id("selected-model") .w_full() - .h(px(TEXT_INPUT_HEIGHT)) - .px_3() - .justify_between() - .gap_2() + .flex_shrink() .child( - div() + truncated_line(FOOTNOTE, presentation.name) .flex_1() - .min_w(px(0.0)) - .overflow_x_hidden() - .flex() - .items_center() - .gap_2() - .child( - div() - .text_size(px(12.0)) - .font_weight(FontWeight::SEMIBOLD) - .text_color(rgb(TEXT)) - .truncate() - .child(presentation.name), - ) - .child( - div() - .text_size(px(11.0)) - .text_color(rgb(MUTED)) - .truncate() - .child(presentation.provider), - ), + .font_weight(FontWeight::MEDIUM) + .text_color(rgb(TEXT_PRIMARY)), ) .child( div() .flex_none() - .flex() - .items_center() - .gap_2() - .when(presentation.is_default, |right| { - right.child( - div() - .px_1p5() - .py(px(1.0)) - .rounded(px(4.0)) - .bg(rgb(SURFACE_SELECTED)) - .text_size(px(9.0)) - .font_weight(FontWeight::MEDIUM) - .text_color(rgb(TEXT_SOFT)) - .child("Default"), - ) - }) - .child(div().text_size(px(10.0)).text_color(rgb(MUTED)).child("⌄")), + .text_caption() + .text_color(rgb(TEXT_TERTIARY)) + .child(presentation.provider), ) + .when(presentation.is_default, |control| { + control.child(tag(Tone::Neutral, "Default")) + }) + .child(icon(Icon::ChevronDown, TEXT_TERTIARY)) .on_click(cx.listener(move |_, _, window, cx| { model_input.focus_handle(cx).focus(window); cx.notify(); @@ -6187,12 +5823,8 @@ impl AppWindow { }; let refresh_control = matches!(&self.model_catalog, ModelCatalogState::Loaded(_)).then(|| { - canvas_button() + button(ButtonKind::Secondary, "Refresh") .id("refresh-opencode-models") - .h(px(TEXT_INPUT_HEIGHT)) - .px_3() - .text_size(px(12.0)) - .child("Refresh") .on_click(cx.listener(|this, _, _, cx| { this.reload_model_catalog(); cx.notify(); @@ -6225,7 +5857,7 @@ impl AppWindow { .gap_2() .when(copy.can_retry, |actions| { actions.child( - header_button(copy.retry_label) + button(ButtonKind::Secondary, copy.retry_label) .id(retry_id) .on_click(cx.listener(|this, _, _, cx| { this.reload_model_catalog(); @@ -6235,7 +5867,7 @@ impl AppWindow { }) .when(copy.can_open_setup, |actions| { actions.child( - header_button(setup_label) + button(ButtonKind::Secondary, setup_label) .id(setup_id) .on_click(|_, _, _| open_opencode_beta_docs()), ) @@ -6268,7 +5900,7 @@ impl AppWindow { .when_some(refresh_control, |row, refresh| row.child(refresh)), ) .when_some(suggestions, |field, suggestions| field.child(suggestions)); - settings_panel() + panel() .child( settings_row("OpenCode model", catalog_status, model_field) .when(!has_variants, |row| row.border_b_0()), @@ -6333,7 +5965,8 @@ impl AppWindow { matches!(&self.model_catalog, ModelCatalogState::Missing), |field| { field.child( - compact_button("Install OpenCode") + button(ButtonKind::Secondary, "Install OpenCode") + .mt_2() .id("install-opencode-beta") .on_click(|_, _, _| open_opencode_beta_docs()), ) @@ -6354,13 +5987,7 @@ impl AppWindow { .flex() .flex_col() .gap_2() - .child( - div() - .text_size(px(11.0)) - .font_weight(FontWeight::SEMIBOLD) - .text_color(rgb(TEXT_SOFT)) - .child("Thinking"), - ) + .child(field_label("Thinking")) .child(div().flex().child(variant_control)), ) }) @@ -6516,26 +6143,35 @@ impl AppWindow { } fn render_meetings(&mut self, cx: &mut Context<Self>) -> AnyElement { - let action = match meeting::active(&self.meetings).map(|meeting| meeting.status) { - _ if self.meeting_stop_pending => meeting_progress_action("Finalizing..."), - Some(MeetingStatus::Transcribing) => meeting_progress_action("Finalizing..."), - Some(MeetingStatus::Starting) => meeting_progress_action("Starting..."), - Some(MeetingStatus::Recording) => { - meeting_action_button("stop-meeting", "Stop & Transcribe") - .on_click(cx.listener(|this, _, _, cx| { - this.meeting_stop_pending = true; - if let Err(error) = this.meeting_requests.try_send(MeetingRequest::Stop) { - this.meeting_stop_pending = false; - log_meeting_request_error(error); - } - cx.notify(); - })) - .into_any_element() - } + let active_meeting = meeting::active(&self.meetings); + let action = match active_meeting.map(|meeting| meeting.status) { + _ if self.meeting_stop_pending => busy_label("Finalizing…").into_any_element(), + Some(MeetingStatus::Transcribing) => busy_label("Finalizing…").into_any_element(), + Some(MeetingStatus::Starting) => busy_label("Starting…").into_any_element(), + // Recording is a state, not the screen's main action: a neutral + // button in the same red-dot language as the Recording tag. + Some(MeetingStatus::Recording) => button(ButtonKind::Secondary, status_dot(DANGER)) + .child("Stop & transcribe") + .children(active_meeting.map(|meeting| { + div() + .text_color(rgb(TEXT_TERTIARY)) + .child(meeting::format_duration(meeting_duration(meeting))) + })) + .id("stop-meeting") + .on_click(cx.listener(|this, _, _, cx| { + this.meeting_stop_pending = true; + if let Err(error) = this.meeting_requests.try_send(MeetingRequest::Stop) { + this.meeting_stop_pending = false; + log_meeting_request_error(error); + } + cx.notify(); + })) + .into_any_element(), _ if self.meeting_refresh_started_ms.is_some() => { - meeting_progress_action("Starting...") + busy_label("Starting…").into_any_element() } - _ => meeting_action_button("start-meeting", "Start Recording") + _ => button(ButtonKind::Primary, "Start recording") + .id("start-meeting") .on_click(cx.listener(|this, _, _, cx| { this.meeting_refresh_started_ms = Some(now_ms()); if let Err(error) = this.meeting_requests.try_send(MeetingRequest::Start) { @@ -6549,8 +6185,12 @@ impl AppWindow { let header_action = div() .flex() .items_center() - .gap_3() - .child(div().w(px(220.0)).child(self.meeting_search.entity.clone())) + .gap_2() + .child( + div() + .w(px(SEARCH_FIELD_WIDTH)) + .child(self.meeting_search.entity.clone()), + ) .child(action) .into_any_element(); @@ -6562,13 +6202,7 @@ impl AppWindow { for (index, meeting) in visible.iter().enumerate() { let day = meeting::day_label(meeting.created_at_ms, now); if current_day.as_ref() != Some(&day) { - rows.push( - compact_section_label(day.clone()) - .pt(px(if rows.is_empty() { 4.0 } else { 12.0 })) - .pb_1() - .px_3() - .into_any_element(), - ); + rows.push(list_section_label(day.clone(), rows.is_empty()).into_any_element()); current_day = Some(day); } let id = meeting.id.clone(); @@ -6588,15 +6222,7 @@ impl AppWindow { .items_center() .gap_2() .child( - div() - .flex_1() - .min_w(px(0.0)) - .overflow_x_hidden() - .text_size(px(12.0)) - .font_weight(FontWeight::SEMIBOLD) - .text_color(rgb(if selected { TEXT } else { TEXT_SOFT })) - .truncate() - .child(meeting::display_title(meeting)), + list_row_title(meeting::display_title(meeting), selected).flex_1(), ) .when_some( meeting_status_chip( @@ -6607,15 +6233,7 @@ impl AppWindow { |row, chip| row.child(chip), ), ) - .child( - div() - .w_full() - .overflow_x_hidden() - .text_size(px(10.0)) - .text_color(rgb(if selected { TEXT_SOFT } else { FAINT })) - .truncate() - .child(meta.join(" · ")), - ) + .child(list_row_meta(meta.join(" · "), selected)) .on_click(cx.listener(move |this, _, _, cx| { this.select_meeting(id.clone()); cx.notify(); @@ -6624,8 +6242,9 @@ impl AppWindow { ); } - let list = pane_list_card( + let list = pane_list( "meetings-list", + PANE_LIST_WIDTH, (visible.is_empty() && self.meetings_error.is_none()).then_some(if searching { "No meetings match." } else { @@ -6641,36 +6260,24 @@ impl AppWindow { .flex() .flex_col() .child(pane_header_with_action("Meetings", Some(header_action))) - .when_some(self.meeting_runtime_error.clone(), |pane, error| { - pane.child( - div().flex().justify_center().px_8().child( - div() - .w_full() - .max_w(px(PANE_CONTENT_WIDTH)) - .py_3() - .border_b_1() - .border_color(rgb(LINE)) - .text_size(px(12.0)) - .text_color(rgb(NEGATIVE)) - .child(error), - ), - ) - }) .child( - pane_body().p_5().child( + pane_body().child( pane_content() .id("meetings-workspace") - .flex_row() - .gap_5() - .child(list) + .gap_3() + .when_some(self.meeting_runtime_error.clone(), |column, error| { + column.child( + error_panel("Meeting could not be recorded.", error).flex_none(), + ) + }) .child( - compact_panel() + div() .flex_1() - .min_w(px(0.0)) - .h_full() + .min_h(px(0.0)) .flex() - .flex_col() - .child(self.render_meeting_detail(cx)), + .gap_5() + .child(list) + .child(pane_detail(self.render_meeting_detail(cx))), ), ), ) @@ -6699,29 +6306,33 @@ impl AppWindow { .as_ref() .is_some_and(|(copied_id, copied)| copied_id == &id && *copied == what) }; - let action = |key: &'static str, label: &'static str| { - canvas_button() - .id(SharedString::from(format!("meeting-action-{key}"))) - .h(px(28.0)) - .px_3() - .flex_none() - .text_size(px(12.0)) - .child(label) + let action_of = |kind: ButtonKind, key: &'static str, label: &'static str| { + button(kind, label).id(SharedString::from(format!("meeting-action-{key}"))) + }; + let action = + |key: &'static str, label: &'static str| action_of(ButtonKind::Secondary, key, label); + let menu_action = |key: &'static str, item: Div| { + item.id(SharedString::from(format!("meeting-action-{key}"))) }; + // Copy transcript and Rename stay visible; the rest live in the + // overflow menu, with Delete last. let mut actions: Vec<AnyElement> = Vec::new(); + let mut menu_items: Vec<AnyElement> = Vec::new(); if let Some(notes) = meeting.notes.clone() { let id = id.clone(); - actions.push( - action( + menu_items.push( + menu_action( "copy-notes", - if copied("notes") { + menu_item(false).child(if copied("notes") { "Copied" } else { - "Copy Summary" - }, + "Copy summary" + }), ) + // Stays open so "Copied" confirms the copy. .on_click(cx.listener(move |this, _, _, cx| { + cx.stop_propagation(); this.copy_meeting_text(id.clone(), "notes", meeting::notes_markdown(¬es), cx) })) .into_any_element(), @@ -6736,7 +6347,7 @@ impl AppWindow { if copied("transcript") { "Copied" } else { - "Copy Transcript" + "Copy transcript" }, ) .on_click(cx.listener(move |this, _, _, cx| { @@ -6750,23 +6361,33 @@ impl AppWindow { .into_any_element(), ); } - if !active { - let export_path = meeting.export_path.clone().filter(|path| path.exists()); - let reveal_id = id.clone(); + if !active && self.meeting_rename.is_none() { + let (id, title) = (id.clone(), title.clone()); actions.push( - action( + action("rename", "Rename") + .on_click(cx.listener(move |this, _, window, cx| { + this.start_meeting_rename(id.clone(), title.clone(), window, cx) + })) + .into_any_element(), + ); + } + if !active { + let export_path = meeting.export_path.clone().filter(|path| path.exists()); + let reveal_id = id.clone(); + menu_items.push( + menu_action( "reveal", - if export_path.is_some() { + menu_item(false).child(if export_path.is_some() { "Reveal Markdown" } else { - "Reveal Files" - }, - ) - .on_click( - cx.listener(move |_, _, _, _| { - reveal_meeting(&reveal_id, export_path.as_deref()) + "Reveal files" }), ) + .on_click(cx.listener(move |this, _, _, cx| { + this.meeting_actions_open = false; + reveal_meeting(&reveal_id, export_path.as_deref()); + cx.notify(); + })) .into_any_element(), ); } @@ -6775,19 +6396,24 @@ impl AppWindow { && self.apple_intelligence_availability().is_available() { if notes_running { - actions.push(meeting_progress_action("Summarizing...")); + menu_items.push( + disabled_menu_item() + .child("Summarizing…") + .into_any_element(), + ); } else { let id = id.clone(); - actions.push( - action( + menu_items.push( + menu_action( "notes", - if meeting.notes.is_some() { - "Regenerate Summary" + menu_item(false).child(if meeting.notes.is_some() { + "Regenerate summary" } else { "Summarize" - }, + }), ) .on_click(cx.listener(move |this, _, _, cx| { + this.meeting_actions_open = false; if let Err(error) = this .meeting_requests .try_send(MeetingRequest::Summarize(id.clone())) @@ -6800,55 +6426,80 @@ impl AppWindow { ); } } - if !active && self.meeting_rename.is_none() { - let (id, title) = (id.clone(), title.clone()); - actions.push( - action("rename", "Rename") - .on_click(cx.listener(move |this, _, window, cx| { - this.start_meeting_rename(id.clone(), title.clone(), window, cx) + let armed = self.meeting_delete_armed.as_deref() == Some(id.as_str()); + if !active && !notes_running && !armed { + let delete_id = id.clone(); + if !menu_items.is_empty() { + menu_items.push(menu_separator().into_any_element()); + } + menu_items.push( + menu_action("delete", danger_menu_item().child("Delete…")) + .on_click(cx.listener(move |this, _, _, cx| { + this.meeting_actions_open = false; + this.trash_meeting(delete_id.clone(), cx) })) .into_any_element(), ); } - if !active && !notes_running { - let armed = self.meeting_delete_armed.as_deref() == Some(id.as_str()); + if !menu_items.is_empty() { + let trigger = icon_button(ButtonKind::Secondary, Icon::Ellipsis) + .id("meeting-action-more") + .on_click(cx.listener(|this, _, _, cx| { + this.meeting_actions_open = true; + cx.notify(); + })); + actions.push( + div() + .relative() + .child(trigger) + .when(self.meeting_actions_open, |more| { + more.child(deferred( + dropdown_menu() + .id("meeting-actions-menu") + .debug_selector(|| "meeting-actions-menu".into()) + .left_0() + .w(px(MENU_WIDTH)) + .on_mouse_down_out(cx.listener(|this, _, _, cx| { + this.meeting_actions_open = false; + cx.notify(); + })) + .children(menu_items), + )) + }) + .into_any_element(), + ); + } + if !active && !notes_running && armed { + // The confirmation takes the whole row until it is answered. + actions.clear(); let delete_id = id.clone(); actions.push( - action( + action_of( + ButtonKind::Danger, "delete", - match ( - armed, - meeting - .export_path - .as_ref() - .is_some_and(|path| path.exists()), - ) { - (false, _) => "Delete", - (true, true) => "Trash Recording & Markdown", - (true, false) => "Trash Recording", + if meeting + .export_path + .as_ref() + .is_some_and(|path| path.exists()) + { + "Trash recording & Markdown" + } else { + "Trash recording" }, ) - .when(armed, |button| { - button - .border_color(rgb(DANGER_BORDER)) - .bg(rgb(DANGER_SURFACE)) - .text_color(rgb(NEGATIVE)) - }) .on_click( cx.listener(move |this, _, _, cx| this.trash_meeting(delete_id.clone(), cx)), ) .into_any_element(), ); - if armed { - actions.push( - action("cancel-delete", "Cancel") - .on_click(cx.listener(|this, _, _, cx| { - this.meeting_delete_armed = None; - cx.notify(); - })) - .into_any_element(), - ); - } + actions.push( + action("cancel-delete", "Cancel") + .on_click(cx.listener(|this, _, _, cx| { + this.meeting_delete_armed = None; + cx.notify(); + })) + .into_any_element(), + ); } let title_row: AnyElement = match &self.meeting_rename { @@ -6868,12 +6519,7 @@ impl AppWindow { })), ) .into_any_element(), - _ => div() - .text_size(px(20.0)) - .line_height(px(26.0)) - .font_weight(FontWeight::SEMIBOLD) - .child(title) - .into_any_element(), + _ => detail_title(title).into_any_element(), }; let mut meta = vec![ @@ -6884,50 +6530,54 @@ impl AppWindow { meta.extend(meeting.app.clone()); let errors = [ - self.meeting_action_error.clone(), - meeting.error.clone(), + self.meeting_action_error + .clone() + .map(|error| ("Meeting could not be updated.", error)), + meeting + .error + .clone() + .map(|error| ("Recording failed.", error)), meeting .export_error .clone() - .map(|error| format!("Markdown not saved: {error}")), + .map(|error| ("Markdown not saved.", error)), meeting .notes_error .clone() .filter(|_| !notes_running) - .map(|error| format!("Notes: {error}")), + .map(|error| ("Notes could not be written.", error)), meeting .live_transcription_error .clone() .filter(|_| active) - .map(|error| format!("Live draft unavailable: {error}")), + .map(|error| ("Live draft unavailable.", error)), ] .into_iter() - .flatten() - .collect::<Vec<_>>(); + .flatten(); let notes = meeting.notes.clone().map(render_meeting_notes).or_else(|| { notes_running.then(|| { div() .pb_5() - .text_size(px(12.0)) - .text_color(rgb(MUTED)) - .child("Writing a title and summary with Apple Intelligence...") + .text_footnote() + .text_color(rgb(TEXT_TERTIARY)) + .child("Writing a title and summary with Apple Intelligence…") }) }); let transcript = match &self.meeting_transcript { - MeetingTranscript::Empty => empty_message("No transcript selected."), + MeetingTranscript::Empty => empty_state("No transcript selected."), MeetingTranscript::Loaded(lines) if lines.is_empty() => { - empty_message(match meeting.status { - MeetingStatus::Starting => "Preparing local audio capture...", + empty_state(match meeting.status { + MeetingStatus::Starting => "Preparing local audio capture…", MeetingStatus::Recording if meeting.live_transcription_error.is_some() => { "Live transcription is unavailable. Audio is still recording safely." } MeetingStatus::Recording if self.meeting_stop_pending => { - "Building the final transcript..." + "Building the final transcript…" } - MeetingStatus::Recording => "Listening for speech...", - MeetingStatus::Transcribing => "Building the final transcript...", + MeetingStatus::Recording => "Listening for speech…", + MeetingStatus::Transcribing => "Building the final transcript…", MeetingStatus::Complete => "No speech was transcribed.", MeetingStatus::Interrupted | MeetingStatus::Failed => { "No recoverable transcript is available." @@ -6952,10 +6602,10 @@ impl AppWindow { .flex() .items_center() .gap_2() - .text_size(px(11.0)) - .text_color(rgb(FAINT)) - .child(div().size(px(6.0)).rounded_full().bg(rgb(NEGATIVE))) - .child("Listening..."), + .text_caption() + .text_color(rgb(TEXT_TERTIARY)) + .child(status_dot(DANGER)) + .child("Listening…"), ) }) .into_any_element(), @@ -6965,13 +6615,13 @@ impl AppWindow { }; let transcript_label = match meeting.status { MeetingStatus::Starting | MeetingStatus::Recording if !self.meeting_stop_pending => { - "LIVE TRANSCRIPT" + "Live transcript" } MeetingStatus::Starting | MeetingStatus::Recording | MeetingStatus::Transcribing => { - "DRAFT TRANSCRIPT" + "Draft transcript" } - MeetingStatus::Complete => "TRANSCRIPT", - MeetingStatus::Interrupted | MeetingStatus::Failed => "RECOVERED DRAFT", + MeetingStatus::Complete => "Transcript", + MeetingStatus::Interrupted | MeetingStatus::Failed => "Recovered draft", }; div() @@ -6982,78 +6632,21 @@ impl AppWindow { .overflow_hidden() .flex() .flex_col() + .child(detail_header( + title_row, + meeting_status_chip(meeting.status, self.meeting_stop_pending, true) + .map(IntoElement::into_any_element) + .into_iter() + .chain([truncated_line(FOOTNOTE, meta.join(" · ")) + .flex_1() + .into_any_element()]) + .collect(), + actions, + )) .child( - div() - .px_6() - .pt_5() - .pb_4() - .flex() - .flex_col() - .gap_2() - .border_b_1() - .border_color(rgb(LINE)) - .child(title_row) - .child( - div() - .flex() - .items_center() - .gap_2() - .text_size(px(11.0)) - .text_color(rgb(MUTED)) - .when_some( - meeting_status_chip( - meeting.status, - self.meeting_stop_pending, - true, - ), - |row, chip| row.child(chip), - ) - .child( - div() - .flex_1() - .min_w(px(0.0)) - .overflow_x_hidden() - .truncate() - .child(meta.join(" · ")), - ), - ) - .when(!actions.is_empty(), |header| { - header.child( - div() - .debug_selector(|| "meeting-actions".into()) - .pt_2() - .flex() - .flex_wrap() - .gap_2() - .children(actions), - ) - }), - ) - .child( - div() - .id("meeting-transcript") - .flex_1() - .flex() - .flex_col() - .overflow_y_scroll() + detail_body("meeting-transcript") .track_scroll(&self.meeting_transcript_scroll) - .px_6() - .pt_5() - .pb_6() - .children(errors.into_iter().map(|error| { - div() - .mb_4() - .px_3() - .py_2() - .rounded(px(6.0)) - .border_1() - .border_color(rgb(DANGER_BORDER)) - .bg(rgb(DANGER_SURFACE)) - .text_size(px(11.0)) - .line_height(px(17.0)) - .text_color(rgb(NEGATIVE)) - .child(error) - })) + .children(errors.map(|(message, error)| error_panel(message, error).mb_4())) .when_some(notes, |content, notes| content.child(notes)) .child( div() @@ -7061,10 +6654,8 @@ impl AppWindow { .flex() .items_center() .gap_2() - .child(compact_section_label(transcript_label).px_0()) - .when(recording, |label| { - label.child(div().size(px(6.0)).rounded_full().bg(rgb(NEGATIVE))) - }), + .child(section_label(transcript_label)) + .when(recording, |label| label.child(status_dot(DANGER))), ) .child(transcript), ) @@ -7084,24 +6675,16 @@ impl AppWindow { .or_else(|| self.personal_commands_status.last_reload_error.clone()); let open_config = config_path.clone(); let config_control = match workspace_state { - PersonalWorkspaceState::Missing => header_button("Create Config") + PersonalWorkspaceState::Missing => button(ButtonKind::Secondary, "Create config") .id("personal-commands-create") .on_click(cx.listener(|this, _, _, cx| { this.provision_personal_workspace(); cx.notify(); })) .into_any_element(), - PersonalWorkspaceState::Creating => div() - .h(px(30.0)) - .px_3() - .flex() - .items_center() - .text_size(px(12.0)) - .text_color(rgb(MUTED)) - .child("Creating Config...") - .into_any_element(), + PersonalWorkspaceState::Creating => busy_label("Creating config…").into_any_element(), PersonalWorkspaceState::Ready | PersonalWorkspaceState::Error => { - header_button("Edit Config") + button(ButtonKind::Secondary, "Edit config") .id("personal-commands-open-config") .on_click(move |_, _, _| open_path(&open_config)) .into_any_element() @@ -7116,7 +6699,7 @@ impl AppWindow { let workspace = self.personal_workspace.display(); let config = config_path.display(); let prompt = format!( - "Help me configure my HEX custom voice commands.\n\n\ + "Help me configure my Hex custom voice commands.\n\n\ Workspace: {workspace}\n\ Config: {config}\n\n\ Read AGENTS.md and .agents/skills/personal-commands/SKILL.md in the workspace \ @@ -7124,11 +6707,14 @@ impl AppWindow { hex.config.ts, and run `bun run check` from the workspace when finished. You \ may add npm packages when a command needs them." ); - header_button(if copied { - "Copied" - } else { - "Copy agent prompt" - }) + button( + ButtonKind::Secondary, + if copied { + "Copied" + } else { + "Copy agent prompt" + }, + ) .id("personal-commands-copy-prompt") .on_click(cx.listener(move |this, _, _, cx| { match arboard::Clipboard::new() @@ -7148,40 +6734,42 @@ impl AppWindow { let commands_toggle_control = if self.pending_microphone_policy == Some(MicrophonePolicyChange::KeepReadyAndEnableCommands) { - compact_button("Keep Microphone Ready & Enable Commands") - .id("confirm-enable-commands") - .on_click(cx.listener(|this, _, _, cx| { - cx.stop_propagation(); - if let Err(error) = this.update_microphone_policy( - MicrophonePolicyChange::KeepReadyAndEnableCommands, - cx, - ) { - tracing::error!(%error, "could not update microphone policy"); - } - })) - .into_any_element() + button( + ButtonKind::Primary, + "Keep microphone ready & enable commands", + ) + .id("confirm-enable-commands") + .on_click(cx.listener(|this, _, _, cx| { + cx.stop_propagation(); + if let Err(error) = this.update_microphone_policy( + MicrophonePolicyChange::KeepReadyAndEnableCommands, + cx, + ) { + tracing::error!(%error, "could not update microphone policy"); + } + })) + .into_any_element() } else { div() .flex() .items_center() - .gap_3() - .child(div().text_size(px(11.0)).text_color(rgb(MUTED)).child( - if self.settings.commands_enabled { - "Enabled" - } else { - "Off" - }, - )) + .gap_2() + .child( + div() + .text_footnote() + .text_color(rgb(TEXT_SECONDARY)) + .child("Voice commands"), + ) .child(toggle(commands_position)) .into_any_element() }; let commands_control = div() .flex() .items_center() - .gap_3() + .gap_2() .child(config_control) .when_some(copy_prompt, |controls, prompt| controls.child(prompt)) - .child(div().w(px(1.0)).h(px(16.0)).bg(rgb(LINE))) + .child(div().mx_1().w(px(1.0)).h_5().bg(rgb(LINE))) .child( div() .id("commands-enabled") @@ -7234,14 +6822,7 @@ impl AppWindow { }); let mut groups: Vec<AnyElement> = Vec::new(); for task in tasks { - groups.push( - compact_section_label(task.clone()) - .px_3() - .pt_3() - .pb_1() - .text_color(rgb(MUTED)) - .into_any_element(), - ); + groups.push(list_section_label(task.clone(), groups.is_empty()).into_any_element()); groups.extend( visible .iter() @@ -7253,25 +6834,9 @@ impl AppWindow { let selected = effective_selected.as_deref() == Some(command_id.as_str()); list_row(selected) .id(("command", index)) - .child( - div() - .w_full() - .overflow_x_hidden() - .text_size(px(12.0)) - .font_weight(FontWeight::SEMIBOLD) - .text_color(rgb(TEXT)) - .truncate() - .child(command.phrase), - ) - .child( - div() - .w_full() - .overflow_x_hidden() - .text_size(px(10.0)) - .text_color(rgb(if selected { TEXT_SOFT } else { FAINT })) - .truncate() - .child(command.description), - ) + .gap_0p5() + .child(list_row_title(command.phrase, selected)) + .child(list_row_meta(command.description, selected)) .on_click(cx.listener(move |this, _, _, cx| { this.selected_command = Some(command_id.clone()); cx.notify(); @@ -7282,22 +6847,19 @@ impl AppWindow { } let no_commands = groups.is_empty(); - let commands_list_width = PANE_LIST_WIDTH; div() .size_full() .flex() .flex_col() .child(pane_header_with_action("Commands", Some(commands_control))) .child( - pane_body().p_5().child( + pane_body().child( pane_content() .gap_3() .when_some(last_error, |column, error| { column.child( - compact_panel().flex_none().child(error_message( - "Personal commands are not loading.", - error, - )), + error_panel("Personal commands are not loading.", error) + .flex_none(), ) }) .child( @@ -7308,7 +6870,7 @@ impl AppWindow { .gap_5() .child( div() - .w(px(commands_list_width)) + .w(px(PANE_LIST_WIDTH)) .h_full() .flex_none() .flex() @@ -7320,61 +6882,28 @@ impl AppWindow { .child(self.command_search.entity.clone()), ) .child( - compact_panel() - .id("commands-list") - .flex_1() - .min_h(px(0.0)) - .overflow_y_scroll() - .when(no_commands, |card| { - card.child(empty_message(if query.is_empty() { - "No commands yet." - } else { - "No matching commands." - })) - }) - .child( - div() - .w(px(commands_list_width - 2.0)) - .p_2() - .flex() - .flex_col() - .gap_1() - .children(groups), - ), + pane_list( + "commands-list", + PANE_LIST_WIDTH, + no_commands.then_some(if query.is_empty() { + "No commands yet." + } else { + "No matching commands." + }), + "", + None, + groups, + ) + .flex_1() + .min_h(px(0.0)), ), ) .child( - div() - .flex_1() - .min_w(px(0.0)) - .h_full() - .flex() - .flex_col() - .gap_2() - .child( - div() - .h(px(TEXT_INPUT_HEIGHT)) - .flex_none() - .flex() - .items_center() - .justify_end() - .child( - div() - .text_size(px(10.0)) - .text_color(rgb(FAINT)) - .child(format!("Context: {context_label}")), - ), - ) - .child( - compact_panel() - .id("command-detail") - .flex_1() - .min_h(px(0.0)) - .overflow_y_scroll() - .child(self.render_command_detail( - effective_selected.as_deref(), - )), - ), + pane_detail(self.render_command_detail( + effective_selected.as_deref(), + &context_label, + )) + .id("command-detail"), ), ), ), @@ -7382,7 +6911,7 @@ impl AppWindow { .into_any_element() } - fn render_command_detail(&self, selected: Option<&str>) -> AnyElement { + fn render_command_detail(&self, selected: Option<&str>, context_label: &str) -> AnyElement { let Some(command) = selected.and_then(|id| self.commands.iter().find(|command| command.id == id)) else { @@ -7394,26 +6923,21 @@ impl AppWindow { command.aliases.join("\n") }; div() - .p_5() - .child( - div() - .text_size(px(15.0)) - .font_weight(FontWeight::SEMIBOLD) - .child(command.phrase.clone()), - ) - .child( - div() - .pt_2() - .text_size(px(12.0)) - .text_color(rgb(MUTED)) - .child(command.description.clone()), - ) + .size_full() + .flex() + .flex_col() + .child(detail_header( + detail_title(command.phrase.clone()), + vec![command.description.clone().into_any_element()], + Vec::new(), + )) .child( - div() - .pt_4() + detail_body("command-detail-body") + .pt_1() .child(detail_row("Task", command.task.clone())) .child(detail_row("Context", command_scope(&command.scope))) - .child(detail_row("Aliases", aliases)), + .child(detail_row("Aliases", aliases)) + .child(detail_row("Current context", context_label.to_string())), ) .into_any_element() } @@ -7464,7 +6988,7 @@ impl AppWindow { } fn render_activity(&mut self, cx: &mut Context<Self>) -> AnyElement { - let refresh = header_button("Refresh") + let refresh = button(ButtonKind::Secondary, "Refresh") .id("refresh-activity") .on_click(cx.listener(|this, _, _, cx| { this.reload_events(); @@ -7500,14 +7024,15 @@ impl AppWindow { age, div() .flex_1() - .text_size(if boundary { px(11.0) } else { px(12.0) }) - .font_weight(if boundary { - FontWeight::SEMIBOLD - } else { - FontWeight::NORMAL + .map(|text| { + if boundary { + text.text_caption() + .font_weight(FontWeight::SEMIBOLD) + .text_color(rgb(TEXT_TERTIARY)) + } else { + text.text_body().text_color(rgb(TEXT_SECONDARY)) + } }) - .text_color(if boundary { rgb(FAINT) } else { rgb(TEXT_SOFT) }) - .line_height(px(18.0)) .child(summary), ) .on_click(cx.listener(move |this, _, _, cx| { @@ -7524,27 +7049,20 @@ impl AppWindow { .flex_col() .child(pane_header_with_action("Activity", Some(header_action))) .child( - pane_body().p_5().child( + pane_body().child( pane_content() .flex_row() .gap_5() - .child(pane_list_card( + .child(pane_list( "activity-list", + PANE_LIST_WIDTH, (self.events.is_empty() && self.activity.error.is_none()) .then_some("No activity yet."), "Activity could not be loaded.", self.activity.error.clone(), rows, )) - .child( - compact_panel() - .flex_1() - .min_w(px(0.0)) - .h_full() - .flex() - .flex_col() - .child(self.render_event_detail()), - ), + .child(pane_detail(self.render_event_detail())), ), ) .into_any_element() @@ -7563,13 +7081,7 @@ impl AppWindow { .overflow_y_scroll() .px_6() .py_6() - .child( - div() - .text_size(px(18.0)) - .font_weight(FontWeight::SEMIBOLD) - .line_height(px(24.0)) - .child(event_summary(event)), - ) + .child(div().text_headline().child(event_summary(event))) .child( div() .pt_6() @@ -7588,10 +7100,10 @@ impl AppWindow { .child(section_label("Raw event")) .child( div() - .pt_3() - .text_size(px(11.0)) - .line_height(px(18.0)) - .text_color(rgb(MUTED)) + .pt_2() + .text_footnote() + .text_monospace() + .text_color(rgb(TEXT_TERTIARY)) .child(raw), ), ) @@ -7749,9 +7261,9 @@ impl AppWindow { div() .flex() .justify_between() - .text_size(px(12.0)) + .text_footnote() .child(label) - .child(div().text_color(rgb(MUTED)).child(display)), + .child(div().text_color(rgb(TEXT_TERTIARY)).child(display)), ) .child( div() @@ -7767,7 +7279,7 @@ impl AppWindow { .top(px(7.0)) .h(px(4.0)) .rounded_full() - .bg(rgb(0x303030)), + .bg(rgb(SURFACE_SELECTED)), ) .child( div() @@ -7788,7 +7300,7 @@ impl AppWindow { .size(px(14.0)) .rounded_full() .border_2() - .border_color(rgb(TEXT)) + .border_color(rgb(TEXT_PRIMARY)) .bg(rgb(SURFACE_SELECTED)), ) .on_mouse_down( @@ -7850,27 +7362,22 @@ impl AppWindow { .flex() .flex_col() .gap_1() - .rounded(px(PANEL_RADIUS)) + .rounded(RADIUS_PANEL) .border_1() .border_color(rgb(if selected { ACCENT } else { LINE })) .bg(rgb(if selected { ACCENT_SURFACE } else { SURFACE })) .hover(|card| card.bg(rgb(SURFACE_HOVER))) + .child(div().text_caption().text_color(rgb(ACCENT_TEXT)).child(key)) .child( div() - .text_size(px(10.0)) - .text_color(rgb(ACCENT_SOFT)) - .child(key), - ) - .child( - div() - .text_size(px(12.0)) - .font_weight(FontWeight::SEMIBOLD) + .text_body() + .font_weight(FontWeight::MEDIUM) .child(name), ) .child( div() - .text_size(px(10.0)) - .text_color(rgb(MUTED)) + .text_caption() + .text_color(rgb(TEXT_TERTIARY)) .child(description), ) .on_click( @@ -7913,125 +7420,78 @@ impl AppWindow { .child(pane_header_with_action( "HUD Lab", Some( - header_button(if copied { "Copied" } else { "Copy preset" }) - .id("copy-hud-preset") - .on_click(cx.listener(move |this, _, _, cx| { - if let Ok(mut clipboard) = arboard::Clipboard::new() { - let _ = clipboard.set_text(this.hud_preset()); - this.hud_copied = true; - cx.notify(); - } - })) - .into_any_element(), + button( + ButtonKind::Secondary, + if copied { "Copied" } else { "Copy preset" }, + ) + .id("copy-hud-preset") + .on_click(cx.listener(move |this, _, _, cx| { + if let Ok(mut clipboard) = arboard::Clipboard::new() { + let _ = clipboard.set_text(this.hud_preset()); + this.hud_copied = true; + cx.notify(); + } + })) + .into_any_element(), ), )) - .child( - pane_body().child( - div() - .id("hud-lab-scroll") - .flex_1() - .overflow_y_scroll() - .px_8() - .py_6() - .child( - div().w_full().flex().justify_center().child( - pane_content() - .gap_6() + .child(pane_scroll_body( + "hud-lab-scroll", + pane_content() + .pt_6() + .gap_6() + .child( + div() + .flex() + .items_center() + .gap_4() + .child(segmented_control().children([ + state_button(HudDemoState::Recording, "Recording", 0), + state_button(HudDemoState::Transcribing, "Transcribing", 1), + state_button(HudDemoState::Processing, "Processing", 2), + ])) + .child( + div() + .flex() + .items_center() + .gap_2() .child( div() - .flex() - .items_center() - .gap_4() - .child(segmented_control().children([ - state_button( - HudDemoState::Recording, - "Recording", - 0, - ), - state_button( - HudDemoState::Transcribing, - "Transcribing", - 1, - ), - state_button( - HudDemoState::Processing, - "Processing", - 2, - ), - ])) - .child( - div() - .flex() - .items_center() - .gap_2() - .child( - div() - .text_size(px(11.0)) - .text_color(rgb(MUTED)) - .child("Queued"), - ) - .child( - segmented_control().children(queue_buttons), - ), - ), + .text_caption() + .text_color(rgb(TEXT_TERTIARY)) + .child("Queued"), ) - .child(div().flex().gap_3().children(style_cards)) - .child(div().flex().child( - div().flex().flex_col().gap_4().children([ - self.render_hud_slider( - HudControl::LineCount, - "Line count", - cx, - ), - self.render_hud_slider( - HudControl::Curvature, - "Curvature", - cx, - ), - self.render_hud_slider(HudControl::Speed, "Speed", cx), - self.render_hud_slider( - HudControl::Blur, - "Highlight blur", - cx, - ), - self.render_hud_slider( - HudControl::Glow, - "Line glow", - cx, - ), - self.render_hud_slider( - HudControl::Depth, - "Sphere depth", - cx, - ), - self.render_hud_slider( - HudControl::LightAngle, - "Light angle", - cx, - ), - self.render_hud_slider( - HudControl::Outline, - "Sphere outline", - cx, - ), - ]), - )) - .child( - div() - .p_3() - .rounded(px(PANEL_RADIUS)) - .border_1() - .border_color(rgb(LINE)) - .bg(rgb(SURFACE)) - .font_family("SF Mono") - .text_size(px(10.0)) - .text_color(rgb(TEXT_SOFT)) - .child(preset), - ), + .child(segmented_control().children(queue_buttons)), ), - ), - ), - ) + ) + .child(div().flex().gap_3().children(style_cards)) + .child( + div() + .flex() + .child(div().flex().flex_col().gap_4().children([ + self.render_hud_slider(HudControl::LineCount, "Line count", cx), + self.render_hud_slider(HudControl::Curvature, "Curvature", cx), + self.render_hud_slider(HudControl::Speed, "Speed", cx), + self.render_hud_slider(HudControl::Blur, "Highlight blur", cx), + self.render_hud_slider(HudControl::Glow, "Line glow", cx), + self.render_hud_slider(HudControl::Depth, "Sphere depth", cx), + self.render_hud_slider(HudControl::LightAngle, "Light angle", cx), + self.render_hud_slider(HudControl::Outline, "Sphere outline", cx), + ])), + ) + .child( + div() + .p_3() + .rounded(RADIUS_PANEL) + .border_1() + .border_color(rgb(LINE)) + .bg(rgb(SURFACE)) + .text_monospace() + .text_caption() + .text_color(rgb(TEXT_SECONDARY)) + .child(preset), + ), + )) .into_any_element() } } @@ -8179,7 +7639,7 @@ impl Render for AppWindow { .when_some(self.settings_load_error.clone(), |column, error| { column.child( div() - .px_5() + .px(px(PANE_GUTTER)) .py_2() .debug_selector(|| "settings-error".into()) .child(error_message("Settings error:", error)), @@ -8266,17 +7726,11 @@ fn preview_history() -> Option<History> { Some(History::new(store)) } -fn voice_action_status_copy(enabled: bool) -> (&'static str, &'static str) { +fn voice_action_description(enabled: bool) -> &'static str { if enabled { - ( - "Enabled", - "Use the shortcut below with OpenCode to run spoken instructions.", - ) + "Use the shortcut below with OpenCode to run spoken instructions" } else { - ( - "Off", - "Off by default. Its saved shortcut is not reserved until you enable Voice Action.", - ) + "Off by default. Its saved shortcut is not reserved until you enable Voice Action." } } @@ -8427,7 +7881,7 @@ fn hotkey_keycaps_width(keycap_count: usize) -> f32 { if keycap_count == 0 { 0.0 } else { - 34.0 * keycap_count as f32 + 3.0 * keycap_count.saturating_sub(1) as f32 + KEYCAP_WIDTH * keycap_count as f32 + 3.0 * keycap_count.saturating_sub(1) as f32 } } @@ -8482,35 +7936,18 @@ fn mode_row( let subtitle = subtitle.into(); let has_activations = !applications.is_empty() || !browser_hosts.is_empty(); list_row(selected) - .min_h(px(52.0)) - .items_start() - .justify_center() - .child( - div() - .w_full() - .overflow_x_hidden() - .text_size(px(12.0)) - .font_weight(FontWeight::SEMIBOLD) - .text_color(rgb(TEXT)) - .truncate() - .child(title), - ) + .gap_1() + .child(list_row_title(title, selected)) .when(has_activations, |row| { row.child(mode_activation_icons(applications, browser_hosts, catalog)) }) .when(!has_activations, |row| { - row.child( - div() - .w_full() - .overflow_x_hidden() - .text_size(px(10.0)) - .text_color(rgb(if selected { TEXT_SOFT } else { FAINT })) - .truncate() - .child(subtitle), - ) + row.child(list_row_meta(subtitle, selected)) }) } +const ACTIVATION_ICON_SIZE: f32 = 18.0; + fn mode_activation_icons( applications: &[String], browser_hosts: &[String], @@ -8521,52 +7958,34 @@ fn mode_activation_icons( let mut icons = Vec::with_capacity(visible + usize::from(total > visible)); for name in applications.iter().take(visible) { let application = catalog.application_named(name); - icons.push(application_icon(application, Some(name), 18.0)); + icons.push(application_icon( + application, + Some(name), + ACTIVATION_ICON_SIZE, + )); } - for host in browser_hosts + for _ in browser_hosts .iter() .take(visible.saturating_sub(icons.len())) { - let initial = host - .chars() - .find(|character| character.is_alphanumeric()) - .map(|character| character.to_uppercase().to_string()) - .unwrap_or_else(|| "?".into()); icons.push( div() - .size(px(18.0)) + .size(px(ACTIVATION_ICON_SIZE)) .flex_none() .flex() .items_center() .justify_center() - .rounded(px(4.0)) - .border_1() - .border_color(rgb(0x3f3f3f)) - .bg(rgb(0x282828)) - .text_size(px(8.0)) - .font_weight(FontWeight::SEMIBOLD) - .text_color(rgb(TEXT_SOFT)) - .child(initial) + .rounded(icon_radius(ACTIVATION_ICON_SIZE)) + .bg(rgb(SURFACE_SELECTED)) + .child(icon(Icon::Globe, TEXT_SECONDARY)) .into_any_element(), ); } if total > visible { - icons.push( - div() - .h(px(18.0)) - .px_1() - .flex() - .items_center() - .rounded(px(4.0)) - .bg(rgb(0x303030)) - .text_size(px(8.0)) - .text_color(rgb(MUTED)) - .child(format!("+{}", total - visible)) - .into_any_element(), - ); + icons.push(tag(Tone::Neutral, format!("+{}", total - visible)).into_any_element()); } div() - .h(px(18.0)) + .h(px(ACTIVATION_ICON_SIZE)) .flex() .items_center() .gap_1() @@ -8582,7 +8001,7 @@ fn application_icon( if let Some(icon) = application.and_then(|application| application.icon.as_ref()) { return img(icon.clone()) .size(px(size)) - .rounded(px(size * 0.22)) + .rounded(icon_radius(size)) .into_any_element(); } let initial = fallback_name @@ -8591,14 +8010,21 @@ fn application_icon( .unwrap_or_else(|| "?".into()); div() .size(px(size)) + .flex_none() .flex() .items_center() .justify_center() - .rounded(px(size * 0.22)) - .bg(rgb(0x343434)) - .text_size(px(size * 0.42)) + .rounded(icon_radius(size)) + .bg(rgb(SURFACE_SELECTED)) + .map(|monogram| { + if size >= CONTROL_HEIGHT { + monogram.text_body() + } else { + monogram.text_caption() + } + }) .font_weight(FontWeight::SEMIBOLD) - .text_color(rgb(TEXT_SOFT)) + .text_color(rgb(TEXT_SECONDARY)) .child(initial) .into_any_element() } @@ -8729,28 +8155,13 @@ fn model_choice_row( subtitle: impl Into<String>, highlighted: bool, ) -> Div { - div() - .w_full() - .px_3() - .py_2() - .flex() - .items_center() - .justify_between() - .gap_3() - .border_b_1() - .border_color(rgb(LINE)) - .when(highlighted, |row| row.bg(rgb(SURFACE_SELECTED))) - .hover(|row| row.bg(rgb(SURFACE_HOVER))) - .child( - div() - .text_size(px(12.0)) - .text_color(rgb(TEXT_SOFT)) - .child(title.into()), - ) + menu_item(highlighted) + .child(truncated_line(FOOTNOTE, title.into()).flex_1()) .child( div() - .text_size(px(10.0)) - .text_color(rgb(FAINT)) + .flex_none() + .text_caption() + .text_color(rgb(TEXT_TERTIARY)) .child(subtitle.into()), ) } @@ -8772,17 +8183,11 @@ fn settings_control( .items_center() .justify_between() .gap_2() + .child(field_label(label)) .child( div() - .text_size(px(11.0)) - .font_weight(FontWeight::SEMIBOLD) - .text_color(rgb(TEXT_SOFT)) - .child(label), - ) - .child( - div() - .text_size(px(10.0)) - .text_color(rgb(FAINT)) + .text_caption() + .text_color(rgb(TEXT_TERTIARY)) .child(description.into()), ), ) @@ -8790,6 +8195,15 @@ fn settings_control( .into_any_element() } +/// The label above a stacked form field. +fn field_label(label: &'static str) -> Div { + div() + .text_footnote() + .font_weight(FontWeight::MEDIUM) + .text_color(rgb(TEXT_SECONDARY)) + .child(label) +} + fn input_text(input: &ProcessingInput, cx: &App) -> String { input.entity.read(cx).text().trim().to_string() } @@ -8890,43 +8304,19 @@ fn command_model_notice(status: &CommandModelStatus) -> Option<(&'static str, &' } } -fn setup_group_label(label: &'static str) -> Div { - div() - .px_1() - .text_size(px(10.0)) - .font_weight(FontWeight::SEMIBOLD) - .text_color(rgb(MUTED)) - .child(label) -} - -fn setup_ready_badge() -> AnyElement { - div() - .h(px(28.0)) - .px_3() - .flex() - .items_center() - .rounded_sm() - .bg(rgb(POSITIVE_SURFACE)) - .text_size(px(11.0)) - .font_weight(FontWeight::SEMIBOLD) - .text_color(rgb(POSITIVE)) - .child("Ready") - .into_any_element() -} - const fn permission_warning_copy(kind: PermissionKind) -> (&'static str, &'static str) { match kind { PermissionKind::Microphone => ( "Microphone access is off", - "HEX cannot record dictation until microphone access is restored.", + "Hex cannot record dictation until microphone access is restored.", ), PermissionKind::InputMonitoring => ( "Input Monitoring is off", - "HEX cannot recognize the dictation shortcut in other apps.", + "Hex cannot recognize the dictation shortcut in other apps.", ), PermissionKind::Accessibility => ( "Accessibility is off", - "HEX cannot paste completed dictation into the foreground app.", + "Hex cannot paste completed dictation into the foreground app.", ), } } @@ -8934,9 +8324,9 @@ const fn permission_warning_copy(kind: PermissionKind) -> (&'static str, &'stati const fn permission_action_label(action: PermissionAction) -> &'static str { match action { PermissionAction::RequestMicrophone => "Allow", - PermissionAction::OpenMicrophoneSettings => "Open Settings", + PermissionAction::OpenMicrophoneSettings => "Open settings", PermissionAction::OpenInputMonitoringSettings - | PermissionAction::OpenAccessibilitySettings => "Grant Access", + | PermissionAction::OpenAccessibilitySettings => "Grant access", } } @@ -9031,46 +8421,6 @@ fn is_function_key(label: &str) -> bool { .is_some_and(|number| (1..=20).contains(&number)) } -fn detail_placeholder(message: &'static str) -> AnyElement { - div() - .flex_1() - .h_full() - .flex() - .items_center() - .justify_center() - .text_size(px(12.0)) - .text_color(rgb(FAINT)) - .child(message) - .into_any_element() -} - -fn detail_row(label: &'static str, value: impl Into<String>) -> AnyElement { - div() - .py_3() - .flex() - .items_start() - .gap_4() - .border_b_1() - .border_color(rgb(LINE)) - .child( - div() - .w(px(92.0)) - .flex_none() - .text_size(px(11.0)) - .text_color(rgb(FAINT)) - .child(label), - ) - .child( - div() - .flex_1() - .text_size(px(12.0)) - .line_height(px(18.0)) - .text_color(rgb(TEXT_SOFT)) - .child(value.into()), - ) - .into_any_element() -} - fn command_task(command: &CommandInfo) -> String { if let Some(group) = &command.group { return group.clone(); @@ -9132,32 +8482,6 @@ fn command_scope(scope: &CommandScope) -> String { } } -fn pane_list_card( - id: &'static str, - empty: Option<&'static str>, - error_title: &'static str, - error: Option<String>, - rows: Vec<AnyElement>, -) -> gpui::Stateful<Div> { - pane_list(id, empty, error_title, error) - .rounded(px(PANEL_RADIUS)) - .border_1() - .border_color(rgb(LINE)) - .bg(rgb(SURFACE)) - .overflow_x_hidden() - .when(!rows.is_empty(), |list| { - list.child( - div() - .w(px(PANE_LIST_WIDTH - 2.0)) - .p_2() - .flex() - .flex_col() - .gap_1() - .children(rows), - ) - }) -} - fn timestamped_list_row( id: (&'static str, usize), selected: bool, @@ -9174,28 +8498,17 @@ fn timestamped_list_row( .child( div() .flex_none() - .text_size(px(10.0)) - .text_color(rgb(if selected { TEXT_SOFT } else { FAINT })) + .pt(px(baseline_offset(BODY, CAPTION))) + .text_caption() + .text_color(rgb(if selected { + TEXT_SECONDARY + } else { + TEXT_TERTIARY + })) .child(age), ) } -fn meeting_action_button(id: &'static str, label: &'static str) -> gpui::Stateful<Div> { - div() - .id(id) - .h(px(30.0)) - .px_3() - .flex() - .items_center() - .rounded_sm() - .bg(rgb(0xe8e8e8)) - .text_size(px(12.0)) - .font_weight(FontWeight::SEMIBOLD) - .text_color(rgb(0x171717)) - .hover(|button| button.bg(rgb(0xffffff))) - .child(label) -} - fn render_meeting_notes(notes: meeting::MeetingNotes) -> Div { let summary = notes.summary.trim().to_string(); div() @@ -9206,12 +8519,15 @@ fn render_meeting_notes(notes: meeting::MeetingNotes) -> Div { .gap(px(6.0)) .border_b_1() .border_color(rgb(LINE)) - .child(compact_section_label("SUMMARY").px_0().pb_1()) + .child(section_label("Summary")) .child( div() - .text_size(px(13.0)) - .line_height(px(20.0)) - .text_color(rgb(if summary.is_empty() { MUTED } else { TEXT_SOFT })) + .text_body() + .text_color(rgb(if summary.is_empty() { + TEXT_TERTIARY + } else { + TEXT_SECONDARY + })) .child(if summary.is_empty() { "Nothing to summarize.".to_string() } else { @@ -9220,44 +8536,24 @@ fn render_meeting_notes(notes: meeting::MeetingNotes) -> Div { ) } -/// A compact status chip; `Ready` appears only in the detail header, so the +/// A compact status tag; `Ready` appears only in the detail header, so the /// list stays quiet for finished meetings. fn meeting_status_chip(status: MeetingStatus, stop_pending: bool, show_ready: bool) -> Option<Div> { - let (label, color, surface, border) = match status { - MeetingStatus::Recording if stop_pending => { - ("Transcribing", ACCENT_SOFT, ACCENT_SURFACE, 0x2c3560) - } - MeetingStatus::Starting | MeetingStatus::Recording => { - ("Recording", NEGATIVE, DANGER_SURFACE, DANGER_BORDER) - } - MeetingStatus::Transcribing => ("Transcribing", ACCENT_SOFT, ACCENT_SURFACE, 0x2c3560), - MeetingStatus::Complete if show_ready => ("Ready", POSITIVE, POSITIVE_SURFACE, 0x24452f), + let (label, tone) = match status { + MeetingStatus::Recording if stop_pending => ("Transcribing", Tone::Accent), + MeetingStatus::Starting | MeetingStatus::Recording => ("Recording", Tone::Danger), + MeetingStatus::Transcribing => ("Transcribing", Tone::Accent), + MeetingStatus::Complete if show_ready => ("Ready", Tone::Success), MeetingStatus::Complete => return None, - MeetingStatus::Interrupted => ("Interrupted", MUTED, CANVAS, LINE), - MeetingStatus::Failed => ("Failed", NEGATIVE, DANGER_SURFACE, DANGER_BORDER), + MeetingStatus::Interrupted => ("Interrupted", Tone::Neutral), + MeetingStatus::Failed => ("Failed", Tone::Danger), }; let live = matches!(status, MeetingStatus::Starting | MeetingStatus::Recording) && !stop_pending; - Some( - div() - .flex_none() - .h(px(18.0)) - .px(px(6.0)) - .flex() - .items_center() - .gap(px(4.0)) - .rounded(px(4.0)) - .border_1() - .border_color(rgb(border)) - .bg(rgb(surface)) - .text_size(px(10.0)) - .font_weight(FontWeight::MEDIUM) - .text_color(rgb(color)) - .when(live, |chip| { - chip.child(div().size(px(5.0)).rounded_full().bg(rgb(NEGATIVE))) - }) - .child(label), - ) + Some(tag(tone, label).when(live, |chip| { + chip.child(status_dot(DANGER).size(px(5.0))) + .flex_row_reverse() + })) } /// `42 min`, `1 h 05 min`, or `< 1 min`. @@ -9303,8 +8599,8 @@ fn transcript_turn(index: usize, line: &TranscriptLine) -> AnyElement { .flex() .items_center() .gap_3() - .text_size(px(10.0)) - .text_color(rgb(FAINT)) + .text_caption() + .text_color(rgb(TEXT_TERTIARY)) .child(div().h(px(1.0)).flex_1().bg(rgb(LINE))) .child(format!("{} pause", meeting_length(pause))) .child(div().h(px(1.0)).flex_1().bg(rgb(LINE))), @@ -9318,16 +8614,17 @@ fn transcript_turn(index: usize, line: &TranscriptLine) -> AnyElement { .when(mine, |turn| turn.items_end()) .when(!mine, |turn| turn.items_start()) .child( + // Inset to the bubble's text, not its rounded edge. div() - .px_1() + .px_3() .flex() .gap_2() - .text_size(px(10.0)) - .text_color(rgb(FAINT)) + .text_caption() + .text_color(rgb(TEXT_TERTIARY)) .child( div() .font_weight(FontWeight::SEMIBOLD) - .text_color(rgb(if mine { ACCENT_SOFT } else { MUTED })) + .text_color(rgb(if mine { ACCENT_TEXT } else { TEXT_TERTIARY })) .child(line.source.label()), ) .child(line.clock.clone()), @@ -9338,22 +8635,21 @@ fn transcript_turn(index: usize, line: &TranscriptLine) -> AnyElement { .max_w(relative(0.82)) .px_3() .py_2() - .rounded(px(10.0)) + .rounded(RADIUS_PANEL) .border_1() .when(mine, |bubble| { bubble - .border_color(rgb(0x2c3560)) + .border_color(rgb(ACCENT_BORDER)) .bg(rgb(ACCENT_SURFACE)) - .text_color(rgb(TEXT)) + .text_color(rgb(TEXT_PRIMARY)) }) .when(!mine, |bubble| { bubble .border_color(rgb(LINE)) .bg(rgb(CANVAS)) - .text_color(rgb(TEXT_SOFT)) + .text_color(rgb(TEXT_SECONDARY)) }) - .text_size(px(13.0)) - .line_height(px(20.0)) + .text_body() .child(line.text.clone()), ), ) @@ -9514,20 +8810,6 @@ over the next couple of months. The build screen needs a proposal before more po (vec![live, synced, design, failed], transcripts) } -fn meeting_progress_action(label: &'static str) -> AnyElement { - div() - .h(px(30.0)) - .px_3() - .flex() - .items_center() - .rounded_sm() - .bg(rgb(SURFACE)) - .text_size(px(12.0)) - .text_color(rgb(MUTED)) - .child(label) - .into_any_element() -} - fn log_meeting_request_error(error: TrySendError<MeetingRequest>) { match error { TrySendError::Full(_) => tracing::warn!("meeting controller is busy"), @@ -9577,13 +8859,13 @@ fn event_age(timestamp_ms: u64) -> String { fn event_summary(event: &VoiceEvent) -> String { match event { - VoiceEvent::SessionStarted { .. } => "HEX session started".into(), + VoiceEvent::SessionStarted { .. } => "Hex session started".into(), VoiceEvent::State { state, device, .. } => match state { VoiceState::Sleeping => "Voice commands went to sleep".into(), VoiceState::Listening => format!("Voice commands resumed on {device}"), VoiceState::Dictating => format!("Dictation started on {device}"), VoiceState::Transcribing => "Dictation is being transcribed".into(), - VoiceState::Stopping => "HEX is stopping".into(), + VoiceState::Stopping => "Hex is stopping".into(), }, VoiceEvent::Transcript { phase, @@ -9604,7 +8886,7 @@ fn event_summary(event: &VoiceEvent) -> String { let command = command.as_deref().unwrap_or("an unmatched command"); match outcome { CommandOutcome::Ignored => format!("Ignored \"{heard}\""), - CommandOutcome::Woke => format!("\"{heard}\" woke HEX"), + CommandOutcome::Woke => format!("\"{heard}\" woke Hex"), CommandOutcome::Slept => format!("\"{heard}\" put voice commands to sleep"), CommandOutcome::Submitted => format!("Sent {command} for \"{heard}\""), CommandOutcome::Executed => format!("Ran {command} for \"{heard}\""), @@ -9683,7 +8965,11 @@ mod tests { use super::*; use crate::personal_commands::StatusExecution; - fn editor_fixture(window: &mut Window, cx: &mut Context<AppWindow>) -> AppWindow { + fn preview_fixture( + preview: AppWindowPreview, + window: &mut Window, + cx: &mut Context<AppWindow>, + ) -> AppWindow { AppWindow::new( PathBuf::from("unused-preview-events.ndjson"), crate::config::voice_control(), @@ -9691,66 +8977,68 @@ mod tests { crate::dictation_indicator::channel().0, None, None, - Some(AppWindowPreview { - pane: DeveloperPane::Modes, - transcription_picker: None, - onboarding: false, - confirm_model_deletion: false, + Some(preview), + window, + cx, + ) + } + + fn pane_preview(pane: DeveloperPane) -> AppWindowPreview { + AppWindowPreview { + pane, + transcription_picker: None, + onboarding: false, + confirm_model_deletion: false, + collapse_mode_processing: false, + open_transformation_picker: false, + select_global_mode: false, + voice_action_enabled: false, + opencode_unavailable: false, + permissions_missing: false, + model_missing: false, + command_model_missing: false, + open_history_retention: false, + confirm_release_microphone: false, + update_available: false, + meetings_enabled: true, + screen_recording_missing: false, + meeting_recording: false, + confirm_meeting_delete: false, + open_meeting_actions: false, + meetings_empty: false, + } + } + + fn editor_fixture(window: &mut Window, cx: &mut Context<AppWindow>) -> AppWindow { + preview_fixture( + AppWindowPreview { collapse_mode_processing: true, - open_transformation_picker: false, select_global_mode: true, - voice_action_enabled: false, opencode_unavailable: true, - permissions_missing: false, - model_missing: false, - command_model_missing: false, - open_history_retention: false, - confirm_release_microphone: false, - update_available: false, meetings_enabled: false, - screen_recording_missing: false, - meeting_recording: false, - confirm_meeting_delete: false, - meetings_empty: false, - }), + ..pane_preview(DeveloperPane::Modes) + }, window, cx, ) } fn meetings_fixture(window: &mut Window, cx: &mut Context<AppWindow>) -> AppWindow { - AppWindow::new( - PathBuf::from("unused-preview-events.ndjson"), - crate::config::voice_control(), - sync_channel(1).0, - crate::dictation_indicator::channel().0, - None, - None, - Some(AppWindowPreview { - pane: DeveloperPane::Meetings, - transcription_picker: None, - onboarding: false, - confirm_model_deletion: false, - collapse_mode_processing: false, - open_transformation_picker: false, - select_global_mode: false, - voice_action_enabled: false, - opencode_unavailable: false, - permissions_missing: false, - model_missing: false, - command_model_missing: false, - open_history_retention: false, - confirm_release_microphone: false, - update_available: false, - meetings_enabled: true, - screen_recording_missing: false, - meeting_recording: false, - confirm_meeting_delete: false, - meetings_empty: false, - }), - window, - cx, - ) + preview_fixture(pane_preview(DeveloperPane::Meetings), window, cx) + } + + fn history_fixture(window: &mut Window, cx: &mut Context<AppWindow>) -> AppWindow { + preview_fixture(pane_preview(DeveloperPane::History), window, cx) + } + + #[gpui::test] + fn history_detail_rows_keep_their_values_on_one_line(cx: &mut gpui::TestAppContext) { + let (_, cx) = cx.add_window_view(history_fixture); + cx.simulate_resize(size(px(WINDOW_WIDTH), px(MINIMUM_HEIGHT))); + cx.update(|window, _| window.refresh()); + cx.run_until_parked(); + let value = cx.debug_bounds("detail-row-value").expect("detail row"); + assert!(value.size.width > px(200.0), "{value:?}"); } #[gpui::test] @@ -9763,7 +9051,7 @@ mod tests { let detail = cx.debug_bounds("meeting-detail").expect("detail rendered"); let them = cx.debug_bounds("meeting-bubble-0").expect("first turn"); let you = cx.debug_bounds("meeting-bubble-1").expect("second turn"); - let actions = cx.debug_bounds("meeting-actions").expect("actions"); + let actions = cx.debug_bounds("detail-actions").expect("actions"); for bounds in [them, you, actions] { assert!( bounds.left() >= detail.left() && bounds.right() <= detail.right(), @@ -9779,6 +9067,29 @@ mod tests { } } + #[gpui::test] + fn meeting_overflow_menu_opens_beside_the_visible_actions(cx: &mut gpui::TestAppContext) { + let (view, cx) = cx.add_window_view(meetings_fixture); + cx.update(|window, cx| { + view.update(cx, |view, _| view.meeting_actions_open = true); + window.refresh(); + }); + cx.run_until_parked(); + let actions = cx.debug_bounds("detail-actions").expect("actions"); + let menu = cx.debug_bounds("meeting-actions-menu").expect("menu"); + assert!(menu.top() >= actions.bottom() - px(CONTROL_HEIGHT)); + assert_eq!(menu.size.width, px(MENU_WIDTH)); + cx.update(|_, cx| { + view.update(cx, |view, _| { + view.select_meeting("preview-design".into()); + assert!( + !view.meeting_actions_open, + "a new selection closes the menu" + ); + }) + }); + } + #[gpui::test] fn meeting_search_delete_and_rename_work_on_the_selected_meeting( cx: &mut gpui::TestAppContext, @@ -10172,18 +9483,20 @@ mod tests { impl Render for SettingsLayout { fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement { window_frame() - .child(sidebar_frame().w(px(SIDEBAR_WIDTH))) + .child(sidebar_frame()) .child( div().flex_1().min_w_0().h_full().child(settings_pane( + "Settings", + "settings-scroll", div() - .child(settings_panel().child(settings_row( + .child(panel().child(settings_row( "Microphone", "Automatically chooses the preferred available input", disclosure_button("Automatic"), ))) .child(settings_copy( "Release the microphone when idle?", - "HEX will open the microphone when you press the shortcut. This adds a start-up delay and removes pre-roll, so the beginning of speech may be missed if you speak right away.", + "Hex will open the microphone when you press the shortcut. This adds a start-up delay and removes pre-roll, so the beginning of speech may be missed if you speak right away.", )), )), ) @@ -10455,13 +9768,11 @@ mod tests { fn voice_action_defaults_off_and_explains_its_inactive_shortcut() { let settings = AppSettings::default(); assert!(!settings.voice_action.enabled); - let (status, description) = voice_action_status_copy(settings.voice_action.enabled); - assert_eq!(status, "Off"); + let description = voice_action_description(settings.voice_action.enabled); assert!(description.contains("Off by default")); assert!(description.contains("not reserved")); - let (status, description) = voice_action_status_copy(true); - assert_eq!(status, "Enabled"); + let description = voice_action_description(true); assert!(description.contains("OpenCode")); assert!(!description.contains("not reserved")); } @@ -10869,7 +10180,7 @@ mod tests { assert_eq!(copy.title, "OpenCode is unavailable"); assert_eq!( copy.description, - "HEX could not load the OpenCode model catalog." + "Hex could not load the OpenCode model catalog." ); assert_eq!( copy.error.as_deref(), @@ -10899,7 +10210,7 @@ mod tests { assert_eq!(loading.title, "Checking for OpenCode"); assert_eq!( loading.description, - "OpenCode transformation will be available after HEX connects to OpenCode." + "OpenCode transformation will be available after Hex connects to OpenCode." ); assert!(!loading.can_retry); assert!(!loading.can_open_setup); diff --git a/src/design_tokens.rs b/src/design_tokens.rs new file mode 100644 index 00000000..8e445e54 --- /dev/null +++ b/src/design_tokens.rs @@ -0,0 +1,318 @@ +//! The desktop design tokens: the only place raw colours, type sizes, line +//! heights, radii, and control heights are written down. Panes and components +//! name these tokens; `docs/design-system.md` explains when to use each one. + +use gpui::{FontWeight, Pixels, Styled, px}; + +// Surfaces: the OpenCode V2 dark hierarchy, darkest (recessed) to lightest. +pub(crate) const SIDEBAR: u32 = 0x111111; +pub(crate) const SIDEBAR_HOVER: u32 = 0x1b1b1b; +pub(crate) const CANVAS: u32 = 0x161616; +pub(crate) const SURFACE: u32 = 0x1d1d1d; +pub(crate) const SURFACE_HOVER: u32 = 0x252525; +pub(crate) const SURFACE_SELECTED: u32 = 0x2e2e2e; +pub(crate) const LINE: u32 = 0x2a2a2a; +pub(crate) const LINE_STRONG: u32 = 0x3d3d3d; +/// Modal backdrops. +pub(crate) const SCRIM: u32 = 0x000000cc; + +// Text, strongest to weakest. Tertiary is the weakest colour that still meets +// 4.5:1 on every surface; disabled is reserved for unavailable controls. +pub(crate) const TEXT_PRIMARY: u32 = 0xf2f2f2; +pub(crate) const TEXT_SECONDARY: u32 = 0xd0d0d0; +pub(crate) const TEXT_TERTIARY: u32 = 0x969696; +pub(crate) const TEXT_DISABLED: u32 = 0x6e6e6e; + +// Roles. Each has a text/fill colour, a tinted surface, and a border. +pub(crate) const ACCENT: u32 = 0x3b5cf6; +pub(crate) const ACCENT_TEXT: u32 = 0x8390ff; +pub(crate) const ACCENT_SURFACE: u32 = 0x1c2030; +pub(crate) const ACCENT_BORDER: u32 = 0x2c3560; +/// Text selection highlight: the accent at one-third opacity. +pub(crate) const SELECTION: u32 = 0x3b5cf655; +pub(crate) const SUCCESS: u32 = 0x69d89f; +pub(crate) const SUCCESS_SURFACE: u32 = 0x17231a; +pub(crate) const DANGER: u32 = 0xf17471; +pub(crate) const DANGER_SURFACE: u32 = 0x2b1718; +pub(crate) const DANGER_BORDER: u32 = 0x66292c; + +/// One step of the type scale. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct TypeStep { + pub(crate) size: f32, + pub(crate) line_height: f32, + /// `None` inherits the surrounding weight. + pub(crate) weight: Option<FontWeight>, +} + +/// Metadata, timestamps, tags, and section labels. +pub(crate) const CAPTION: TypeStep = TypeStep { + size: 11.0, + line_height: 14.0, + weight: None, +}; +/// Descriptions, control labels, and secondary copy. +pub(crate) const FOOTNOTE: TypeStep = TypeStep { + size: 12.0, + line_height: 16.0, + weight: None, +}; +/// The window default: values, inputs, list titles, and reading text. +pub(crate) const BODY: TypeStep = TypeStep { + size: 13.0, + line_height: 18.0, + weight: None, +}; +/// Detail, card, and dialog titles. +pub(crate) const HEADLINE: TypeStep = TypeStep { + size: 15.0, + line_height: 20.0, + weight: Some(FontWeight::SEMIBOLD), +}; +/// Pane titles. +pub(crate) const TITLE: TypeStep = TypeStep { + size: 20.0, + line_height: 26.0, + weight: Some(FontWeight::SEMIBOLD), +}; + +/// The only text below the scale: the L/R badge in a sided modifier keycap. +pub(crate) const KEYCAP_SIDE_BADGE: TypeStep = TypeStep { + size: 7.0, + line_height: 8.0, + weight: Some(FontWeight::SEMIBOLD), +}; + +pub(crate) const MONOSPACE_FAMILY: &str = "SF Mono"; + +/// SF Pro's ascender and descender as fractions of the font size. +const FONT_ASCENT: f32 = 0.952; +const FONT_DESCENT: f32 = 0.241; + +/// The top inset that puts `smaller`'s first baseline on `larger`'s when both +/// lines start at the same top, such as a timestamp beside a row title. +pub(crate) fn baseline_offset(larger: TypeStep, smaller: TypeStep) -> f32 { + // GPUI centres the font's ascent + descent in the line box. + let baseline = + |step: TypeStep| step.line_height / 2.0 + step.size * (FONT_ASCENT - FONT_DESCENT) / 2.0; + baseline(larger) - baseline(smaller) +} + +/// Applies type steps to any styled element. Weight is applied only by steps +/// that own one, so call `font_weight` after the step to emphasise text. +pub(crate) trait Typography: Styled + Sized { + fn text_step(self, step: TypeStep) -> Self { + let styled = self + .text_size(px(step.size)) + .line_height(px(step.line_height)); + match step.weight { + Some(weight) => styled.font_weight(weight), + None => styled, + } + } + + fn text_caption(self) -> Self { + self.text_step(CAPTION) + } + + fn text_footnote(self) -> Self { + self.text_step(FOOTNOTE) + } + + fn text_body(self) -> Self { + self.text_step(BODY) + } + + fn text_headline(self) -> Self { + self.text_step(HEADLINE) + } + + fn text_title(self) -> Self { + self.text_step(TITLE) + } + + fn text_monospace(self) -> Self { + self.font_family(MONOSPACE_FAMILY) + } +} + +impl<E: Styled> Typography for E {} + +// Spacing: a 4 px grid. Prefer GPUI's spacing utilities (`*_1` 4, `*_2` 8, +// `*_3` 12, `*_4` 16, `*_5` 20, `*_6` 24, `*_8` 32); `SPACE_2` is named for +// arithmetic on control heights and negative icon-button margins. +pub(crate) const SPACE_2: f32 = 8.0; + +// Radii. +pub(crate) const RADIUS_SMALL: Pixels = px(4.0); +pub(crate) const RADIUS_CONTROL: Pixels = px(6.0); +pub(crate) const RADIUS_PANEL: Pixels = px(10.0); +pub(crate) const RADIUS_MODAL: Pixels = px(12.0); +pub(crate) const RADIUS_KNOB: Pixels = px(2.0); + +/// The corner radius of an application icon or monogram of `size`. +pub(crate) fn icon_radius(size: f32) -> Pixels { + px(size * 0.22) +} + +// Control geometry. +pub(crate) const CONTROL_HEIGHT: f32 = 30.0; +pub(crate) const CONTROL_HEIGHT_SMALL: f32 = 24.0; +pub(crate) const KEYCAP_WIDTH: f32 = 34.0; +pub(crate) const TAG_HEIGHT: f32 = 18.0; +pub(crate) const MENU_ITEM_HEIGHT: f32 = 28.0; +pub(crate) const MENU_WIDTH: f32 = 180.0; +/// The gap between a control and the menu it opens. +pub(crate) const MENU_OFFSET: f32 = 4.0; +pub(crate) const NAV_ITEM_HEIGHT: f32 = 32.0; +pub(crate) const MULTILINE_INPUT_HEIGHT: f32 = 76.0; +pub(crate) const DISCLOSURE_WIDTH: f32 = 220.0; +pub(crate) const SEARCH_FIELD_WIDTH: f32 = 220.0; +/// The widest an inline field beside a row label grows. +pub(crate) const MODE_FIELD_WIDTH: f32 = 300.0; +pub(crate) const ICON_SIZE: f32 = 14.0; +pub(crate) const ICON_SIZE_SMALL: f32 = 11.0; +pub(crate) const TOGGLE_WIDTH: f32 = 28.0; +pub(crate) const TOGGLE_HEIGHT: f32 = 16.0; +/// Opacity of a control that cannot currently be used. +pub(crate) const DISABLED_OPACITY: f32 = 0.4; + +// Pane geometry. +pub(crate) const SIDEBAR_WIDTH: f32 = 220.0; +/// Space above the sidebar's first item, clear of the window controls. +pub(crate) const TITLEBAR_INSET: f32 = 52.0; +pub(crate) const PANE_HEADER_HEIGHT: f32 = 70.0; +/// Horizontal inset shared by every pane header and body, so cards align +/// with the header rule. +pub(crate) const PANE_GUTTER: f32 = 32.0; +/// The one content width every pane is bounded to. +pub(crate) const PANE_CONTENT_WIDTH: f32 = 940.0; +/// The list column of a list+detail pane. +pub(crate) const PANE_LIST_WIDTH: f32 = 320.0; +/// The narrower list column for panes whose detail is an editor (Modes). +pub(crate) const PANE_LIST_WIDTH_COMPACT: f32 = 220.0; +pub(crate) const MODAL_WIDTH: f32 = 780.0; +pub(crate) const MODAL_HEIGHT: f32 = 520.0; +/// A determinate or indeterminate progress track. +pub(crate) const PROGRESS_HEIGHT: f32 = 3.0; +pub(crate) const PANEL_HEADER_HEIGHT: f32 = 38.0; +pub(crate) const SETTINGS_ROW_MIN_HEIGHT: f32 = 56.0; + +#[cfg(test)] +mod tests { + use super::*; + + fn luminance(hex: u32) -> f64 { + let channel = |shift: u32| { + let value = f64::from((hex >> shift) & 0xff) / 255.0; + if value <= 0.039_28 { + value / 12.92 + } else { + ((value + 0.055) / 1.055).powf(2.4) + } + }; + 0.2126 * channel(16) + 0.7152 * channel(8) + 0.0722 * channel(0) + } + + fn contrast(foreground: u32, background: u32) -> f64 { + let (light, dark) = { + let (a, b) = (luminance(foreground), luminance(background)); + if a > b { (a, b) } else { (b, a) } + }; + (light + 0.05) / (dark + 0.05) + } + + #[test] + fn readable_text_meets_aa_contrast_on_every_surface() { + for background in [SIDEBAR, CANVAS, SURFACE, SURFACE_HOVER, SURFACE_SELECTED] { + for text in [TEXT_PRIMARY, TEXT_SECONDARY, TEXT_TERTIARY] { + let ratio = contrast(text, background); + assert!( + ratio >= 4.5, + "{text:06x} on {background:06x} is {ratio:.2}:1" + ); + } + } + for (text, surface) in [ + (ACCENT_TEXT, ACCENT_SURFACE), + (SUCCESS, SUCCESS_SURFACE), + (DANGER, DANGER_SURFACE), + ] { + let ratio = contrast(text, surface); + assert!(ratio >= 4.5, "{text:06x} on {surface:06x} is {ratio:.2}:1"); + } + } + + #[test] + fn type_scale_ascends() { + let steps = [CAPTION, FOOTNOTE, BODY, HEADLINE, TITLE]; + for pair in steps.windows(2) { + assert!(pair[0].size < pair[1].size); + assert!(pair[0].line_height < pair[1].line_height); + } + for step in steps { + assert!(step.line_height >= step.size * 1.25); + } + } + + #[test] + fn baseline_offset_lowers_smaller_text_onto_the_larger_baseline() { + assert_eq!(baseline_offset(BODY, BODY), 0.0); + let caption = baseline_offset(BODY, CAPTION); + assert!((2.5..3.0).contains(&caption), "{caption}"); + assert!(baseline_offset(HEADLINE, CAPTION) > caption); + } + + /// Raw styling values belong here. A pane that writes a literal colour, + /// font size, line height, or radius, or truncates text by hand, bypasses + /// the design system. + #[test] + fn desktop_sources_use_tokens_instead_of_raw_styles() { + const SOURCES: &[(&str, &str)] = &[ + ("app_window.rs", include_str!("app_window.rs")), + ("desktop_ui.rs", include_str!("desktop_ui.rs")), + ( + "desktop_transcription_picker.rs", + include_str!("desktop_transcription_picker.rs"), + ), + ("linux_app.rs", include_str!("linux_app.rs")), + ("meeting_watcher.rs", include_str!("meeting_watcher.rs")), + ("text_input.rs", include_str!("text_input.rs")), + ]; + const FORBIDDEN: &[&str] = &[ + "rgb(0x", + "rgba(0x", + ".text_size(", + ".line_height(px(", + ".line_height(relative(", + ".line_height(rems(", + ".font_family(", + ".rounded(px(", + ".rounded_sm()", + ".rounded_md()", + ".rounded_lg()", + ".text_xs()", + ".text_sm()", + ".text_base()", + ".text_lg()", + ".text_xl()", + // One-line text goes through `desktop_ui::truncated_line`. + ".truncate()", + ]; + let mut violations = Vec::new(); + for (file, source) in SOURCES { + for (index, line) in source.lines().enumerate() { + for pattern in FORBIDDEN { + if line.contains(pattern) { + violations.push(format!("{file}:{}: {}", index + 1, line.trim())); + } + } + } + } + assert!( + violations.is_empty(), + "use design_tokens instead of raw styles:\n{}", + violations.join("\n") + ); + } +} diff --git a/src/desktop_transcription_picker.rs b/src/desktop_transcription_picker.rs index 5923f012..b6b11ac8 100644 --- a/src/desktop_transcription_picker.rs +++ b/src/desktop_transcription_picker.rs @@ -2,10 +2,9 @@ use gpui::{ AnyElement, Context, FontWeight, IntoElement, div, prelude::*, px, relative, rgb, rgba, }; +use crate::design_tokens::*; use crate::desktop_ui::{ - ACCENT, CANVAS, DANGER_BORDER, DANGER_SURFACE, FAINT, LINE, MUTED, NEGATIVE, PANEL_RADIUS, - SIDEBAR, SURFACE, SURFACE_HOVER, SURFACE_SELECTED, TEXT, TEXT_SOFT, compact_button, - error_message, + ButtonKind, Icon, Tone, error_panel, icon_button, menu_item, small_button, tag, }; use crate::transcription_models::{ ModelChoice, ModelDefinition, TranscriptionModelId, TranscriptionSelection, language_name, @@ -93,19 +92,9 @@ pub(crate) fn render_transcription_picker<T: TranscriptionPickerDelegate>( .map(|(index, (code, name))| { let selected = *code == selected_language; let code = (*code).to_string(); - div() + menu_item(selected) .id(("transcription-language", index)) - .h(px(34.0)) - .px_3() - .flex() - .items_center() - .rounded(px(6.0)) - .text_size(px(13.0)) - .text_color(if selected { rgb(TEXT) } else { rgb(MUTED) }) - .when(selected, |row| row.bg(rgb(SURFACE_SELECTED))) - .when(!selected, |row| { - row.hover(|row| row.bg(rgb(0x1b1b1b)).text_color(rgb(TEXT_SOFT))) - }) + .text_body() .child(*name) .on_click(cx.listener(move |this, _, _, cx| { cx.stop_propagation(); @@ -116,15 +105,17 @@ pub(crate) fn render_transcription_picker<T: TranscriptionPickerDelegate>( let language = view.language.clone(); let definition = model.choice.model; let mut deletion = ModelDeletion::Unavailable; - let (state_label, action, active, preparing, progress, installed) = match model.status { - TranscriptionPickerStatus::Active => ("Active".into(), None, true, false, None, true), + let (state, action, active, preparing, progress, installed) = match model.status { + TranscriptionPickerStatus::Active => { + (tag(Tone::Accent, "Active"), None, true, false, None, true) + } TranscriptionPickerStatus::Available { installed, deletion: offered, } => { deletion = offered; ( - model.choice.recommendation.label().into(), + tag(Tone::Neutral, model.choice.recommendation.label()), Some(if installed { "Use" } else { "Download" }), false, false, @@ -132,9 +123,14 @@ pub(crate) fn render_transcription_picker<T: TranscriptionPickerDelegate>( installed, ) } - TranscriptionPickerStatus::Preparing { label, progress } => { - (label, Some("Cancel"), false, true, progress, false) - } + TranscriptionPickerStatus::Preparing { label, progress } => ( + tag(Tone::Accent, label), + Some("Cancel"), + false, + true, + progress, + false, + ), }; let mut metadata = if definition.coverage == language_name(&language) { format!("{} · {}", definition.quality_context, definition.timestamps) @@ -147,34 +143,12 @@ pub(crate) fn render_transcription_picker<T: TranscriptionPickerDelegate>( if installed && !active { metadata.push_str(" · Installed"); } - let badge = div() - .h(px(22.0)) - .px_2() - .flex() - .items_center() - .rounded(px(6.0)) - .text_size(px(10.0)) - .map(|badge| { - if active { - badge - .bg(rgb(ACCENT)) - .font_weight(FontWeight::SEMIBOLD) - .text_color(rgb(TEXT)) - } else if preparing { - badge.bg(rgb(SURFACE_SELECTED)).text_color(rgb(TEXT_SOFT)) - } else { - badge - .border_1() - .border_color(rgb(LINE)) - .text_color(rgb(MUTED)) - } - }) - .child(state_label); let actions = div() .flex() + .flex_none() .items_center() .gap_2() - .child(badge) + .child(state) .when_some( match deletion { ModelDeletion::Unavailable => None, @@ -183,18 +157,19 @@ pub(crate) fn render_transcription_picker<T: TranscriptionPickerDelegate>( }, |actions, confirming| { actions.child( - card_button(if confirming { - format!("Delete {}?", definition.size_label()) - } else { - "Delete".into() - }) + small_button( + if confirming { + ButtonKind::Danger + } else { + ButtonKind::Secondary + }, + if confirming { + format!("Delete {}?", definition.size_label()) + } else { + "Delete".into() + }, + ) .id(("transcription-model-delete", index)) - .when(confirming, |button| { - button - .border_color(rgb(DANGER_BORDER)) - .bg(rgb(DANGER_SURFACE)) - .text_color(rgb(NEGATIVE)) - }) .on_click(cx.listener(move |this, _, _, cx| { cx.stop_propagation(); this.delete_transcription_model(definition.id, cx); @@ -203,17 +178,21 @@ pub(crate) fn render_transcription_picker<T: TranscriptionPickerDelegate>( ) }, ) - .when_some(action, |actions, action| actions.child(card_button(action))); + .when_some(action, |actions, action| { + actions.child(small_button(ButtonKind::Secondary, action)) + }); div() .id(("transcription-model", index)) .w_full() .px_4() .py_3() - .mb_2p5() - .rounded(px(PANEL_RADIUS)) + .flex() + .flex_col() + .gap_2() + .rounded(RADIUS_PANEL) .border_1() - .border_color(if active { rgb(ACCENT) } else { rgb(LINE) }) - .bg(rgb(SURFACE)) + .border_color(rgb(if active { ACCENT_BORDER } else { LINE })) + .bg(rgb(if active { ACCENT_SURFACE } else { SURFACE })) .when(!active, |card| { card.cursor_pointer() .hover(|card| card.bg(rgb(SURFACE_HOVER))) @@ -227,20 +206,23 @@ pub(crate) fn render_transcription_picker<T: TranscriptionPickerDelegate>( .child( div() .flex_1() - .min_w(px(0.0)) + .min_w_0() .flex() .flex_col() .gap_1() .child( div() - .text_size(px(13.0)) + .flex() + .items_center() + .gap_2() + .text_body() .font_weight(FontWeight::SEMIBOLD) .child(definition.name), ) .child( div() - .text_size(px(11.0)) - .text_color(rgb(MUTED)) + .text_caption() + .text_color(rgb(TEXT_TERTIARY)) .child(metadata), ), ) @@ -248,48 +230,39 @@ pub(crate) fn render_transcription_picker<T: TranscriptionPickerDelegate>( ) .child( div() - .pt_2p5() .flex() - .gap_2() + .gap_5() .child(model_metric("Speed", definition.realtime)) .child(model_metric("Size", definition.size_label())) .child(model_metric("WER", definition.quality)), ) .when(definition.id == TranscriptionModelId::ParakeetV3, |card| { - card.child( - div() - .pt_2() - .text_size(px(11.0)) - .text_color(rgb(MUTED)) - .child( - "Detects language from audio, not the selected language. \ + card.child(div().text_caption().text_color(rgb(TEXT_TERTIARY)).child( + "Detects language from audio, not the selected language. \ For language-guided transcription, try Whisper.", - ), - ) + )) }) .when_some(progress, |card, progress| { let indicator = match progress { TranscriptionPickerProgress::Downloading(progress) => div() .h_full() .w(relative(progress)) - .rounded_sm() + .rounded(RADIUS_KNOB) .bg(rgb(ACCENT)), TranscriptionPickerProgress::Loading(progress) => div() .ml(relative(progress * 0.75)) .h_full() .w(relative(0.25)) - .rounded_sm() + .rounded(RADIUS_KNOB) .bg(rgb(ACCENT)), }; card.child( - div().pt_2p5().child( - div() - .h(px(3.0)) - .w_full() - .rounded_sm() - .bg(rgb(CANVAS)) - .child(indicator), - ), + div() + .h(px(PROGRESS_HEIGHT)) + .w_full() + .rounded(RADIUS_KNOB) + .bg(rgb(CANVAS)) + .child(indicator), ) }) .on_click(cx.listener(move |this, _, _, cx| { @@ -313,18 +286,18 @@ pub(crate) fn render_transcription_picker<T: TranscriptionPickerDelegate>( .flex() .items_center() .justify_center() - .bg(rgba(0x000000bb)) + .bg(rgba(SCRIM)) .on_click(cx.listener(|this, _, _, cx| { this.dismiss_transcription_picker(cx); })) .child( div() .id("transcription-picker") - .w(px(780.0)) - .h(px(520.0)) + .w(px(MODAL_WIDTH)) + .h(px(MODAL_HEIGHT)) .flex() .flex_col() - .rounded(px(PANEL_RADIUS + 2.0)) + .rounded(RADIUS_MODAL) .border_1() .border_color(rgb(LINE)) .bg(rgb(CANVAS)) @@ -332,27 +305,18 @@ pub(crate) fn render_transcription_picker<T: TranscriptionPickerDelegate>( .on_click(|_, _, cx| cx.stop_propagation()) .child( div() - .px_5() - .py_3p5() + .pl_5() + .pr_3() + .py_3() .flex() .items_center() .justify_between() .border_b_1() .border_color(rgb(LINE)) + .child(div().text_headline().child("Local transcription")) .child( - div() - .text_size(px(15.0)) - .font_weight(FontWeight::SEMIBOLD) - .child("Local transcription"), - ) - .child( - compact_button("×") + icon_button(ButtonKind::Ghost, Icon::Close) .id("close-transcription-picker") - .size(px(28.0)) - .px_0() - .justify_center() - .text_size(px(15.0)) - .text_color(rgb(MUTED)) .on_click(cx.listener(|this, _, _, cx| { this.dismiss_transcription_picker(cx); })), @@ -366,9 +330,12 @@ pub(crate) fn render_transcription_picker<T: TranscriptionPickerDelegate>( .child( div() .id("transcription-language-list") - .w(px(220.0)) + .w(px(SIDEBAR_WIDTH)) .h_full() - .p_4() + .p_3() + .flex() + .flex_col() + .gap_0p5() .overflow_y_scroll() .bg(rgb(SIDEBAR)) .border_r_1() @@ -382,34 +349,15 @@ pub(crate) fn render_transcription_picker<T: TranscriptionPickerDelegate>( .min_w_0() .h_full() .p_4() + .flex() + .flex_col() + .gap_2() .overflow_y_scroll() .when_some(view.error, |list, error| { - list.child( - div() - .mb_3() - .rounded(px(PANEL_RADIUS)) - .border_1() - .border_color(rgb(DANGER_BORDER)) - .bg(rgb(DANGER_SURFACE)) - .child(error_message( - "Model could not be installed.", - error, - )), - ) + list.child(error_panel("Model could not be installed.", error)) }) .when_some(view.deletion_error, |list, error| { - list.child( - div() - .mb_3() - .rounded(px(PANEL_RADIUS)) - .border_1() - .border_color(rgb(DANGER_BORDER)) - .bg(rgb(DANGER_SURFACE)) - .child(error_message( - "Model could not be deleted.", - error, - )), - ) + list.child(error_panel("Model could not be deleted.", error)) }) .children(model_cards), ), @@ -418,36 +366,23 @@ pub(crate) fn render_transcription_picker<T: TranscriptionPickerDelegate>( .into_any_element() } -fn card_button(label: impl Into<gpui::SharedString>) -> gpui::Div { - crate::desktop_ui::canvas_button() - .h(px(26.0)) - .px(px(10.0)) - .child(label.into()) -} - -fn model_metric(label: impl IntoElement, value: impl IntoElement) -> gpui::Div { +/// One inline statistic: a tertiary label beside its value. +fn model_metric(label: &'static str, value: impl IntoElement) -> gpui::Div { div() - .flex_1() - .h(px(32.0)) - .px_3() .flex() - .items_center() - .justify_between() - .rounded_sm() - .border_1() - .border_color(rgb(LINE)) - .bg(rgb(CANVAS)) + .items_baseline() + .gap_1p5() .child( div() - .text_size(px(10.0)) - .text_color(rgb(FAINT)) + .text_caption() + .text_color(rgb(TEXT_TERTIARY)) .child(label), ) .child( div() - .text_size(px(12.0)) - .font_weight(FontWeight::SEMIBOLD) - .text_color(rgb(TEXT)) + .text_footnote() + .font_weight(FontWeight::MEDIUM) + .text_color(rgb(TEXT_SECONDARY)) .child(value), ) } diff --git a/src/desktop_ui.rs b/src/desktop_ui.rs index 0f124923..193805a0 100644 --- a/src/desktop_ui.rs +++ b/src/desktop_ui.rs @@ -1,6 +1,10 @@ +//! Shared desktop components. Every pane builds from these and the tokens in +//! [`crate::design_tokens`]; see `docs/design-system.md` for which component +//! does which job. + use gpui::{ - AnyElement, Div, ElementId, FontWeight, IntoElement, Rgba, Stateful, div, prelude::*, px, rgb, - rgba, + AnyElement, App, Div, ElementId, FontWeight, IntoElement, Pixels, Rgba, ShapedLine, + SharedString, Stateful, Window, canvas, div, prelude::*, px, rgb, }; #[cfg(target_os = "linux")] @@ -8,6 +12,8 @@ use gpui::{Image, ImageFormat, img}; #[cfg(target_os = "linux")] use std::sync::Arc; +use crate::design_tokens::*; + #[derive(Clone, Copy)] pub(crate) enum NavigationIcon { Activity, @@ -21,14 +27,15 @@ pub(crate) enum NavigationIcon { } impl NavigationIcon { + /// Line-weight symbols only, so no sidebar item reads heavier than another. #[cfg(target_os = "macos")] fn sf_symbol(self) -> &'static str { match self { Self::Settings => "slider.horizontal.3", - Self::Modes => "square.grid.2x2.fill", + Self::Modes => "square.grid.2x2", Self::VoiceAction => "sparkle", Self::Commands => "command", - Self::History => "clock.fill", + Self::History => "clock", Self::Meetings => "calendar", Self::Activity => "chart.bar", Self::HudLab => "flask", @@ -53,98 +60,128 @@ impl NavigationIcon { #[cfg(target_os = "macos")] fn navigation_icon(icon: NavigationIcon, selected: bool) -> AnyElement { gpui_symbols::Icon::new(icon.sf_symbol()) - .size(px(14.0)) - .color(rgb(if selected { TEXT } else { MUTED })) + .size(px(ICON_SIZE)) + .color(rgb(if selected { + TEXT_PRIMARY + } else { + TEXT_TERTIARY + })) .weight(gpui_symbols::SymbolWeight::Medium) .rendering_mode(gpui_symbols::RenderingMode::Monochrome) .into_any_element() } -#[cfg(target_os = "macos")] -fn disclosure_chevron() -> AnyElement { - gpui_symbols::Icon::new("chevron.down") - .size(px(10.0)) - .color(rgb(MUTED)) - .weight(gpui_symbols::SymbolWeight::Medium) - .rendering_mode(gpui_symbols::RenderingMode::Monochrome) +#[cfg(target_os = "linux")] +fn navigation_icon(icon: NavigationIcon, selected: bool) -> AnyElement { + let color = format!( + "#{:06x}", + if selected { + TEXT_PRIMARY + } else { + TEXT_TERTIARY + } + ); + let image = Image::from_bytes( + ImageFormat::Svg, + icon.heroicon().replace("currentColor", &color).into_bytes(), + ); + img(Arc::new(image)) + .size(px(ICON_SIZE + 2.0)) .into_any_element() } -#[cfg(target_os = "macos")] -fn plus_icon() -> AnyElement { - gpui_symbols::Icon::new("plus") - .size(px(11.0)) - .color(rgb(TEXT_SOFT)) - .weight(gpui_symbols::SymbolWeight::Semibold) - .rendering_mode(gpui_symbols::RenderingMode::Monochrome) - .into_any_element() +/// The one inline icon set. On macOS every icon is a line-weight SF Symbol at +/// medium weight; Linux, without SF Symbols, falls back to a matching glyph. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum Icon { + ArrowRight, + ChevronDown, + Close, + DragHandle, + Ellipsis, + Globe, + Plus, } -#[cfg(target_os = "linux")] -fn disclosure_chevron() -> AnyElement { +impl Icon { + /// The ellipsis's three dots read lighter than the other glyphs at the + /// same box, so it is drawn one size up to match them optically. + fn optical_size(self) -> f32 { + match self { + Self::Ellipsis => ICON_SIZE, + _ => ICON_SIZE_SMALL, + } + } + + #[cfg(target_os = "macos")] + fn sf_symbol(self) -> &'static str { + match self { + Self::ArrowRight => "arrow.right", + Self::ChevronDown => "chevron.down", + Self::Close => "xmark", + Self::DragHandle => "line.3.horizontal", + Self::Ellipsis => "ellipsis", + Self::Globe => "globe", + Self::Plus => "plus", + } + } + + #[cfg(target_os = "linux")] + fn glyph(self) -> &'static str { + match self { + Self::ArrowRight => "→", + Self::ChevronDown => "⌄", + Self::Close => "×", + Self::DragHandle => "≡", + Self::Ellipsis => "…", + Self::Globe => "◎", + Self::Plus => "+", + } + } +} + +/// An inline icon at [`ICON_SIZE_SMALL`] (optically matched) in `color`. +#[cfg(target_os = "macos")] +pub(crate) fn icon(icon: Icon, color: u32) -> AnyElement { + let size = icon.optical_size(); div() - .size(px(10.0)) + .size(px(size)) + .flex_none() .flex() .items_center() .justify_center() - .text_size(px(10.0)) - .text_color(rgb(MUTED)) - .child("⌄") + .child( + gpui_symbols::Icon::new(icon.sf_symbol()) + .size(px(size)) + .color(rgb(color)) + .weight(gpui_symbols::SymbolWeight::Medium) + .rendering_mode(gpui_symbols::RenderingMode::Monochrome), + ) .into_any_element() } +/// An inline icon at [`ICON_SIZE_SMALL`] (optically matched) in `color`. #[cfg(target_os = "linux")] -fn plus_icon() -> AnyElement { +pub(crate) fn icon(icon: Icon, color: u32) -> AnyElement { + let size = icon.optical_size(); div() - .size(px(11.0)) + .size(px(size)) + .flex_none() .flex() .items_center() .justify_center() - .text_size(px(11.0)) - .text_color(rgb(TEXT_SOFT)) - .child("+") + .text_step(TypeStep { + size, + line_height: size, + weight: None, + }) + .text_color(rgb(color)) + .child(icon.glyph()) .into_any_element() } -#[cfg(target_os = "linux")] -fn navigation_icon(icon: NavigationIcon, selected: bool) -> AnyElement { - let color = if selected { "#eeeeee" } else { "#858585" }; - let image = Image::from_bytes( - ImageFormat::Svg, - icon.heroicon().replace("currentColor", color).into_bytes(), - ); - img(Arc::new(image)).size(px(16.0)).into_any_element() -} - -pub(crate) const SIDEBAR_WIDTH: f32 = 220.0; - -pub(crate) const SIDEBAR: u32 = 0x111111; -pub(crate) const CANVAS: u32 = 0x161616; -pub(crate) const SURFACE: u32 = 0x1d1d1d; -pub(crate) const SURFACE_HOVER: u32 = 0x252525; -pub(crate) const SURFACE_SELECTED: u32 = 0x2e2e2e; -const SEGMENTED_ITEM_HOVER: u32 = 0x222222; -pub(crate) const LINE: u32 = 0x2a2a2a; -pub(crate) const ACCENT: u32 = 0x3b5cf6; -pub(crate) const ACCENT_SOFT: u32 = 0x8390ff; -pub(crate) const ACCENT_SURFACE: u32 = 0x1c2030; -pub(crate) const POSITIVE: u32 = 0x69d89f; -pub(crate) const POSITIVE_SURFACE: u32 = 0x17231a; -pub(crate) const TEXT: u32 = 0xf2f2f2; -pub(crate) const TEXT_SOFT: u32 = 0xd0d0d0; -pub(crate) const MUTED: u32 = 0x969696; -pub(crate) const FAINT: u32 = 0x6e6e6e; -pub(crate) const NEGATIVE: u32 = 0xf17471; -pub(crate) const DANGER_SURFACE: u32 = 0x2b1718; -pub(crate) const DANGER_BORDER: u32 = 0x66292c; - -pub(crate) const CONTROL_HEIGHT: f32 = 32.0; -pub(crate) const TEXT_INPUT_HEIGHT: f32 = 34.0; -pub(crate) const MULTILINE_INPUT_HEIGHT: f32 = 76.0; -pub(crate) const PANEL_RADIUS: f32 = 10.0; -pub(crate) const COMPACT_PANEL_HEADER_HEIGHT: f32 = 38.0; -pub(crate) const SECTION_GAP: f32 = 8.0; - +/// The window root: canvas background and the body type step every unsized +/// descendant inherits. pub(crate) fn window_frame() -> Div { div() .size_full() @@ -152,12 +189,20 @@ pub(crate) fn window_frame() -> Div { .overflow_hidden() .flex() .bg(rgb(CANVAS)) - .text_color(rgb(TEXT)) + .text_body() + .text_color(rgb(TEXT_PRIMARY)) } +/// The navigation column: [`SIDEBAR_WIDTH`] wide, clear of the title bar. pub(crate) fn sidebar_frame() -> Div { div() + .w(px(SIDEBAR_WIDTH)) .h_full() + .px_3() + .pt(px(TITLEBAR_INSET)) + .pb_4() + .flex() + .flex_col() .flex_none() .bg(rgb(SIDEBAR)) .border_r_1() @@ -166,26 +211,30 @@ pub(crate) fn sidebar_frame() -> Div { pub(crate) fn navigation_item(icon: NavigationIcon, selected: bool) -> Div { div() - .h(px(34.0)) + .h(px(NAV_ITEM_HEIGHT)) .px_3() .flex() .items_center() - .gap_2p5() - .rounded(px(6.0)) - .text_size(px(13.0)) + .gap_2() + .rounded(RADIUS_CONTROL) + .text_body() .font_weight(if selected { FontWeight::MEDIUM } else { FontWeight::NORMAL }) - .text_color(if selected { rgb(TEXT) } else { rgb(MUTED) }) + .text_color(rgb(if selected { + TEXT_PRIMARY + } else { + TEXT_TERTIARY + })) .when(selected, |item| item.bg(rgb(SURFACE_SELECTED))) .when(!selected, |item| { - item.hover(|item| item.bg(rgb(0x1b1b1b)).text_color(rgb(TEXT_SOFT))) + item.hover(|item| item.bg(rgb(SIDEBAR_HOVER)).text_color(rgb(TEXT_SECONDARY))) }) .child( div() - .size(px(18.0)) + .size(px(ICON_SIZE + 4.0)) .flex_none() .flex() .items_center() @@ -194,36 +243,143 @@ pub(crate) fn navigation_item(icon: NavigationIcon, selected: bool) -> Div { ) } -/// The one content width every pane is bounded to. Headers and bodies share -/// it; panes must not introduce their own content widths. -pub(crate) const PANE_CONTENT_WIDTH: f32 = 940.0; +pub(crate) fn pane_header(title: &'static str) -> AnyElement { + pane_header_with_action(title, None) +} -/// The one fixed list-column width every list+detail pane uses. -pub(crate) const PANE_LIST_WIDTH: f32 = 320.0; +/// The one pane header: title left, optional action right, inset by +/// [`PANE_GUTTER`] and bounded to [`PANE_CONTENT_WIDTH`] like the body below. +pub(crate) fn pane_header_with_action( + title: &'static str, + action: Option<AnyElement>, +) -> AnyElement { + div() + .h(px(PANE_HEADER_HEIGHT)) + .px(px(PANE_GUTTER)) + .flex_none() + .flex() + .justify_center() + .child( + div() + .h_full() + .w_full() + .max_w(px(PANE_CONTENT_WIDTH)) + .flex() + .items_center() + .justify_between() + .gap_4() + .border_b_1() + .border_color(rgb(LINE)) + .child(div().flex_none().text_title().child(title)) + .when_some(action, |header, action| { + header.child(div().flex().items_center().gap_2().child(action)) + }), + ) + .into_any_element() +} + +/// The one fixed pane body under the header, inset by [`PANE_GUTTER`] so +/// its cards align with the header rule. Put the column in [`pane_content`]. +pub(crate) fn pane_body() -> Div { + div() + .flex_1() + .min_h(px(0.0)) + .overflow_hidden() + .flex() + .justify_center() + .px(px(PANE_GUTTER)) + .pt_5() + .pb_6() +} + +/// The scrolling pane body: the scrollbar sits at the window edge while the +/// content keeps the [`PANE_GUTTER`] inset and [`PANE_CONTENT_WIDTH`]. +pub(crate) fn pane_scroll_body(id: impl Into<ElementId>, content: Div) -> Stateful<Div> { + div() + .id(id) + .flex_1() + .min_h(px(0.0)) + .overflow_y_scroll() + .px(px(PANE_GUTTER)) + .pb_6() + .child( + div() + .w_full() + .flex() + .justify_center() + .child(content.w_full().max_w(px(PANE_CONTENT_WIDTH)).min_w_0()), + ) +} + +/// The one pane content column, bounded to [`PANE_CONTENT_WIDTH`]. +pub(crate) fn pane_content() -> Div { + div() + .w_full() + .max_w(px(PANE_CONTENT_WIDTH)) + .min_h(px(0.0)) + .flex() + .flex_col() +} -/// The one list column of a list+detail pane: fixed to [`PANE_LIST_WIDTH`], -/// scrolling, with the pane's empty notice and load error ahead of its rows. -/// Callers decide when `empty` applies and append their rows. +/// The list column of a list+detail pane: a scrolling card `width` wide +/// ([`PANE_LIST_WIDTH`], or [`PANE_LIST_WIDTH_COMPACT`] beside an editor) with +/// its empty notice and load error ahead of its rows. Callers decide when +/// `empty` applies. pub(crate) fn pane_list( id: impl Into<ElementId>, + width: f32, empty: Option<&'static str>, error_title: &'static str, error: Option<String>, + rows: Vec<AnyElement>, ) -> Stateful<Div> { - div() + panel() .id(id) - .w(px(PANE_LIST_WIDTH)) + .w(px(width)) .h_full() .flex_none() .overflow_y_scroll() - .when_some(empty, |list, message| list.child(empty_message(message))) + .when_some(empty, |list, message| { + list.flex().flex_col().child(detail_placeholder(message)) + }) .when_some(error, |list, error| { list.child(error_message(error_title, error)) }) + .when(!rows.is_empty(), |list| { + // A scrolling card gives its content no definite width, which + // truncated rows need, so the column states its own. + list.child( + div() + .w(px(width - 2.0)) + .p_2() + .flex() + .flex_col() + .gap_1() + .children(rows), + ) + }) } -const LIST_ROW_SELECTED: u32 = 0x292929; -const LIST_ROW_SELECTED_HOVER: u32 = 0x303030; +/// The detail card beside a [`pane_list`], filling the remaining width. +pub(crate) fn pane_detail(content: impl IntoElement) -> Div { + panel() + .flex_1() + .min_w(px(0.0)) + .h_full() + .flex() + .flex_col() + .child(content) +} + +/// A [`section_label`] heading a group of rows in a [`pane_list`], inset to +/// the row text. The first group sits closer to the top of the card. +pub(crate) fn list_section_label(label: impl IntoElement, first: bool) -> Div { + section_label(label) + .px_3() + .when(first, |label| label.pt_1()) + .when(!first, |label| label.pt_3()) + .pb_1() +} /// A rounded, selectable row inside a list column. pub(crate) fn list_row(selected: bool) -> Div { @@ -234,96 +390,252 @@ pub(crate) fn list_row(selected: bool) -> Div { .flex() .flex_col() .gap_1() - .rounded(px(6.0)) - .when(selected, |row| { - row.bg(rgb(LIST_ROW_SELECTED)) - .hover(|row| row.bg(rgb(LIST_ROW_SELECTED_HOVER))) - }) + .rounded(RADIUS_CONTROL) + .when(selected, |row| row.bg(rgb(SURFACE_SELECTED))) .when(!selected, |row| row.hover(|row| row.bg(rgb(SURFACE_HOVER)))) } -pub(crate) fn pane_header(title: &'static str) -> AnyElement { - pane_header_with_action(title, None) +/// The title line of a [`list_row`]. +pub(crate) fn list_row_title(text: impl Into<SharedString>, selected: bool) -> Div { + truncated_line(BODY, text) + .font_weight(FontWeight::MEDIUM) + .text_color(rgb(if selected { + TEXT_PRIMARY + } else { + TEXT_SECONDARY + })) } -/// The one pane header: title left, optional action right, bounded to -/// [`PANE_CONTENT_WIDTH`] like the body below it. -pub(crate) fn pane_header_with_action( - title: &'static str, - action: Option<AnyElement>, -) -> AnyElement { +/// The metadata line of a [`list_row`]. +pub(crate) fn list_row_meta(text: impl Into<SharedString>, selected: bool) -> Div { + truncated_line(CAPTION, text).text_color(rgb(if selected { + TEXT_SECONDARY + } else { + TEXT_TERTIARY + })) +} + +/// One line that ends in an ellipsis when it overflows. Add `flex_1` in rows. +/// +/// The line is shaped while painting, against the width its box finally +/// receives. GPUI's own truncation measures text during layout, where +/// ancestors sizing themselves to their content can hand it a provisional +/// width that then sticks. +pub(crate) fn truncated_line(step: TypeStep, text: impl Into<SharedString>) -> Div { + let text = text.into(); div() - .h(px(70.0)) - .px_8() - .flex_none() - .flex() - .justify_center() + .w_full() + .min_w_0() + .h(px(step.line_height)) + .flex_shrink() + .text_step(step) .child( - div() - .h_full() - .w_full() - .max_w(px(PANE_CONTENT_WIDTH)) - .flex() - .items_center() - .justify_between() - .border_b_1() - .border_color(rgb(LINE)) - .child( - div() - .text_size(px(20.0)) - .font_weight(FontWeight::SEMIBOLD) - .child(title), - ) - .when_some(action, |header, action| header.child(action)), + canvas( + move |bounds, window, cx| fit_line(text, bounds.size.width, window, cx), + |bounds, line, window, cx| { + let _ = line.paint(bounds.origin, bounds.size.height, window, cx); + }, + ) + .size_full(), ) - .into_any_element() } -/// The one pane body: fills the space under the header and centers its -/// children. Put the pane's content column inside [`pane_content`]. -pub(crate) fn pane_body() -> Div { +/// Shapes `text` in the inherited text style, cut to `width` with "…". +fn fit_line(text: SharedString, width: Pixels, window: &mut Window, cx: &mut App) -> ShapedLine { + let style = window.text_style(); + let font_size = style.font_size.to_pixels(window.rem_size()); + let mut runs = vec![style.to_run(text.len())]; + let text = cx + .text_system() + .line_wrapper(style.font(), font_size) + .truncate_line(text, width, "…", &mut runs); + window + .text_system() + .shape_line(text, font_size, &runs, None) +} + +/// A card: the elevated surface every grouped control and list sits on. +pub(crate) fn panel() -> Div { div() - .flex_1() - .min_h(px(0.0)) + .w_full() + .relative() + .rounded(RADIUS_PANEL) + .border_1() + .border_color(rgb(LINE)) + .bg(rgb(SURFACE)) .overflow_hidden() - .flex() - .justify_center() } -/// The one pane-header action button: a bordered 30-point chip. Every -/// clickable header action renders this. -pub(crate) fn header_button(label: impl IntoElement) -> Div { +/// A card's title bar, with an optional trailing control. +pub(crate) fn panel_header(title: impl IntoElement, action: Option<AnyElement>) -> Div { div() - .h(px(30.0)) - .px_3() + .h(px(PANEL_HEADER_HEIGHT)) + .px_4() + .flex_none() .flex() .items_center() - .rounded_sm() - .border_1() + .justify_between() + .border_b_1() .border_color(rgb(LINE)) - .bg(rgb(SURFACE)) - .text_size(px(12.0)) - .text_color(rgb(TEXT_SOFT)) - .hover(|button| button.bg(rgb(SURFACE_HOVER)).text_color(rgb(TEXT))) + .child( + div() + .text_body() + .font_weight(FontWeight::SEMIBOLD) + .child(title), + ) + .when_some(action, |header, action| header.child(action)) +} + +/// A small label above a group: caption, semibold, sentence case. +pub(crate) fn section_label(label: impl IntoElement) -> Div { + div() + .text_caption() + .font_weight(FontWeight::SEMIBOLD) + .text_color(rgb(TEXT_TERTIARY)) .child(label) } -/// The one pane content column, bounded to [`PANE_CONTENT_WIDTH`]. -pub(crate) fn pane_content() -> Div { +/// A [`section_label`] spaced to head a settings card, inset to the card's +/// row text so both share one left edge. +pub(crate) fn settings_section_label(label: &'static str) -> AnyElement { + section_label(label).pt_6().pb_2().px_4().into_any_element() +} + +/// The fixed top of a detail card: a [`detail_title`], an optional metadata +/// line, and an optional action row, ruled off edge to edge from the +/// [`detail_body`] below. +pub(crate) fn detail_header( + title: impl IntoElement, + meta: Vec<AnyElement>, + actions: Vec<AnyElement>, +) -> Div { div() - .w_full() - .max_w(px(PANE_CONTENT_WIDTH)) + .flex_none() + .px_6() + .pt_5() + .pb_4() + .flex() + .flex_col() + .gap_2() + .border_b_1() + .border_color(rgb(LINE)) + .child(title) + .when(!meta.is_empty(), |header| { + header.child( + div() + .flex() + .items_center() + .gap_2() + .text_footnote() + .text_color(rgb(TEXT_TERTIARY)) + .children(meta), + ) + }) + .when(!actions.is_empty(), |header| { + header.child( + div() + .debug_selector(|| "detail-actions".into()) + .pt_2() + .flex() + .flex_wrap() + .items_center() + .gap_2() + .children(actions), + ) + }) +} + +/// The title of a [`detail_header`]. +pub(crate) fn detail_title(text: impl Into<SharedString>) -> Div { + div().text_headline().child(text.into()) +} + +/// The scrolling content under a [`detail_header`]. It stays a block: a flex +/// column here measures `detail_row` values at their full length. +pub(crate) fn detail_body(id: impl Into<ElementId>) -> Stateful<Div> { + div() + .id(id) + .flex_1() .min_h(px(0.0)) + .overflow_y_scroll() + .px_6() + .pt_5() + .pb_6() +} + +pub(crate) fn settings_copy(title: &'static str, description: impl IntoElement) -> AnyElement { + div() + .debug_selector(|| "settings-copy".into()) .flex() .flex_col() + .gap_0p5() + .child( + div() + .text_body() + .font_weight(FontWeight::MEDIUM) + .child(title), + ) + .child( + div() + .text_footnote() + .text_color(rgb(TEXT_TERTIARY)) + .child(description), + ) + .into_any_element() } -pub(crate) fn section_label(label: &'static str) -> AnyElement { +pub(crate) fn settings_row( + title: &'static str, + description: impl IntoElement, + control: impl IntoElement, +) -> Div { div() - .text_size(px(11.0)) - .font_weight(FontWeight::SEMIBOLD) - .text_color(rgb(FAINT)) - .child(label) + .w_full() + .min_h(px(SETTINGS_ROW_MIN_HEIGHT)) + .px_4() + .py_2() + .flex() + .items_center() + .justify_between() + .gap_4() + .border_b_1() + .border_color(rgb(LINE)) + .child( + div() + .flex_1() + .min_w_0() + .child(settings_copy(title, description)), + ) + .child(control) +} + +/// A label and value inside a detail card. +pub(crate) fn detail_row(label: &'static str, value: impl Into<String>) -> AnyElement { + div() + .py_3() + .flex() + .items_start() + .gap_4() + .border_b_1() + .border_color(rgb(LINE)) + .child( + div() + .w(px(92.0)) + .flex_none() + .pt(px(baseline_offset(BODY, FOOTNOTE))) + .text_footnote() + .text_color(rgb(TEXT_TERTIARY)) + .child(label), + ) + .child( + div() + .debug_selector(|| "detail-row-value".into()) + .flex_1() + .min_w_0() + .text_body() + .text_color(rgb(TEXT_SECONDARY)) + .child(value.into()), + ) .into_any_element() } @@ -336,13 +648,7 @@ pub(crate) fn listener_status( .flex() .items_center() .gap_2() - .child( - div() - .size(px(8.0)) - .flex_none() - .rounded_full() - .bg(if active { rgb(POSITIVE) } else { rgb(FAINT) }), - ) + .child(status_dot(if active { SUCCESS } else { TEXT_DISABLED })) .child( div() .min_w(px(0.0)) @@ -350,19 +656,28 @@ pub(crate) fn listener_status( .flex_col() .child( div() - .text_size(px(12.0)) - .text_color(rgb(TEXT_SOFT)) + .text_footnote() + .text_color(rgb(TEXT_SECONDARY)) .child(status), ) .child( div() - .text_size(px(10.0)) - .text_color(rgb(FAINT)) + .text_caption() + .text_color(rgb(TEXT_TERTIARY)) .child(device), ), ) } +/// A small filled circle that marks live or recorded state. +pub(crate) fn status_dot(color: u32) -> Div { + div() + .size(px(6.0)) + .flex_none() + .rounded_full() + .bg(rgb(color)) +} + pub(crate) fn hotkey_keycaps(parts: Vec<String>, opacity: f32) -> AnyElement { div() .flex() @@ -372,29 +687,28 @@ pub(crate) fn hotkey_keycaps(parts: Vec<String>, opacity: f32) -> AnyElement { .children(parts.into_iter().map(|part| { let (side, label) = split_sided_keycap(&part); div() - .min_w(px(34.0)) - .h(px(24.0)) + .min_w(px(KEYCAP_WIDTH)) + .h(px(CONTROL_HEIGHT_SMALL)) .px_2() .relative() .flex() .items_center() .justify_center() - .rounded(px(4.0)) + .rounded(RADIUS_SMALL) .border_1() - .border_color(rgb(0x3f3f3f)) - .bg(rgb(0x282828)) - .text_size(px(12.0)) + .border_color(rgb(LINE_STRONG)) + .bg(rgb(SURFACE_HOVER)) + .text_footnote() .font_weight(FontWeight::MEDIUM) - .text_color(rgb(TEXT)) + .text_color(rgb(TEXT_PRIMARY)) .when_some(side, |keycap, side| { keycap.child( div() .absolute() .top(px(2.0)) .left(px(4.0)) - .text_size(px(7.0)) - .font_weight(FontWeight::SEMIBOLD) - .text_color(rgb(FAINT)) + .text_step(KEYCAP_SIDE_BADGE) + .text_color(rgb(TEXT_TERTIARY)) .child(side), ) }) @@ -419,196 +733,256 @@ fn split_sided_keycap(part: &str) -> (Option<&'static str>, String) { pub(crate) fn toggle(position: f32) -> AnyElement { let color_position = position.clamp(0.0, 1.0); + let knob = TOGGLE_HEIGHT - 4.0; div() - .w(px(24.0)) - .h(px(16.0)) + .w(px(TOGGLE_WIDTH)) + .h(px(TOGGLE_HEIGHT)) .p(px(2.0)) .flex_none() .flex() .items_center() - .rounded(px(4.0)) - .bg(mix_color(rgb(0x3a3a3a), rgb(ACCENT), color_position)) + .rounded(RADIUS_SMALL) + .bg(mix_color(rgb(LINE_STRONG), rgb(ACCENT), color_position)) .child( div() - .ml(px(8.0 * position.clamp(-0.04, 1.04))) - .size(px(12.0)) - .rounded(px(2.0)) - .bg(mix_color(rgb(0xc8c8c8), rgb(0xfafafa), color_position)), + .ml(px( + (TOGGLE_WIDTH - TOGGLE_HEIGHT) * position.clamp(-0.04, 1.04) + )) + .size(px(knob)) + .rounded(RADIUS_KNOB) + .bg(mix_color( + rgb(TEXT_SECONDARY), + rgb(TEXT_PRIMARY), + color_position, + )), ) .into_any_element() } -pub(crate) fn settings_section_label(label: &'static str) -> AnyElement { - div() - .pt_6() - .pb_2() - .px_1() - .text_size(px(11.0)) - .font_weight(FontWeight::SEMIBOLD) - .text_color(rgb(MUTED)) - .child(label) - .into_any_element() +/// The four button roles. Text buttons are always bordered or filled; only +/// icon buttons and menu items are borderless. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ButtonKind { + /// The one main action in a view: accent fill. + Primary, + /// Every other action: recessed canvas fill with a line border. + Secondary, + /// Borderless; for [`icon_button`]s inside cards and rows only. + Ghost, + /// Destructive or armed confirmation: danger tint. + Danger, } -pub(crate) fn settings_copy(title: &'static str, description: impl IntoElement) -> AnyElement { - div() - .debug_selector(|| "settings-copy".into()) - .flex() - .flex_col() - .gap_1() - .child( - div() - .text_size(px(13.0)) - .font_weight(FontWeight::SEMIBOLD) - .child(title), - ) - .child( - div() - .text_size(px(11.0)) - .text_color(rgb(MUTED)) - .child(description), - ) - .into_any_element() +/// A [`CONTROL_HEIGHT`] button. +pub(crate) fn button(kind: ButtonKind, label: impl IntoElement) -> Div { + button_frame(kind) + .h(px(CONTROL_HEIGHT)) + .px_3() + .text_footnote() + .child(label) } -pub(crate) fn compact_button(label: impl IntoElement) -> Div { - div() - .h(px(30.0)) - .px_3() - .flex() - .items_center() - .rounded_sm() - .text_size(px(12.0)) - .text_color(rgb(TEXT_SOFT)) - .hover(|button| button.bg(rgb(SURFACE_HOVER))) +/// A [`CONTROL_HEIGHT_SMALL`] button for actions inside list rows and cards. +pub(crate) fn small_button(kind: ButtonKind, label: impl IntoElement) -> Div { + button_frame(kind) + .h(px(CONTROL_HEIGHT_SMALL)) + .px_2() + .text_caption() .child(label) } -pub(crate) fn compact_plus_button() -> Div { - compact_button(plus_icon()) - .size(px(28.0)) - .px_0() +/// A square [`CONTROL_HEIGHT`] button holding one [`Icon`]. Use +/// [`ButtonKind::Ghost`] inside cards and rows, [`ButtonKind::Secondary`] +/// where the icon stands alone, such as a pane header. +pub(crate) fn icon_button(kind: ButtonKind, glyph: Icon) -> Div { + let color = match kind { + ButtonKind::Ghost => TEXT_TERTIARY, + ButtonKind::Secondary => TEXT_SECONDARY, + ButtonKind::Primary => TEXT_PRIMARY, + ButtonKind::Danger => DANGER, + }; + button_frame(kind) + .size(px(CONTROL_HEIGHT)) .justify_center() + .child(icon(glyph, color)) } -pub(crate) fn compact_header_plus_button() -> Div { - compact_plus_button().mr(px(-7.0)) +fn button_frame(kind: ButtonKind) -> Div { + let frame = div() + .flex() + .flex_none() + .items_center() + .gap_2() + .rounded(RADIUS_CONTROL) + .font_weight(FontWeight::MEDIUM) + .cursor_pointer(); + match kind { + ButtonKind::Primary => frame + .bg(rgb(ACCENT)) + .text_color(rgb(TEXT_PRIMARY)) + .hover(|button| button.bg(mix_color(rgb(ACCENT), rgb(TEXT_PRIMARY), 0.12))), + ButtonKind::Secondary => frame + .border_1() + .border_color(rgb(LINE)) + .bg(rgb(CANVAS)) + .text_color(rgb(TEXT_SECONDARY)) + .hover(|button| button.bg(rgb(SURFACE_HOVER)).text_color(rgb(TEXT_PRIMARY))), + ButtonKind::Ghost => frame + .text_color(rgb(TEXT_SECONDARY)) + .hover(|button| button.bg(rgb(SURFACE_HOVER)).text_color(rgb(TEXT_PRIMARY))), + ButtonKind::Danger => frame + .border_1() + .border_color(rgb(DANGER_BORDER)) + .bg(rgb(DANGER_SURFACE)) + .text_color(rgb(DANGER)) + .hover(|button| button.border_color(rgb(DANGER))), + } } -pub(crate) fn compact_panel() -> Div { +/// Text standing where a button will return: an in-progress action. +pub(crate) fn busy_label(label: impl IntoElement) -> Div { div() - .w_full() - .rounded(px(PANEL_RADIUS)) - .border_1() - .border_color(rgb(LINE)) - .bg(rgb(SURFACE)) - .overflow_hidden() + .h(px(CONTROL_HEIGHT)) + .px_3() + .flex() + .flex_none() + .items_center() + .text_footnote() + .text_color(rgb(TEXT_TERTIARY)) + .child(label) +} + +/// A dropdown trigger: a secondary control with its value and a chevron. +pub(crate) fn disclosure_button(label: impl Into<SharedString>) -> Div { + disclosure_frame() + .w(px(DISCLOSURE_WIDTH)) + .child(truncated_line(FOOTNOTE, label).flex_1()) + .child(icon(Icon::ChevronDown, TEXT_TERTIARY)) } -pub(crate) fn compact_panel_header(title: impl IntoElement, action: Option<AnyElement>) -> Div { +/// The frame of a dropdown trigger for custom content; end it with an +/// [`Icon::ChevronDown`]. +pub(crate) fn disclosure_frame() -> Div { div() - .h(px(COMPACT_PANEL_HEADER_HEIGHT)) + .h(px(CONTROL_HEIGHT)) .px_3() - .flex_none() .flex() + .flex_none() .items_center() - .justify_between() - .border_b_1() + .gap_2() + .rounded(RADIUS_CONTROL) + .border_1() .border_color(rgb(LINE)) - .child( - div() - .text_size(px(12.0)) - .font_weight(FontWeight::SEMIBOLD) - .text_color(rgb(TEXT)) - .child(title), - ) - .when_some(action, |header, action| header.child(action)) + .bg(rgb(CANVAS)) + .text_footnote() + .text_color(rgb(TEXT_SECONDARY)) + .cursor_pointer() + .hover(|button| button.bg(rgb(SURFACE_HOVER)).text_color(rgb(TEXT_PRIMARY))) +} + +/// The tone of a [`tag`]. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum Tone { + Neutral, + Accent, + Success, + Danger, } -pub(crate) fn compact_section_label(label: impl IntoElement) -> Div { +/// A non-interactive status label: tinted fill, no border, never clickable. +pub(crate) fn tag(tone: Tone, label: impl IntoElement) -> Div { + let (text, fill) = match tone { + Tone::Neutral => (TEXT_SECONDARY, SURFACE_SELECTED), + Tone::Accent => (ACCENT_TEXT, ACCENT_SURFACE), + Tone::Success => (SUCCESS, SUCCESS_SURFACE), + Tone::Danger => (DANGER, DANGER_SURFACE), + }; div() - .px_1() - .text_size(px(10.0)) - .font_weight(FontWeight::SEMIBOLD) - .text_color(rgb(FAINT)) + .h(px(TAG_HEIGHT)) + .px(px(6.0)) + .flex() + .flex_none() + .items_center() + .gap_1() + .rounded(RADIUS_SMALL) + .bg(rgb(fill)) + .text_caption() + .font_weight(FontWeight::MEDIUM) + .text_color(rgb(text)) .child(label) } -pub(crate) const SOUND_VOLUME_STEPS: [(&str, f32); 5] = [ - ("Off", 0.0), - ("25%", 0.25), - ("50%", 0.5), - ("75%", 0.75), - ("100%", 1.0), -]; - -pub(crate) fn canvas_button() -> Div { +/// A floating menu or popover surface. Callers position it. +pub(crate) fn menu() -> Div { div() + .p_1() .flex() - .items_center() - .rounded(px(6.0)) + .flex_col() + .rounded(RADIUS_PANEL) .border_1() - .border_color(rgb(LINE)) - .bg(rgb(CANVAS)) - .text_size(px(11.0)) - .text_color(rgb(TEXT_SOFT)) - .hover(|button| button.bg(rgb(SURFACE_HOVER)).text_color(rgb(TEXT))) + .border_color(rgb(LINE_STRONG)) + .bg(rgb(SURFACE)) + .shadow_lg() + .occlude() } -pub(crate) fn disclosure_button(label: impl IntoElement) -> Div { - canvas_button() - .w(px(220.0)) - .h(px(CONTROL_HEIGHT)) - .px_3() - .flex_none() - .gap_2() - .child( - div() - .min_w(px(0.0)) - .flex_1() - .overflow_x_hidden() - .truncate() - .child(label), - ) - .child( - div() - .size(px(10.0)) - .flex_none() - .flex() - .items_center() - .justify_center() - .child(disclosure_chevron()), - ) +/// A [`menu`] that drops [`MENU_OFFSET`] below the [`CONTROL_HEIGHT`] control +/// that opens it. Put both in a `relative` wrapper and pin the menu to the +/// control's left or right edge. +pub(crate) fn dropdown_menu() -> Div { + menu().absolute().top(px(CONTROL_HEIGHT + MENU_OFFSET)) } -pub(crate) fn settings_row( - title: &'static str, - description: impl IntoElement, - control: impl IntoElement, -) -> Div { +/// One choice inside a [`menu`]. +pub(crate) fn menu_item(selected: bool) -> Div { + menu_item_frame() + .text_color(rgb(if selected { + TEXT_PRIMARY + } else { + TEXT_SECONDARY + })) + .when(selected, |item| item.bg(rgb(SURFACE_SELECTED))) + .hover(|item| item.bg(rgb(SURFACE_HOVER))) +} + +/// A destructive choice: last in its [`menu`], after a [`menu_separator`]. +pub(crate) fn danger_menu_item() -> Div { + menu_item_frame() + .text_color(rgb(DANGER)) + .hover(|item| item.bg(rgb(DANGER_SURFACE))) +} + +/// A choice that is temporarily unavailable, such as work in progress. +pub(crate) fn disabled_menu_item() -> Div { + menu_item_frame().text_color(rgb(TEXT_TERTIARY)) +} + +/// A rule between groups of [`menu`] choices. +pub(crate) fn menu_separator() -> Div { + div().mx_2().my_1().h(px(1.0)).flex_none().bg(rgb(LINE)) +} + +fn menu_item_frame() -> Div { div() .w_full() - .min_h(px(72.0)) - .px_4() - .py_3() + .h(px(MENU_ITEM_HEIGHT)) + .px_2() .flex() + .flex_none() .items_center() .justify_between() - .gap_4() - .border_b_1() - .border_color(rgb(LINE)) - .child( - div() - .flex_1() - .min_w_0() - .child(settings_copy(title, description)), - ) - .child(control) + .gap_2() + .rounded(RADIUS_CONTROL) + .text_footnote() } -pub(crate) fn settings_panel() -> Div { - compact_panel().relative() -} +pub(crate) const SOUND_VOLUME_STEPS: [(&str, f32); 5] = [ + ("Off", 0.0), + ("25%", 0.25), + ("50%", 0.5), + ("75%", 0.75), + ("100%", 1.0), +]; pub(crate) fn segmented_control() -> Div { div() @@ -617,32 +991,35 @@ pub(crate) fn segmented_control() -> Div { .flex_none() .flex() .items_center() - .rounded(px(6.0)) + .rounded(RADIUS_CONTROL) .border_1() .border_color(rgb(LINE)) .bg(rgb(CANVAS)) } +const SEGMENT_HEIGHT: f32 = CONTROL_HEIGHT - 6.0; + pub(crate) fn segmented_item(selected: bool) -> Div { div() - .h(px(26.0)) + .h(px(SEGMENT_HEIGHT)) .px_3() .flex() .items_center() - .rounded(px(4.0)) - .text_size(px(11.0)) + .rounded(RADIUS_SMALL) + .text_caption() .font_weight(if selected { FontWeight::MEDIUM } else { FontWeight::NORMAL }) - .text_color(if selected { rgb(TEXT) } else { rgb(MUTED) }) + .text_color(rgb(if selected { + TEXT_PRIMARY + } else { + TEXT_TERTIARY + })) .when(selected, |item| item.bg(rgb(SURFACE_SELECTED))) .when(!selected, |item| { - item.hover(|item| { - item.bg(rgb(SEGMENTED_ITEM_HOVER)) - .text_color(rgb(TEXT_SOFT)) - }) + item.hover(|item| item.bg(rgb(SURFACE_HOVER)).text_color(rgb(TEXT_SECONDARY))) }) } @@ -657,10 +1034,10 @@ pub(crate) fn sliding_segmented_control(position: f32, widths: &[f32]) -> Div { .left(px(left)) .top(px(2.0)) .w(px(width)) - .h(px(26.0)) - .rounded(px(4.0)) + .h(px(SEGMENT_HEIGHT)) + .rounded(RADIUS_SMALL) .border_1() - .border_color(rgb(0x3d3d3d)) + .border_color(rgb(LINE_STRONG)) .bg(rgb(SURFACE_SELECTED)), ) } @@ -672,7 +1049,7 @@ pub(crate) fn sliding_segmented_item(width: f32, selected: bool) -> Div { .w(px(width)) .px(px(0.0)) .justify_center() - .bg(rgba(0x00000000)) + .bg(gpui::transparent_black()) } fn segmented_geometry(position: f32, widths: &[f32]) -> (f32, f32) { @@ -698,34 +1075,72 @@ pub(crate) fn mix_color(from: Rgba, to: Rgba, position: f32) -> Rgba { } } -pub(crate) fn empty_message(message: &'static str) -> AnyElement { +/// The one empty state: one centered footnote line. Detail panes centre it +/// vertically with [`detail_placeholder`]. +pub(crate) fn empty_state(message: &'static str) -> AnyElement { div() + .w_full() .p_6() - .text_size(px(12.0)) - .text_color(rgb(FAINT)) + .flex() + .justify_center() + .text_center() + .text_footnote() + .text_color(rgb(TEXT_TERTIARY)) .child(message) .into_any_element() } +/// An [`empty_state`] centred in a detail card. +pub(crate) fn detail_placeholder(message: &'static str) -> AnyElement { + div() + .flex_1() + .size_full() + .flex() + .items_center() + .justify_center() + .child(empty_state(message)) + .into_any_element() +} + pub(crate) fn error_message(message: &'static str, error: String) -> AnyElement { div() - .p_6() + .p_4() .flex() .flex_col() - .gap_2() - .text_size(px(12.0)) - .text_color(rgb(NEGATIVE)) + .gap_1() + .text_footnote() + .text_color(rgb(DANGER)) .child(message) .child( div() - .text_size(px(11.0)) - .line_height(px(17.0)) - .text_color(rgb(MUTED)) + .text_caption() + .text_color(rgb(TEXT_TERTIARY)) .child(error), ) .into_any_element() } +/// An [`error_message`] on a danger-tinted card. +pub(crate) fn error_panel(message: &'static str, error: String) -> Div { + div() + .w_full() + .rounded(RADIUS_CONTROL) + .border_1() + .border_color(rgb(DANGER_BORDER)) + .bg(rgb(DANGER_SURFACE)) + .child(error_message(message, error)) +} + +/// A one-line inline error under a control or row. +pub(crate) fn inline_error(error: impl IntoElement) -> Div { + div() + .px_4() + .py_2() + .text_footnote() + .text_color(rgb(DANGER)) + .child(error) +} + #[cfg(test)] mod tests { use super::{segmented_geometry, split_sided_keycap}; @@ -809,4 +1224,150 @@ mod layout_tests { "narrow copy must wrap: {heights:?}" ); } + + const LONG: &str = "The parser needs a retry when the socket drops during the reconnect"; + + struct Blank; + + impl Render for Blank { + fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement { + div() + } + } + + #[gpui::test] + fn fit_line_cuts_only_overflowing_text_with_an_ellipsis(cx: &mut TestAppContext) { + let (_, cx) = cx.add_window_view(|_, _| Blank); + cx.update(|window, cx| { + let cut = fit_line(LONG.into(), px(120.0), window, cx); + assert!(cut.text.ends_with('…'), "not cut: {:?}", cut.text); + assert!( + cut.width <= px(120.0), + "{:?} is wider than its box", + cut.width + ); + let whole = fit_line("Work notes".into(), px(400.0), window, cx); + assert_eq!(whole.text.as_ref(), "Work notes"); + }); + } + + /// Renders `build` and reports the width of the element it marks `line`. + struct Probe(fn() -> AnyElement); + + impl Render for Probe { + fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement { + div().size_full().child((self.0)()) + } + } + + fn measured_width( + cx: &mut TestAppContext, + selector: &'static str, + build: fn() -> AnyElement, + ) -> f32 { + let (_, cx) = cx.add_window_view(move |_, _| Probe(build)); + cx.simulate_resize(size(px(1000.0), px(400.0))); + cx.run_until_parked(); + f32::from(cx.debug_bounds(selector).unwrap().size.width) + } + + fn line_width(cx: &mut TestAppContext, build: fn() -> AnyElement) -> f32 { + measured_width(cx, "line", build) + } + + fn line(step: TypeStep, text: &'static str) -> Div { + truncated_line(step, text).debug_selector(|| "line".into()) + } + + #[gpui::test] + fn truncated_line_takes_the_row_beside_a_timestamp(cx: &mut TestAppContext) { + // History and Meetings list rows. + let width = line_width(cx, || { + pane_list( + "probe-list", + PANE_LIST_WIDTH, + None, + "", + None, + vec![ + list_row(false) + .flex_row() + .gap_3() + .child( + div() + .flex_1() + .min_w_0() + .flex() + .flex_col() + .child(line(BODY, LONG)) + .child(list_row_meta("Dictation · Zed", false)), + ) + .child(div().flex_none().child("1d ago")) + .into_any_element(), + ], + ) + .into_any_element() + }); + assert!(width > 180.0 && width < PANE_LIST_WIDTH, "{width}"); + } + + #[gpui::test] + fn detail_rows_fill_a_detail_body(cx: &mut TestAppContext) { + let width = measured_width(cx, "detail-row-value", || { + div() + .w(px(600.0)) + .h(px(300.0)) + .flex() + .flex_col() + .child(detail_body("probe").child(detail_row("Latency", LONG))) + .into_any_element() + }); + assert!(width > 400.0, "{width}"); + } + + #[gpui::test] + fn truncated_line_takes_the_row_beside_a_tag(cx: &mut TestAppContext) { + // The meeting detail metadata row. + let width = line_width(cx, || { + div() + .w(px(700.0)) + .px_6() + .flex() + .flex_col() + .child( + div() + .flex() + .items_center() + .gap_2() + .child(tag(Tone::Success, "Ready")) + .child(line(FOOTNOTE, "Today · 8:18 PM · 38 min · Zoom").flex_1()), + ) + .into_any_element() + }); + assert!(width > 500.0, "{width}"); + } + + #[gpui::test] + fn truncated_line_survives_intrinsic_sizing_of_block_ancestors(cx: &mut TestAppContext) { + // A flex item's automatic minimum width sizes its block subtree to + // content first: the transformation rows in Modes. + let width = line_width(cx, || { + div() + .w(px(600.0)) + .flex() + .child( + div().flex_1().child( + panel().child( + div() + .flex() + .items_center() + .child(line(BODY, "Smart punctuation").flex_1()) + .child(icon_button(ButtonKind::Ghost, Icon::Close)), + ), + ), + ) + .into_any_element() + }); + assert!(width > 500.0, "{width}"); + } } diff --git a/src/linux_app.rs b/src/linux_app.rs index 9f7776cb..77877542 100644 --- a/src/linux_app.rs +++ b/src/linux_app.rs @@ -11,6 +11,7 @@ use gpui::{ WindowKind, WindowOptions, div, prelude::*, px, rgb, size, }; +use crate::design_tokens::*; use crate::desktop_activity::DesktopActivity; use crate::desktop_host::{ DesktopAction, DesktopHost, DesktopListenerSnapshot, DesktopShortcut, DesktopSnapshot, @@ -22,10 +23,9 @@ use crate::desktop_transcription_picker::{ render_transcription_picker, transcription_selection_is_active, }; use crate::desktop_ui::{ - LINE, MUTED, NavigationIcon, SIDEBAR_WIDTH, SURFACE, TEXT_SOFT, compact_button, - disclosure_button, hotkey_keycaps, navigation_item, pane_body, pane_content, pane_header, - segmented_control, segmented_item, settings_panel, settings_row, settings_section_label, - sidebar_frame, toggle, window_frame, + ButtonKind, NavigationIcon, button, disclosure_button, hotkey_keycaps, navigation_item, + pane_content, pane_header, pane_scroll_body, panel, segmented_control, segmented_item, + settings_row, settings_section_label, sidebar_frame, status_dot, toggle, window_frame, }; use crate::events::EventReader; use crate::linux_service::{ @@ -332,7 +332,7 @@ pub fn run_service(event_path: PathBuf, shutdown: &'static AtomicBool) -> Result }) => { let result = (|| -> Result<()> { if stopping { - return Err(color_eyre::eyre::eyre!("HEX service is stopping")); + return Err(color_eyre::eyre::eyre!("Hex service is stopping")); } if let Some((owner, deadline)) = &mut editor { if *owner == client { @@ -405,7 +405,7 @@ pub fn open(start_hidden: bool, shutdown: &'static AtomicBool) -> Result<()> { }) }, ) - .expect("could not open the HEX window"); + .expect("could not open the Hex window"); let app = window.update(cx, |_, _, cx| cx.entity()).unwrap(); let hotkey_app = app.clone(); cx.observe_keystrokes(move |event, _, cx| { @@ -457,7 +457,7 @@ pub fn open(start_hidden: bool, shutdown: &'static AtomicBool) -> Result<()> { .detach(); window .update(cx, |_, window, cx| { - window.set_window_title("HEX"); + window.set_window_title("Hex"); let close_app = cx.entity().downgrade(); window.on_window_should_close(cx, move |_, cx| { if close_app @@ -1056,7 +1056,7 @@ impl DesktopHost for LinuxDesktopHost { } DesktopAction::RestartIntoUpdate => { if let Err(error) = self.restart_into_update() { - self.error = Some(format!("Could not restart HEX: {error:#}")); + self.error = Some(format!("Could not restart Hex: {error:#}")); return Err(error); } } @@ -1080,12 +1080,6 @@ impl DesktopHost for LinuxDesktopHost { impl LinuxApp { fn render_shared_navigation(&self) -> AnyElement { sidebar_frame() - .w(px(SIDEBAR_WIDTH)) - .px(px(14.0)) - .pt(px(52.0)) - .pb_4() - .flex() - .flex_col() .child( navigation_item(NavigationIcon::Settings, true) .id("linux-nav-settings") @@ -1114,7 +1108,7 @@ impl LinuxApp { segmented_item(selected) .id(("linux-sound-volume", index)) .child(label) - .when(sound_volume_busy, |item| item.opacity(0.5)) + .when(sound_volume_busy, |item| item.opacity(DISABLED_OPACITY)) .on_click(cx.listener(move |this, _, _, cx| { if !this.quitting && !sound_volume_busy { let _ = this.host.set_sound_effect_volume(volume); @@ -1123,27 +1117,25 @@ impl LinuxApp { })) }), ); - let shortcut = if self.host.capturing_hotkey() { - div() - .w(px(180.0)) - .h(px(34.0)) - .flex() - .items_center() - .justify_center() - .rounded(px(6.0)) - .border_1() - .border_color(rgb(TEXT_SOFT)) - .bg(rgb(SURFACE)) - .text_size(px(11.0)) - .child(if running { - "Stopping listener..." - } else { - "Press a shortcut..." - }) - .into_any_element() - } else { - hotkey_keycaps(snapshot.dictation_shortcut.clone(), 1.0) - }; + let shortcut = + if self.host.capturing_hotkey() { + div() + .h(px(CONTROL_HEIGHT)) + .flex() + .items_center() + .gap_2() + .child(status_dot(DANGER)) + .child(div().text_footnote().text_color(rgb(TEXT_SECONDARY)).child( + if running { + "Stopping listener…" + } else { + "Press a shortcut…" + }, + )) + .into_any_element() + } else { + hotkey_keycaps(snapshot.dictation_shortcut.clone(), 1.0) + }; let update_ready = snapshot.update_status == DesktopUpdateStatus::ReadyToRestart; let transcription = &snapshot.transcription; let transcription_label = format!( @@ -1159,25 +1151,18 @@ impl LinuxApp { .flex_col() .child(pane_header("Settings")) .child( - pane_body().child(div() - .id("settings-scroll") - .flex_1() - .overflow_y_scroll() - .px_8() - .pt_1() - .pb_7() - .child( - div().w_full().flex().justify_center().child( + pane_scroll_body( + "settings-scroll", pane_content() .relative() - .child(settings_section_label("LISTENER")) - .child(settings_panel().child(settings_row( + .child(settings_section_label("Listener")) + .child(panel().child(settings_row( "Dictation", "Global hotkey dictation", div().flex().items_center().gap_3() - .child(div().text_size(px(12.0)).text_color(rgb(MUTED)) + .child(div().text_footnote().text_color(rgb(TEXT_TERTIARY)) .child(snapshot.listener.status.clone())) - .child(compact_button(if running { "Stop" } else { "Start" }) + .child(button(ButtonKind::Secondary, if running { "Stop" } else { "Start" }) .id("linux-listener-toggle") .on_click(cx.listener(move |this, _, _, cx| { if !this.quitting { @@ -1192,25 +1177,25 @@ impl LinuxApp { ))) .when_some(snapshot.listener.warning.clone(), |content, warning| { content.child( - div().id("linux-listener-warning").mt_3().p_3().rounded(px(6.0)).border_1().border_color(rgb(LINE)) - .child(div().text_size(px(12.0)).child(warning)) - .child(div().mt_2().text_size(px(12.0)).text_color(rgb(MUTED)) - .child("Dictation still works. Release the hold shortcut or press the locked shortcut to finish. Free Escape in the other application; HEX retries on the next capture.")), + panel().id("linux-listener-warning").mt_3().p_3() + .child(div().text_footnote().child(warning)) + .child(div().mt_2().text_footnote().text_color(rgb(TEXT_TERTIARY)) + .child("Dictation still works. Release the hold shortcut or press the locked shortcut to finish. Free Escape in the other application; Hex retries on the next capture.")), ) }) .when_some(snapshot.operation_error.clone(), |content, error| { content.child( - div().mt_3().p_3().rounded(px(6.0)).border_1().border_color(rgb(LINE)) - .child(div().text_size(px(12.0)).child(error)) + div().mt_3().p_3().rounded(RADIUS_PANEL).border_1().border_color(rgb(DANGER_BORDER)).bg(rgb(DANGER_SURFACE)) + .child(div().text_footnote().text_color(rgb(DANGER)).child(error)) .child(div().mt_2().flex().gap_2() - .when(!running, |buttons| buttons.child(compact_button("Retry").id("linux-error-retry") + .when(!running, |buttons| buttons.child(button(ButtonKind::Secondary, "Retry").id("linux-error-retry") .on_click(cx.listener(|this, _, _, cx| { if !this.quitting { let _ = this.host.dispatch(DesktopAction::StartListening); cx.notify(); } })))) - .child(compact_button("Dismiss").id("linux-error-dismiss") + .child(button(ButtonKind::Secondary, "Dismiss").id("linux-error-dismiss") .on_click(cx.listener(|this, _, _, cx| { let _ = this.host.dispatch(DesktopAction::ClearError); cx.notify(); @@ -1218,11 +1203,11 @@ impl LinuxApp { ) }) .when_some(self.host.client.state.as_ref().and_then(|s| s.hud_limitation.clone()), |content, limitation| { - content.child(div().mt_3().text_size(px(12.0)).text_color(rgb(MUTED)).child(limitation)) + content.child(div().mt_3().text_footnote().text_color(rgb(TEXT_TERTIARY)).child(limitation)) }) - .child(settings_section_label("DICTATION")) + .child(settings_section_label("Dictation")) .child( - settings_panel() + panel() .child(settings_row( "Local transcription", "Language and on-device speech model", @@ -1265,23 +1250,23 @@ impl LinuxApp { .when(editing, |panel| panel.child(settings_row( "Updating shortcut settings", "Listening resumes after the change if it was previously running.", - compact_button("Cancel").id("linux-cancel-shortcut") + button(ButtonKind::Secondary, "Cancel").id("linux-cancel-shortcut") .on_click(cx.listener(|this, _, _, cx| { this.host.cancel_settings_edit(); cx.notify(); })), ))), ) - .child(settings_section_label("BEHAVIOR")) + .child(settings_section_label("Behavior")) .child( - settings_panel().child( + panel().child( settings_row( "Double-tap to lock", "Double-tap the shortcut for hands-free dictation", toggle(if double_tap_lock { 1.0 } else { 0.0 }), ) .id("double-tap-setting") - .when(editing, |row| row.opacity(0.5)) + .when(editing, |row| row.opacity(DISABLED_OPACITY)) .on_click( cx.listener(move |this, _, _, cx| { if !this.quitting && !editing { @@ -1301,7 +1286,7 @@ impl LinuxApp { toggle(if paste_with_shift { 1.0 } else { 0.0 }), ) .id("terminal-paste-setting") - .when(editing, |row| row.opacity(0.5)) + .when(editing, |row| row.opacity(DISABLED_OPACITY)) .on_click(cx.listener(move |this, _, _, cx| { if !this.quitting && !editing { this.host.set_terminal_paste(!paste_with_shift); @@ -1314,20 +1299,20 @@ impl LinuxApp { sound_volume, ).border_b_0()), ) - .child(settings_section_label("APPLICATION")) + .child(settings_section_label("Application")) .child( - settings_panel() + panel() .child(settings_row( - "HEX", + "Hex", "Private local dictation for Linux", - div().text_size(px(12.0)).text_color(rgb(MUTED)).child( + div().text_footnote().text_color(rgb(TEXT_TERTIARY)).child( format!("Version {}", env!("CARGO_PKG_VERSION")), ), )) .child(settings_row( - "Close Settings", + "Close settings", "Dictation continues in the background service", - compact_button("Close") + button(ButtonKind::Secondary, "Close") .id("linux-quit") .on_click(cx.listener(|this, _, _, cx| { this.request_quit(); @@ -1337,11 +1322,9 @@ impl LinuxApp { .when(update_ready, |panel| { panel.child(settings_row( "Update ready", - "Restart into the verified Linux release.", - compact_button("Restart") + "Restart into the verified Linux release", + button(ButtonKind::Primary, "Restart") .id("restart-update") - .border_1() - .border_color(rgb(LINE)) .on_click(cx.listener(|this, _, _, cx| { let _ = this.host.dispatch(DesktopAction::RestartIntoUpdate); cx.notify(); @@ -1349,8 +1332,7 @@ impl LinuxApp { )) }), ), - ), - )), + ), ) .into_any_element() } diff --git a/src/main.rs b/src/main.rs index f3b45099..3f7a23e6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,6 +24,9 @@ mod context; #[cfg(all(debug_assertions, target_os = "macos"))] mod dashboard; #[cfg(any(target_os = "macos", target_os = "linux"))] +#[cfg_attr(target_os = "linux", allow(dead_code))] +mod design_tokens; +#[cfg(any(target_os = "macos", target_os = "linux"))] mod desktop_activity; #[cfg(target_os = "linux")] mod desktop_host; @@ -236,6 +239,9 @@ enum Command { /// For `meetings`: ask to delete the selected meeting. #[arg(long)] confirm_meeting_delete: bool, + /// For `meetings`: open the selected meeting's overflow menu. + #[arg(long)] + open_meeting_actions: bool, /// For `meetings`: show the pane before any meeting was recorded. #[arg(long)] meetings_empty: bool, @@ -509,6 +515,7 @@ fn main() -> Result<()> { screen_recording_missing, meeting_recording, confirm_meeting_delete, + open_meeting_actions, meetings_empty, meeting_panel, } => { @@ -579,6 +586,7 @@ fn main() -> Result<()> { screen_recording_missing, meeting_recording, confirm_meeting_delete, + open_meeting_actions, meetings_empty, }), ) diff --git a/src/meeting_watcher.rs b/src/meeting_watcher.rs index bd0da56e..83a7e8b9 100644 --- a/src/meeting_watcher.rs +++ b/src/meeting_watcher.rs @@ -10,13 +10,15 @@ use std::time::{Duration, Instant}; use color_eyre::eyre::{Result, eyre}; use gpui::{ App, Application, Bounds, ClipboardItem, Context, IntoElement, KeyBinding, Menu, MenuItem, - Render, Rgba, SystemMenuType, Timer, Window, WindowBackgroundAppearance, WindowBounds, - WindowHandle, WindowKind, WindowOptions, div, point, prelude::*, px, rgb, size, + Render, SystemMenuType, Timer, Window, WindowBackgroundAppearance, WindowBounds, WindowHandle, + WindowKind, WindowOptions, div, point, prelude::*, px, rgb, size, }; use crate::app_settings::AppSettings; use crate::app_window::AppWindow; use crate::context::ContextSnapshot; +use crate::design_tokens::*; +use crate::desktop_ui::{ButtonKind, button, status_dot, truncated_line}; use crate::dictation_indicator::DictationIndicatorEvent; use crate::dictation_indicator::{self, DictationIndicatorUi}; use crate::meeting::{self, MeetingRequest}; @@ -458,7 +460,7 @@ pub fn run(shutdown: &'static AtomicBool, launch: Launch) -> Result<()> { ), }; if let Err(error) = result { - tracing::error!(%error, "could not open HEX"); + tracing::error!(%error, "could not open Hex"); } }); let application_runtime_workers = runtime_workers.clone(); @@ -508,13 +510,13 @@ pub fn run(shutdown: &'static AtomicBool, launch: Launch) -> Result<()> { MenuItem::action("Settings…", crate::app_window::ShowSettings), MenuItem::os_submenu("Services", SystemMenuType::Services), MenuItem::separator(), - MenuItem::action("Hide HEX", crate::app_window::HideApplication), + MenuItem::action("Hide Hex", crate::app_window::HideApplication), MenuItem::separator(), - MenuItem::action("Quit HEX", crate::app_window::QuitApplication), + MenuItem::action("Quit Hex", crate::app_window::QuitApplication), ]); cx.set_menus(vec![ Menu { - name: "HEX".into(), + name: "Hex".into(), items: application_items, }, Menu { @@ -539,7 +541,7 @@ pub fn run(shutdown: &'static AtomicBool, launch: Launch) -> Result<()> { match crate::status_item::install() { Ok(actions) => Some(actions), Err(error) => { - tracing::error!(%error, "could not install HEX status item"); + tracing::error!(%error, "could not install Hex status item"); None } } @@ -582,7 +584,7 @@ pub fn run(shutdown: &'static AtomicBool, launch: Launch) -> Result<()> { None => None, }; if let Some(Err(error)) = open_result { - tracing::error!(%error, "could not open HEX"); + tracing::error!(%error, "could not open Hex"); } let quit_shutdown = shutdown; let quit_workers = application_runtime_workers.clone(); @@ -692,7 +694,7 @@ async fn drive_ui( }); } Err(error) => { - tracing::error!(%error, "could not open HEX from status item"); + tracing::error!(%error, "could not open Hex from status item"); } }, crate::status_item::StatusItemAction::SelectModel(model) => { @@ -741,7 +743,7 @@ async fn drive_ui( } }); if let Err(error) = result { - tracing::error!(%error, "could not handle HEX status item action"); + tracing::error!(%error, "could not handle Hex status item action"); return; } } @@ -860,7 +862,7 @@ async fn drive_ui( meeting_state = MeetingMenuState::Idle; ui.show_meeting_window( offer_window.take(), - "could not open HEX for meeting failure", + "could not open Hex for meeting failure", cx, |window, cx| window.meeting_failed(title, error, cx), ) @@ -1224,12 +1226,10 @@ fn open_panel( timeout: Option<Duration>, cx: &mut App, ) -> gpui::Result<WindowHandle<MeetingPanel>> { - let deadline = timeout.map(|timeout| (Instant::now(), timeout)); let handle = cx.open_window(panel_options(cx), |_, cx| { cx.new(|_| MeetingPanel { kind, commands, - deadline, copied: false, }) })?; @@ -1270,18 +1270,11 @@ enum PanelKind { struct MeetingPanel { kind: PanelKind, commands: SyncSender<ControllerCommand>, - deadline: Option<(Instant, Duration)>, copied: bool, } impl Render for MeetingPanel { - fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { - let remaining = self.deadline.map(|(opened, timeout)| { - 1.0 - (opened.elapsed().as_secs_f32() / timeout.as_secs_f32()).clamp(0.0, 1.0) - }); - if remaining.is_some() { - window.request_animation_frame(); - } + fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { let (dot, title, subtitle, actions) = match &self.kind { PanelKind::Offer(candidate) => { let dismiss = self.commands.clone(); @@ -1293,52 +1286,60 @@ impl Render for MeetingPanel { } else { format!("{} call detected", candidate.source.label()) }; - let seconds = self - .deadline - .map(|(opened, timeout)| timeout.saturating_sub(opened.elapsed()).as_secs() + 1) - .unwrap_or_default(); ( - rgb(0xd4d4d4), + TEXT_SECONDARY, title, - format!("Record locally? Tell participants · {seconds}s"), + "Record locally? Tell participants.".to_string(), div() .flex() .gap_2() - .child(secondary_button("not-now", "Not Now", move |window, _| { - let _ = dismiss.try_send(ControllerCommand::Dismiss); - window.remove_window(); - })) - .child(primary_button("record", "Record", move |window, _| { - let command = if is_preview { - ControllerCommand::Dismiss - } else { - ControllerCommand::Record(candidate.clone()) - }; - if record.try_send(command).is_ok() { + .child(capsule_button( + ButtonKind::Secondary, + "not-now", + "Not now", + move |window, _| { + let _ = dismiss.try_send(ControllerCommand::Dismiss); window.remove_window(); - } - })), + }, + )) + .child(capsule_button( + ButtonKind::Primary, + "record", + "Record", + move |window, _| { + let command = if is_preview { + ControllerCommand::Dismiss + } else { + ControllerCommand::Record(candidate.clone()) + }; + if record.try_send(command).is_ok() { + window.remove_window(); + } + }, + )), ) } PanelKind::CallEnded { app } => { let stop = self.commands.clone(); ( - rgb(0xff5f57), + DANGER, format!("{app} left the call"), - "HEX is still recording.".into(), + "Hex is still recording.".into(), div() .flex() .gap_2() - .child(secondary_button( + .child(capsule_button( + ButtonKind::Secondary, "keep-recording", - "Keep Recording", + "Keep recording", |window, _| { window.remove_window(); }, )) - .child(primary_button( + .child(capsule_button( + ButtonKind::Primary, "stop-meeting", - "Stop & Transcribe", + "Stop & transcribe", move |window, _| { if stop.try_send(ControllerCommand::Stop).is_ok() { window.remove_window(); @@ -1376,7 +1377,7 @@ impl Render for MeetingPanel { format!("Ready · {title}") }; ( - rgb(0x5ac46a), + SUCCESS, title, subtitle, div() @@ -1384,7 +1385,8 @@ impl Render for MeetingPanel { .gap_2() .when_some(copy_path, |actions, path| { let label = if self.copied { "Copied" } else { "Copy" }; - actions.child(secondary_button( + actions.child(capsule_button( + ButtonKind::Secondary, "copy-transcript", label, move |_, cx| match std::fs::read_to_string(&path) { @@ -1402,7 +1404,8 @@ impl Render for MeetingPanel { )) }) .when_some(reveal_path, |actions, path| { - actions.child(primary_button( + actions.child(capsule_button( + ButtonKind::Primary, "reveal-transcript", "Reveal", move |window, _| { @@ -1414,22 +1417,21 @@ impl Render for MeetingPanel { ) } }; - div().size_full().p(px(4.0)).child( + div().size_full().p_1().child( div() - .relative() .size_full() .flex() .items_center() .gap_3() .pl_4() .pr_2() - .bg(rgb(0x161616)) + .bg(rgb(CANVAS)) .border_1() - .border_color(rgb(0x303030)) + .border_color(rgb(LINE_STRONG)) .rounded_full() .shadow_lg() .overflow_hidden() - .child(div().flex_none().size(px(8.0)).rounded_full().bg(dot)) + .child(status_dot(dot).size_2()) .child( div() .flex_1() @@ -1437,77 +1439,27 @@ impl Render for MeetingPanel { .flex() .flex_col() .child( - div() - .text_size(px(13.0)) + truncated_line(BODY, title) .font_weight(gpui::FontWeight::SEMIBOLD) - .text_color(rgb(0xf1f1f1)) - .overflow_x_hidden() - .truncate() - .child(title), + .text_color(rgb(TEXT_PRIMARY)), ) - .child( - div() - .text_size(px(11.0)) - .text_color(rgb(0x8f8f8f)) - .overflow_x_hidden() - .truncate() - .child(subtitle), - ), + .child(truncated_line(CAPTION, subtitle).text_color(rgb(TEXT_TERTIARY))), ) - .child(actions.flex_none()) - .when_some(remaining, |panel, remaining| { - panel.child( - div() - .absolute() - .bottom_0() - .left_0() - .h(px(2.0)) - .w(px((PANEL_WIDTH - 8.0) * remaining)) - .bg(rgb(0x5a5a5a)), - ) - }), + .child(actions.flex_none()), ) } } -fn primary_button( - id: &'static str, - label: &'static str, - on_click: impl Fn(&mut Window, &mut App) + 'static, -) -> impl IntoElement { - button(id, label, rgb(0xe8e8e8), rgb(0x111111), on_click) -} - -fn secondary_button( - id: &'static str, - label: &'static str, - on_click: impl Fn(&mut Window, &mut App) + 'static, -) -> impl IntoElement { - button(id, label, rgb(0x262626), rgb(0xbdbdbd), on_click) -} - -fn button( +/// Capsule buttons are the shared buttons in the capsule's pill shape. +fn capsule_button( + kind: ButtonKind, id: &'static str, label: &'static str, - background: Rgba, - foreground: Rgba, on_click: impl Fn(&mut Window, &mut App) + 'static, ) -> impl IntoElement { - div() + button(kind, label) .id(id) - .h(px(32.0)) - .px_3() - .flex() - .items_center() - .justify_center() .rounded_full() - .bg(background) - .text_size(px(12.0)) - .font_weight(gpui::FontWeight::SEMIBOLD) - .text_color(foreground) - .hover(|style| style.opacity(0.86)) - .active(|style| style.opacity(0.8)) - .child(label) .on_click(move |_, window, cx| on_click(window, cx)) } diff --git a/src/status_item.rs b/src/status_item.rs index 08cdbfb0..59c97a4c 100644 --- a/src/status_item.rs +++ b/src/status_item.rs @@ -132,7 +132,7 @@ pub fn install() -> Result<Receiver<StatusItemAction>> { MainThreadMarker::new().ok_or_else(|| eyre!("status item requires the main thread"))?; let (actions, receiver) = sync_channel(8); let target = StatusItemTarget::new(actions, mtm); - let menu = NSMenu::initWithTitle(NSMenu::alloc(mtm), &NSString::from_str("HEX")); + let menu = NSMenu::initWithTitle(NSMenu::alloc(mtm), &NSString::from_str("Hex")); let meeting_status = add_item( &menu, &target, @@ -240,7 +240,7 @@ pub fn install() -> Result<Receiver<StatusItemAction>> { ); } menu.addItem(&NSMenuItem::separatorItem(mtm)); - add_item(&menu, &target, "Quit HEX", sel!(quit:), mtm); + add_item(&menu, &target, "Quit Hex", sel!(quit:), mtm); let item = NSStatusBar::systemStatusBar().statusItemWithLength(-2.0); let button = item @@ -248,15 +248,15 @@ pub fn install() -> Result<Receiver<StatusItemAction>> { .ok_or_else(|| eyre!("status item button is unavailable"))?; let symbol = NSImage::imageWithSystemSymbolName_accessibilityDescription( &NSString::from_str("hexagon"), - Some(&NSString::from_str("HEX")), + Some(&NSString::from_str("Hex")), ) - .ok_or_else(|| eyre!("HEX status symbol is unavailable"))?; + .ok_or_else(|| eyre!("Hex status symbol is unavailable"))?; symbol.setTemplate(true); button.setImage(Some(&symbol)); item.setMenu(Some(&menu)); let recording_symbol = NSImage::imageWithSystemSymbolName_accessibilityDescription( &NSString::from_str("record.circle"), - Some(&NSString::from_str("HEX is recording a meeting")), + Some(&NSString::from_str("Hex is recording a meeting")), ); if let Some(recording_symbol) = &recording_symbol { recording_symbol.setTemplate(true); diff --git a/src/text_input.rs b/src/text_input.rs index 9354b0b2..ed7d94d9 100644 --- a/src/text_input.rs +++ b/src/text_input.rs @@ -9,12 +9,12 @@ use gpui::{ }; use unicode_segmentation::UnicodeSegmentation; -use crate::desktop_ui::{ - ACCENT, CANVAS, LINE, MULTILINE_INPUT_HEIGHT, MUTED, TEXT, TEXT_INPUT_HEIGHT, +use crate::design_tokens::{ + ACCENT, BODY, CANVAS, CONTROL_HEIGHT, LINE, MULTILINE_INPUT_HEIGHT, RADIUS_CONTROL, SELECTION, + TEXT_PRIMARY, TEXT_TERTIARY, Typography, }; const FOCUS: u32 = ACCENT; -const SELECTION: u32 = 0x3b5cf655; actions!( text_input, @@ -1060,7 +1060,7 @@ impl Element for TextElement { let selected_range = input.selected_range.clone(); let style = window.text_style(); let (display_text, color) = if content.is_empty() { - (input.placeholder.clone(), rgb(MUTED).into()) + (input.placeholder.clone(), rgb(TEXT_TERTIARY).into()) } else { (content, style.color) }; @@ -1131,7 +1131,7 @@ impl Element for TextElement { point(bounds.left() + caret.x, bounds.top() + caret.y - scroll_y), size(px(1.), layout.line_height), ), - rgb(TEXT), + rgb(TEXT_PRIMARY), ) }); let selections = if selected_range.is_empty() { @@ -1173,7 +1173,7 @@ impl Element for TextElement { ), size(px(1.), bounds.bottom() - bounds.top()), ), - rgb(TEXT), + rgb(TEXT_PRIMARY), )), ) } else { @@ -1373,18 +1373,17 @@ impl Render for TextInput { .h(px(if self.multiline { MULTILINE_INPUT_HEIGHT } else { - TEXT_INPUT_HEIGHT + CONTROL_HEIGHT })) - .px(px(10.)) - .py(px(7.)) + .px_2p5() + .py(px((CONTROL_HEIGHT - 2.0 - BODY.line_height) / 2.0)) .overflow_hidden() - .rounded_sm() + .rounded(RADIUS_CONTROL) .border_1() .border_color(rgb(border)) .bg(rgb(CANVAS)) - .text_color(rgb(TEXT)) - .text_size(px(14.)) - .line_height(px(20.)) + .text_color(rgb(TEXT_PRIMARY)) + .text_body() .child(TextElement { input: cx.entity() }) } }