From 9afd80b11165ae61dc75a0b4a37cf432e07ef6e9 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 17 Oct 2026 23:16:02 -0700 Subject: [PATCH 01/14] feat(ui): add design tokens and one shared component set --- src/design_tokens.rs | 294 ++++++++++++ src/desktop_ui.rs | 1008 ++++++++++++++++++++++++++++-------------- src/main.rs | 3 + 3 files changed, 966 insertions(+), 339 deletions(-) create mode 100644 src/design_tokens.rs diff --git a/src/design_tokens.rs b/src/design_tokens.rs new file mode 100644 index 00000000..cda331d3 --- /dev/null +++ b/src/design_tokens.rs @@ -0,0 +1,294 @@ +//! 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, +} + +/// 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"; + +/// 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 Typography for E {} + +// Spacing: a 4 px grid. Use GPUI's spacing utilities on these steps only: +// `*_1` 4, `*_2` 8, `*_3` 12, `*_4` 16, `*_5` 20, `*_6` 24, `*_8` 32. +pub(crate) const SPACE_1: f32 = 4.0; +pub(crate) const SPACE_2: f32 = 8.0; +pub(crate) const SPACE_3: f32 = 12.0; +pub(crate) const SPACE_5: f32 = 20.0; +pub(crate) const SPACE_8: f32 = 32.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 TAG_HEIGHT: f32 = 18.0; +pub(crate) const MENU_ITEM_HEIGHT: f32 = 28.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 = SPACE_8; +/// 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; +pub(crate) const SECTION_GAP: f32 = SPACE_2; + +#[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); + } + } + + /// Raw styling values belong here. A pane that writes a literal colour, + /// font size, line height, or radius 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()", + ]; + 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_ui.rs b/src/desktop_ui.rs index 0f124923..ef6931a1 100644 --- a/src/desktop_ui.rs +++ b/src/desktop_ui.rs @@ -1,6 +1,9 @@ +//! 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, }; #[cfg(target_os = "linux")] @@ -8,6 +11,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 +26,15 @@ pub(crate) enum NavigationIcon { } impl NavigationIcon { + /// Outline 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::VoiceAction => "sparkle", + Self::Modes => "square.grid.2x2", + Self::VoiceAction => "wand.and.stars", Self::Commands => "command", - Self::History => "clock.fill", + Self::History => "clock", Self::Meetings => "calendar", Self::Activity => "chart.bar", Self::HudLab => "flask", @@ -53,98 +59,117 @@ 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 an outline 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, + Globe, + Plus, } -#[cfg(target_os = "linux")] -fn disclosure_chevron() -> AnyElement { +impl Icon { + #[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::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::Globe => "◎", + Self::Plus => "+", + } + } +} + +/// An inline icon at [`ICON_SIZE_SMALL`] in `color`. +pub(crate) fn icon(icon: Icon, color: u32) -> AnyElement { + sized_icon(icon, ICON_SIZE_SMALL, color) +} + +#[cfg(target_os = "macos")] +pub(crate) fn sized_icon(icon: Icon, size: f32, color: u32) -> AnyElement { 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() } #[cfg(target_os = "linux")] -fn plus_icon() -> AnyElement { +pub(crate) fn sized_icon(icon: Icon, size: f32, color: u32) -> AnyElement { 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 +177,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 +199,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 +231,127 @@ 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 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 { + 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, content: Div) -> Stateful
{ + 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 fixed list-column width every list+detail pane uses. -pub(crate) const PANE_LIST_WIDTH: f32 = 320.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 [`PANE_LIST_WIDTH`] scrolling +/// card with its empty notice and load error ahead of its rows. Callers +/// decide when `empty` applies. pub(crate) fn pane_list( id: impl Into, empty: Option<&'static str>, error_title: &'static str, error: Option, + rows: Vec, ) -> Stateful
{ - div() + panel() .id(id) .w(px(PANE_LIST_WIDTH)) .h_full() .flex_none() .overflow_y_scroll() - .when_some(empty, |list, message| list.child(empty_message(message))) + .when_some(empty, |list, message| list.child(empty_state(message))) .when_some(error, |list, error| { list.child(error_message(error_title, error)) }) + .when(!rows.is_empty(), |list| { + list.child( + div() + .w(px(PANE_LIST_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 rounded, selectable row inside a list column. pub(crate) fn list_row(selected: bool) -> Div { @@ -234,96 +362,175 @@ 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 IntoElement, 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 { +/// The metadata line of a [`list_row`]. +pub(crate) fn list_row_meta(text: impl IntoElement, 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. +/// +/// GPUI truncates only text first measured against a definite width, so the +/// line is laid out absolutely inside a one-line box that takes the +/// available width and contributes none of its own. Add `flex_1` in rows. +pub(crate) fn truncated_line(step: TypeStep, text: impl IntoElement) -> Div { div() - .h(px(70.0)) - .px_8() - .flex_none() - .flex() - .justify_center() + .relative() + .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)), + .absolute() + .top_0() + .left_0() + .right_0() + .truncate() + .child(text), ) - .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 { +/// 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() + .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) -> 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. +pub(crate) fn settings_section_label(label: &'static str) -> AnyElement { + section_label(label).pt_6().pb_2().px_1().into_any_element() +} + +pub(crate) fn settings_copy(title: &'static str, description: impl IntoElement) -> AnyElement { div() - .w_full() - .max_w(px(PANE_CONTENT_WIDTH)) - .min_h(px(0.0)) + .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) +} + +pub(crate) fn settings_panel() -> Div { + panel().relative() +} + +/// A label and value inside a detail card. +pub(crate) fn detail_row(label: &'static str, value: impl Into) -> 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_footnote() + .text_color(rgb(TEXT_TERTIARY)) + .child(label), + ) + .child( + div() + .flex_1() + .min_w_0() + .text_body() + .text_color(rgb(TEXT_SECONDARY)) + .child(value.into()), + ) .into_any_element() } @@ -336,13 +543,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 +551,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, opacity: f32) -> AnyElement { div() .flex() @@ -372,29 +582,28 @@ pub(crate) fn hotkey_keycaps(parts: Vec, 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), ) }) @@ -403,6 +612,9 @@ pub(crate) fn hotkey_keycaps(parts: Vec, opacity: f32) -> AnyElement { .into_any_element() } +/// The minimum width of one keycap in [`hotkey_keycaps`]. +pub(crate) const KEYCAP_WIDTH: f32 = 34.0; + fn split_sided_keycap(part: &str) -> (Option<&'static str>, String) { match part { "L⌃" => (Some("L"), "⌃".into()), @@ -419,196 +631,228 @@ 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() -} - -pub(crate) fn compact_button(label: impl IntoElement) -> Div { - div() - .h(px(30.0)) +/// A [`CONTROL_HEIGHT`] button. +pub(crate) fn button(kind: ButtonKind, label: impl IntoElement) -> Div { + button_frame(kind) + .h(px(CONTROL_HEIGHT)) .px_3() - .flex() - .items_center() - .rounded_sm() - .text_size(px(12.0)) - .text_color(rgb(TEXT_SOFT)) - .hover(|button| button.bg(rgb(SURFACE_HOVER))) + .text_footnote() .child(label) } -pub(crate) fn compact_plus_button() -> Div { - compact_button(plus_icon()) - .size(px(28.0)) - .px_0() - .justify_center() +/// 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_header_plus_button() -> Div { - compact_plus_button().mr(px(-7.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_panel() -> Div { - div() - .w_full() - .rounded(px(PANEL_RADIUS)) - .border_1() - .border_color(rgb(LINE)) - .bg(rgb(SURFACE)) - .overflow_hidden() +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_header(title: impl IntoElement, action: Option) -> Div { +/// Text standing where a button will return: an in-progress action. +pub(crate) fn busy_label(label: impl IntoElement) -> 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() - .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)) -} - -pub(crate) fn compact_section_label(label: impl IntoElement) -> Div { - div() - .px_1() - .text_size(px(10.0)) - .font_weight(FontWeight::SEMIBOLD) - .text_color(rgb(FAINT)) + .text_footnote() + .text_color(rgb(TEXT_TERTIARY)) .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), -]; +/// A dropdown trigger: a secondary control with its value and a chevron. +pub(crate) fn disclosure_button(label: impl IntoElement) -> Div { + disclosure_frame() + .w(px(DISCLOSURE_WIDTH)) + .child(truncated_line(FOOTNOTE, label).flex_1()) + .child(icon(Icon::ChevronDown, TEXT_TERTIARY)) +} -pub(crate) fn canvas_button() -> 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(CONTROL_HEIGHT)) + .px_3() .flex() + .flex_none() .items_center() - .rounded(px(6.0)) + .gap_2() + .rounded(RADIUS_CONTROL) .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))) + .text_footnote() + .text_color(rgb(TEXT_SECONDARY)) + .cursor_pointer() + .hover(|button| button.bg(rgb(SURFACE_HOVER)).text_color(rgb(TEXT_PRIMARY))) } -pub(crate) fn disclosure_button(label: impl IntoElement) -> Div { - canvas_button() - .w(px(220.0)) - .h(px(CONTROL_HEIGHT)) - .px_3() +/// The tone of a [`tag`]. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum Tone { + Neutral, + Accent, + Success, + Danger, +} + +/// 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() + .h(px(TAG_HEIGHT)) + .px(px(6.0)) + .flex() .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()), - ) + .items_center() + .gap_1() + .rounded(RADIUS_SMALL) + .bg(rgb(fill)) + .text_caption() + .font_weight(FontWeight::MEDIUM) + .text_color(rgb(text)) + .child(label) } -pub(crate) fn settings_row( - title: &'static str, - description: impl IntoElement, - control: impl IntoElement, -) -> Div { +/// A floating menu or popover surface. Callers position it. +pub(crate) fn menu() -> Div { + div() + .p_1() + .flex() + .flex_col() + .rounded(RADIUS_PANEL) + .border_1() + .border_color(rgb(LINE_STRONG)) + .bg(rgb(SURFACE)) + .shadow_lg() + .occlude() +} + +/// One choice inside a [`menu`]. +pub(crate) fn menu_item(selected: bool) -> 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() + .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))) } -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 +861,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 +904,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 +919,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 +945,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}; @@ -753,7 +1038,8 @@ mod tests { #[cfg(all(test, target_os = "macos"))] mod layout_tests { use super::*; - use gpui::{Context, Render, TestAppContext, Window, size}; + use gpui::{Context, Render, StyledText, TestAppContext, TextLayout, Window, size}; + use std::{cell::RefCell, rc::Rc}; struct SettingsRow; @@ -809,4 +1095,48 @@ mod layout_tests { "narrow copy must wrap: {heights:?}" ); } + + const LONG: &str = "The parser needs a retry when the socket drops during the reconnect"; + + /// A list-like row: truncating title beside a fixed timestamp, inside a + /// fixed-width column, as History and Meetings render it. + struct TruncatedRow { + layout: Rc>>, + } + + impl Render for TruncatedRow { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let text = StyledText::new(LONG); + *self.layout.borrow_mut() = Some(text.layout().clone()); + div().w(px(PANE_LIST_WIDTH)).flex().flex_col().child( + list_row(false) + .flex_row() + .gap_3() + .child( + div() + .flex_1() + .min_w_0() + .flex() + .flex_col() + .child(truncated_line(BODY, text)) + .child(list_row_meta("Dictation · Zed", false)), + ) + .child(div().flex_none().child("1d ago")), + ) + } + } + + #[gpui::test] + fn truncated_line_ends_in_an_ellipsis_inside_flex_rows(cx: &mut TestAppContext) { + let layout = Rc::new(RefCell::new(None)); + let (_, cx) = cx.add_window_view({ + let layout = layout.clone(); + |_, _| TruncatedRow { layout } + }); + cx.simulate_resize(size(px(600.0), px(200.0))); + cx.run_until_parked(); + let text = layout.borrow().as_ref().unwrap().text(); + assert!(text.ends_with('…'), "not truncated: {text:?}"); + assert!(text.len() < LONG.len()); + } } diff --git a/src/main.rs b/src/main.rs index f3b45099..9dcc3d37 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; From 1886d139f67b5cfa42a84edc3fcdd9162b2dfe99 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 17 Oct 2026 23:16:02 -0700 Subject: [PATCH 02/14] style(ui): adopt the design system across the Settings window panes --- src/app_window.rs | 1985 +++++++++++++++------------------------------ 1 file changed, 652 insertions(+), 1333 deletions(-) diff --git a/src/app_window.rs b/src/app_window.rs index de0b8874..a0952900 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,14 @@ 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, detail_placeholder, detail_row, + disclosure_button, disclosure_frame, empty_state, error_message, error_panel, hotkey_keycaps, + icon, icon_button, inline_error, list_row, list_row_meta, list_row_title, listener_status, + menu, menu_item, 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_panel, 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}; @@ -83,21 +82,8 @@ fn sidebar_update_button( preview: bool, ) -> Option> { (status == crate::sparkle::UpdateStatus::UpdateAvailable).then(|| { - div() + button(ButtonKind::Primary, "Update") .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))) - .child("Update") .on_click(move |_, window, cx| { if !preview { window.dispatch_action(Box::new(CheckForUpdates), cx); @@ -118,46 +104,24 @@ fn microphone_picker_menu( dismiss: impl Fn(&MouseDownEvent, &mut Window, &mut App) + 'static, ) -> gpui::Stateful
{ let choose = Rc::new(choose); - div() + 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); @@ -166,10 +130,10 @@ fn microphone_picker_menu( .when_some(error, |picker, error| { picker.child( div() - .px_3() - .pt_2() - .text_size(px(11.0)) - .text_color(rgb(NEGATIVE)) + .px_2() + .py_1() + .text_caption() + .text_color(rgb(DANGER)) .child(error), ) }) @@ -185,25 +149,12 @@ fn settings_pane_titled(title: &'static str, scroll_id: &'static str, content: D .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 +329,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() }), @@ -697,16 +648,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 +718,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 +749,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", @@ -2642,41 +2594,38 @@ impl AppWindow { fn render_history(&mut self, cx: &mut Context) -> 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() + menu() .id("history-retention-menu") .absolute() - .top(px(36.0)) + .top(px(CONTROL_HEIGHT + 4.0)) .right_0() .w(px(160.0)) - .p_1() - .rounded_md() - .border_1() - .border_color(rgb(LINE)) - .bg(rgb(SURFACE)) - .occlude() .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 +2637,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 +2676,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,11 +2695,11 @@ 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", if retention_off { Some("History is off. New dictations are not retained.") @@ -2786,15 +2714,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 +2731,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", @@ -2843,16 +2759,11 @@ impl AppWindow { .flex() .items_center() .gap_3() + .child(div().text_headline().child(entry.kind.label())) .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)) + .text_caption() + .text_color(rgb(TEXT_TERTIARY)) .child(event_age(entry.timestamp_ms)), ), ) @@ -2861,7 +2772,7 @@ impl AppWindow { .pt_4() .flex() .items_center() - .gap_3() + .gap_2() .child( action_button(if copied { "Copied" } else { "Copy" }, "copy") .on_click(cx.listener(move |this, _, _, cx| { @@ -2881,10 +2792,9 @@ impl AppWindow { .child(section_label("Final text")) .child( div() - .pt_3() - .text_size(px(12.0)) - .line_height(px(19.0)) - .text_color(rgb(TEXT)) + .pt_2() + .text_body() + .text_color(rgb(TEXT_PRIMARY)) .child(entry.final_text.clone()), ), ) @@ -2898,10 +2808,9 @@ impl AppWindow { .child(section_label("Raw transcript")) .child( div() - .pt_3() - .text_size(px(12.0)) - .line_height(px(19.0)) - .text_color(rgb(MUTED)) + .pt_2() + .text_body() + .text_color(rgb(TEXT_TERTIARY)) .child(entry.raw_text.clone()), ), ) @@ -2949,13 +2858,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 +2867,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 +2917,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 +2959,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 +2974,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 +2997,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 +3009,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 +3024,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( @@ -3213,8 +3099,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 +3295,7 @@ impl AppWindow { div() .id(("text-replacement", index)) .w_full() - .px_3() + .px_4() .py_2() .flex() .items_center() @@ -3424,13 +3309,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 +3317,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 +3331,8 @@ impl AppWindow { }) .collect::>(); - 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 +3347,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,18 +3484,22 @@ impl AppWindow { let warning_count = warnings.len(); Some( div() - .child(settings_section_label("PERMISSIONS NEEDED")) + .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| { + 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()) }, @@ -3644,11 +3522,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 +3539,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 +3550,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 +3567,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 +3656,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 +3675,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 +3684,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 +3778,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 +3786,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, @@ -3936,7 +3802,7 @@ impl AppWindow { settings_pane( div() .children(permission_warnings) - .child(settings_section_label("DICTATION")) + .child(settings_section_label("Dictation")) .child( settings_panel() .child(settings_row( @@ -3984,7 +3850,7 @@ impl AppWindow { .id("dictation-hotkey-setting"), ), ) - .child(settings_section_label("BEHAVIOR")) + .child(settings_section_label("Behavior")) .child( settings_panel() .child( @@ -4019,7 +3885,7 @@ impl AppWindow { ) .child( div() - .h(px(72.0 * double_tap_only_visibility)) + .h(px(SETTINGS_ROW_MIN_HEIGHT * double_tap_only_visibility)) .overflow_hidden() .opacity(double_tap_only_visibility) .child( @@ -4062,13 +3928,8 @@ impl AppWindow { .child(paste_last_control) .when(self.settings.paste_last_hotkey.is_some(), |row| { row.child( - canvas_button() + button(ButtonKind::Secondary, "Clear") .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); })), @@ -4079,15 +3940,15 @@ impl AppWindow { .id("paste-last-hotkey-setting"), ) ) - .child(settings_section_label("MEETINGS")) + .child(settings_section_label("Meetings")) .child(meetings_section) - .child(settings_section_label("APPLICATION")) + .child(settings_section_label("Application")) .child( settings_panel() .child( settings_row( "Launch at login", - "Start HEX when you log in", + "Start Hex when you log in", launch_at_login_control, ) .id("launch-at-login-setting") @@ -4107,20 +3968,13 @@ impl AppWindow { .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), - ) + settings.child(inline_error(error)) }, ) .child( settings_row( "Show Dock icon", - "Keep HEX in the menu bar when Settings is closed", + "Keep Hex in the menu bar when Settings is closed", toggle(dock_icon_position), ) .id("dock-icon-setting") @@ -4157,9 +4011,8 @@ impl AppWindow { let permission_control = if self.screen_recording_permitted { setup_ready_badge() } 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(); @@ -4201,7 +4054,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 +4066,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 +4106,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)) }) }) } @@ -4319,17 +4160,15 @@ impl AppWindow { let status = self.setup_status; let microphone = match status.microphone { PermissionState::Ready => setup_ready_badge(), - PermissionState::NeedsRequest => header_button("Allow") + 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(); @@ -4349,9 +4188,8 @@ impl AppWindow { )); } 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(); @@ -4364,9 +4202,8 @@ impl AppWindow { )); } 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(); @@ -4397,11 +4234,8 @@ impl AppWindow { let models = if models_ready { setup_ready_badge() } 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 +4255,7 @@ impl AppWindow { .flex() .items_center() .justify_center() - .bg(rgba(0x000000dd)) + .bg(rgba(SCRIM)) .child( div() .id("setup") @@ -4430,7 +4264,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 +4273,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,7 +4287,7 @@ impl AppWindow { .flex() .flex_col() .gap_2() - .child(setup_group_label("PERMISSIONS NEEDED")) + .child(section_label("Permissions needed").px_1()) .child(settings_panel().children(permission_rows)), ) }) @@ -4467,7 +4296,7 @@ impl AppWindow { .flex() .flex_col() .gap_2() - .child(setup_group_label("LOCAL MODELS")) + .child(section_label("Local models").px_1()) .child( settings_panel().child( settings_row( @@ -4490,9 +4319,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 +4330,7 @@ impl AppWindow { fn render_voice_action(&mut self, window: &mut Window, cx: &mut Context) -> 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 +4339,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); @@ -4560,15 +4381,7 @@ impl AppWindow { .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(), @@ -4580,14 +4393,13 @@ impl AppWindow { "Voice Action", "voice-action-scroll", div() - .child(settings_section_label("VOICE ACTION")) - .child(settings_panel().child( + .child(settings_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( settings_row( @@ -4605,7 +4417,7 @@ impl AppWindow { ) .when_some(processing, |column, processing| { column - .child(settings_section_label("PROCESSING")) + .child(settings_section_label("Processing")) .child(processing) }), ) @@ -4964,13 +4776,8 @@ impl AppWindow { } fn render_modes(&mut self, window: &mut Window, cx: &mut Context) -> 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,26 +4843,22 @@ 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)) + .w(px(PANE_LIST_WIDTH_COMPACT)) .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), - ), + panel().id("mode-list-card").overflow_y_scroll().child( + div().p_2().flex().flex_col().gap_1().children(rows), + ), ), ) .child(detail), @@ -5067,27 +4870,15 @@ impl AppWindow { fn render_mode_context_menu(&self, cx: &mut Context) -> Option { 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)) .child( - div() + menu_item(false) .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))) + .text_color(rgb(DANGER)) .child("Delete mode…") .on_mouse_down( MouseButton::Left, @@ -5126,11 +4917,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 +4933,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 +4947,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 +4969,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 +5022,7 @@ impl AppWindow { ) .items_center(); div() - .px_3() + .px_4() .py_3() .flex() .items_start() @@ -5239,37 +5036,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 +5072,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 +5123,11 @@ impl AppWindow { .child( div() .w_full() - .max_w(px(700.0)) .flex() .flex_col() .gap(px(SECTION_GAP)) - .pb_5() .child(basics) - .child(div().h(px(4.0))) - .child(compact_section_label("TEXT PROCESSING")) + .child(section_label("Text processing").pt_2().px_1()) .child(corrections) .child(processing) .child(transformations) @@ -5378,8 +5163,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 +5192,26 @@ impl AppWindow { .child( div() .id(("drag-mode-transformation", target_index)) - .w(px(20.0)) + .w(px(SPACE_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 +5234,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 +5250,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 +5280,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 +5294,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 +5307,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 +5330,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 +5379,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, @@ -5678,20 +5418,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 +5453,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 +5475,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 +5488,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 +5498,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 +5518,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() @@ -6022,12 +5738,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 +5789,13 @@ impl AppWindow { let choices = std::iter::once(None) .chain(variants.iter().cloned().map(Some)) .collect::>(); - 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 +5822,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 +5853,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 +5887,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 +5897,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()), ) @@ -6333,7 +5995,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 +6017,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)), ) }) @@ -6517,25 +6174,25 @@ impl AppWindow { fn render_meetings(&mut self, cx: &mut Context) -> 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() - } + _ 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(), + Some(MeetingStatus::Recording) => button(ButtonKind::Primary, "Stop & transcribe") + .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 +6206,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(); @@ -6563,8 +6224,8 @@ impl AppWindow { 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 })) + section_label(day.clone()) + .pt(px(if rows.is_empty() { SPACE_1 } else { SPACE_3 })) .pb_1() .px_3() .into_any_element(), @@ -6588,15 +6249,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 +6260,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,7 +6269,7 @@ impl AppWindow { ); } - let list = pane_list_card( + let list = pane_list( "meetings-list", (visible.is_empty() && self.meetings_error.is_none()).then_some(if searching { "No meetings match." @@ -6643,35 +6288,27 @@ impl AppWindow { .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().flex().justify_center().px(px(PANE_GUTTER)).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)) + .text_footnote() + .text_color(rgb(DANGER)) .child(error), ), ) }) .child( - pane_body().p_5().child( + pane_body().child( pane_content() .id("meetings-workspace") .flex_row() .gap_5() .child(list) - .child( - compact_panel() - .flex_1() - .min_w(px(0.0)) - .h_full() - .flex() - .flex_col() - .child(self.render_meeting_detail(cx)), - ), + .child(pane_detail(self.render_meeting_detail(cx))), ), ) .into_any_element() @@ -6699,15 +6336,11 @@ 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 mut actions: Vec = Vec::new(); if let Some(notes) = meeting.notes.clone() { @@ -6718,7 +6351,7 @@ impl AppWindow { if copied("notes") { "Copied" } else { - "Copy Summary" + "Copy summary" }, ) .on_click(cx.listener(move |this, _, _, cx| { @@ -6736,7 +6369,7 @@ impl AppWindow { if copied("transcript") { "Copied" } else { - "Copy Transcript" + "Copy transcript" }, ) .on_click(cx.listener(move |this, _, _, cx| { @@ -6759,7 +6392,7 @@ impl AppWindow { if export_path.is_some() { "Reveal Markdown" } else { - "Reveal Files" + "Reveal files" }, ) .on_click( @@ -6775,14 +6408,14 @@ impl AppWindow { && self.apple_intelligence_availability().is_available() { if notes_running { - actions.push(meeting_progress_action("Summarizing...")); + actions.push(busy_label("Summarizing…").into_any_element()); } else { let id = id.clone(); actions.push( action( "notes", if meeting.notes.is_some() { - "Regenerate Summary" + "Regenerate summary" } else { "Summarize" }, @@ -6814,7 +6447,12 @@ impl AppWindow { let armed = self.meeting_delete_armed.as_deref() == Some(id.as_str()); let delete_id = id.clone(); actions.push( - action( + action_of( + if armed { + ButtonKind::Danger + } else { + ButtonKind::Secondary + }, "delete", match ( armed, @@ -6824,16 +6462,10 @@ impl AppWindow { .is_some_and(|path| path.exists()), ) { (false, _) => "Delete", - (true, true) => "Trash Recording & Markdown", - (true, false) => "Trash Recording", + (true, true) => "Trash recording & Markdown", + (true, false) => "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)), ) @@ -6868,12 +6500,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(), + _ => div().text_headline().child(title).into_any_element(), }; let mut meta = vec![ @@ -6909,16 +6536,16 @@ impl AppWindow { 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 { + 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." @@ -6952,10 +6579,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 +6592,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() @@ -6998,8 +6625,7 @@ impl AppWindow { .flex() .items_center() .gap_2() - .text_size(px(11.0)) - .text_color(rgb(MUTED)) + .text_color(rgb(TEXT_TERTIARY)) .when_some( meeting_status_chip( meeting.status, @@ -7008,14 +6634,7 @@ impl AppWindow { ), |row, chip| row.child(chip), ) - .child( - div() - .flex_1() - .min_w(px(0.0)) - .overflow_x_hidden() - .truncate() - .child(meta.join(" · ")), - ), + .child(truncated_line(FOOTNOTE, meta.join(" · ")).flex_1()), ) .when(!actions.is_empty(), |header| { header.child( @@ -7045,13 +6664,12 @@ impl AppWindow { .mb_4() .px_3() .py_2() - .rounded(px(6.0)) + .rounded(RADIUS_CONTROL) .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)) + .text_footnote() + .text_color(rgb(DANGER)) .child(error) })) .when_some(notes, |content, notes| content.child(notes)) @@ -7061,10 +6679,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 +6700,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 +6724,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 +6732,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 +6759,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(px(SPACE_5)).bg(rgb(LINE))) .child( div() .id("commands-enabled") @@ -7235,11 +6848,10 @@ impl AppWindow { let mut groups: Vec = Vec::new(); for task in tasks { groups.push( - compact_section_label(task.clone()) + section_label(task.clone()) .px_3() .pt_3() .pb_1() - .text_color(rgb(MUTED)) .into_any_element(), ); groups.extend( @@ -7253,25 +6865,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 +6878,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 +6901,7 @@ impl AppWindow { .gap_5() .child( div() - .w(px(commands_list_width)) + .w(px(PANE_LIST_WIDTH)) .h_full() .flex_none() .flex() @@ -7320,61 +6913,35 @@ 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), - ), - ), - ) - .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}")), - ), + pane_list( + "commands-list", + 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( - compact_panel() - .id("command-detail") - .flex_1() - .min_h(px(0.0)) - .overflow_y_scroll() - .child(self.render_command_detail( - effective_selected.as_deref(), - )), + truncated_line( + CAPTION, + format!("Current context: {context_label}"), + ) + .px_1() + .text_color(rgb(TEXT_TERTIARY)), ), + ) + .child( + pane_detail( + self.render_command_detail(effective_selected.as_deref()), + ) + .id("command-detail") + .overflow_y_scroll(), ), ), ), @@ -7394,18 +6961,13 @@ impl AppWindow { command.aliases.join("\n") }; div() - .p_5() - .child( - div() - .text_size(px(15.0)) - .font_weight(FontWeight::SEMIBOLD) - .child(command.phrase.clone()), - ) + .p_6() + .child(div().text_headline().child(command.phrase.clone())) .child( div() - .pt_2() - .text_size(px(12.0)) - .text_color(rgb(MUTED)) + .pt_1() + .text_body() + .text_color(rgb(TEXT_TERTIARY)) .child(command.description.clone()), ) .child( @@ -7464,7 +7026,7 @@ impl AppWindow { } fn render_activity(&mut self, cx: &mut Context) -> 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 +7062,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,11 +7087,11 @@ 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", (self.events.is_empty() && self.activity.error.is_none()) .then_some("No activity yet."), @@ -7536,15 +7099,7 @@ impl AppWindow { 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 +7118,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 +7137,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 +7298,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 +7316,7 @@ impl AppWindow { .top(px(7.0)) .h(px(4.0)) .rounded_full() - .bg(rgb(0x303030)), + .bg(rgb(SURFACE_SELECTED)), ) .child( div() @@ -7788,7 +7337,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 +7399,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 +7457,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 +7676,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 +7763,11 @@ fn preview_history() -> Option { 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." } } @@ -8482,35 +7973,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 +7995,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 +8038,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 +8047,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 +8192,13 @@ fn model_choice_row( subtitle: impl Into, 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 +8220,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 +8232,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 +8341,23 @@ 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() + tag(Tone::Success, "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 +8365,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 +8462,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) -> 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 +8523,6 @@ fn command_scope(scope: &CommandScope) -> String { } } -fn pane_list_card( - id: &'static str, - empty: Option<&'static str>, - error_title: &'static str, - error: Option, - rows: Vec, -) -> gpui::Stateful
{ - 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 +8539,16 @@ fn timestamped_list_row( .child( div() .flex_none() - .text_size(px(10.0)) - .text_color(rgb(if selected { TEXT_SOFT } else { FAINT })) + .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() - .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 +8559,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 +8576,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
{ - 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 +8639,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))), @@ -9322,12 +8658,12 @@ fn transcript_turn(index: usize, line: &TranscriptLine) -> AnyElement { .px_1() .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 +8674,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 +8849,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) { match error { TrySendError::Full(_) => tracing::warn!("meeting controller is busy"), @@ -9577,13 +8898,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 +8925,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}\""), @@ -10172,7 +9493,7 @@ mod tests { impl Render for SettingsLayout { fn render(&mut self, _: &mut Window, _: &mut Context) -> 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( div() @@ -10183,7 +9504,7 @@ mod tests { ))) .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 +9776,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 +10188,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 +10218,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); From fa9bb974bbbe536fc1cd4dceb29368243269b203 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 17 Oct 2026 23:16:02 -0700 Subject: [PATCH 03/14] style(ui): adopt the design system in the picker, inputs, capsules, and Linux --- scripts/capture-preview.sh | 2 +- scripts/test-wayland-paste.sh | 4 +- src/desktop_transcription_picker.rs | 239 ++++++++++------------------ src/linux_app.rs | 128 +++++++-------- src/meeting_watcher.rs | 118 +++++--------- src/status_item.rs | 10 +- src/text_input.rs | 25 ++- 7 files changed, 198 insertions(+), 328 deletions(-) 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/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( .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( 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( 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( 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( }, |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( ) }, ) - .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( .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( ) .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( .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( .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( .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( .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( .into_any_element() } -fn card_button(label: impl Into) -> 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/linux_app.rs b/src/linux_app.rs index 9f7776cb..515ae8e6 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, segmented_control, segmented_item, settings_panel, + 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_section_label("Listener")) .child(settings_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.")), + div().id("linux-listener-warning").mt_3().p_3().rounded(RADIUS_PANEL).border_1().border_color(rgb(LINE)).bg(rgb(SURFACE)) + .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,9 +1203,9 @@ 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() .child(settings_row( @@ -1265,14 +1250,14 @@ 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( settings_row( @@ -1281,7 +1266,7 @@ impl LinuxApp { 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() .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/meeting_watcher.rs b/src/meeting_watcher.rs index bd0da56e..610c543b 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, cx: &mut App, ) -> gpui::Result> { - 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, - deadline: Option<(Instant, Duration)>, copied: bool, } impl Render for MeetingPanel { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> 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) -> impl IntoElement { let (dot, title, subtitle, actions) = match &self.kind { PanelKind::Offer(candidate) => { let dismiss = self.commands.clone(); @@ -1293,18 +1286,14 @@ 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, _| { + .child(secondary_button("not-now", "Not now", move |window, _| { let _ = dismiss.try_send(ControllerCommand::Dismiss); window.remove_window(); })) @@ -1323,22 +1312,22 @@ impl Render for MeetingPanel { 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( "keep-recording", - "Keep Recording", + "Keep recording", |window, _| { window.remove_window(); }, )) .child(primary_button( "stop-meeting", - "Stop & Transcribe", + "Stop & transcribe", move |window, _| { if stop.try_send(ControllerCommand::Stop).is_ok() { window.remove_window(); @@ -1376,7 +1365,7 @@ impl Render for MeetingPanel { format!("Ready · {title}") }; ( - rgb(0x5ac46a), + SUCCESS, title, subtitle, div() @@ -1414,22 +1403,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,45 +1425,24 @@ 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()), ) } } +/// Capsule buttons are the shared buttons in the capsule's pill shape. 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) + capsule_button(ButtonKind::Primary, id, label, on_click) } fn secondary_button( @@ -1483,31 +1450,18 @@ fn secondary_button( label: &'static str, on_click: impl Fn(&mut Window, &mut App) + 'static, ) -> impl IntoElement { - button(id, label, rgb(0x262626), rgb(0xbdbdbd), on_click) + capsule_button(ButtonKind::Secondary, id, label, on_click) } -fn button( +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> { 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> { ); } 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> { .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() }) } } From ed1e83d96ffb0d7a3812ca2cb8768b9dc53ec974 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 17 Oct 2026 23:16:02 -0700 Subject: [PATCH 04/14] docs: add the design system guide and point agents to it --- AGENTS.md | 40 +++---- README.md | 2 +- docs/design-system.md | 243 ++++++++++++++++++++++++++++++++++++++ docs/features/README.md | 2 +- docs/features/commands.md | 6 +- docs/features/meetings.md | 14 +-- 6 files changed, 269 insertions(+), 38 deletions(-) create mode 100644 docs/design-system.md diff --git a/AGENTS.md b/AGENTS.md index 1229840c..f91f1efc 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,13 @@ 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. + 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,7 +503,7 @@ 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`). 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..1e90f896 --- /dev/null +++ b/docs/design-system.md @@ -0,0 +1,243 @@ +# 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()`, and `.text_xs/sm/base/lg/xl()` 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`: outline SF Symbols at medium + weight on macOS. Never draw an icon with text glyphs or letters. +6. **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. + +### 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. + +```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 +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` | +| 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` → `settings_panel` → `settings_row(title, description, control)` | +| Label above a group | `section_label` (caption, semibold, tertiary) | +| 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)` | +| 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 detail card: `detail_placeholder` | +| Errors | `error_panel` (card), `error_message` (inside a card), `inline_error` (under a control) | + +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…"). +- 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`, `Globe`, + `Plus`) via `icon(Icon, colour)` at `ICON_SIZE_SMALL`, or `sized_icon` for a + different size. Use `NavigationIcon` for the sidebar. +- On macOS these are outline SF Symbols 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, no clipped text without "…", one primary button +per view, 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 filled and outline SF Symbols, "×" "+" "≡" "→" glyphs, letter + monograms -> one outline Icon set, globe for web pages +Truncation .truncate() clipped or wrapped in flex rows -> 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..73b3bbd6 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 ``` @@ -203,8 +203,8 @@ List (grouped Today / Yesterday / date) └── 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 summary, Copy transcript, Reveal Markdown, Summarize / +│ Regenerate summary, Rename, Delete (finished meetings only for the last four) ├── 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; From aa26550ad8682f04406b993bab3c1b161a45ed73 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 17 Oct 2026 23:59:05 -0700 Subject: [PATCH 05/14] fix(ui): keep truncated lines at their final width and restore the sparkle icon --- docs/design-system.md | 17 ++-- src/app_window.rs | 26 +++--- src/design_tokens.rs | 5 +- src/desktop_ui.rs | 200 ++++++++++++++++++++++++++++++++++-------- 4 files changed, 188 insertions(+), 60 deletions(-) diff --git a/docs/design-system.md b/docs/design-system.md index 1e90f896..1e3a5d0a 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -11,7 +11,8 @@ 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()`, and `.text_xs/sm/base/lg/xl()` in +`.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`. @@ -29,8 +30,9 @@ outside the system: its glossy capsule is hand-tuned in HUD Lab. 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`: outline SF Symbols at medium - weight on macOS. Never draw an icon with text glyphs or letters. +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. **Verify by looking.** Capture every pane you touched with `scripts/capture-preview.sh` (see [Verification](#verification)). @@ -184,8 +186,9 @@ Meeting capsules are pill-shaped. They use the shared buttons with - Use `Icon` (`ArrowRight`, `ChevronDown`, `Close`, `DragHandle`, `Globe`, `Plus`) via `icon(Icon, colour)` at `ICON_SIZE_SMALL`, or `sized_icon` for a different size. Use `NavigationIcon` for the sidebar. -- On macOS these are outline SF Symbols at medium weight, monochrome. On Linux - they fall back to Heroicons (navigation) and matching glyphs (inline). +- 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 @@ -235,9 +238,9 @@ Buttons 7 looks (header_button, canvas_button, compact_button, blue fill 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 filled and outline SF Symbols, "×" "+" "≡" "→" glyphs, letter +Icons mixed filled and line SF Symbols, "×" "+" "≡" "→" glyphs, letter monograms -> one outline Icon set, globe for web pages -Truncation .truncate() clipped or wrapped in flex rows -> truncated_line +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/src/app_window.rs b/src/app_window.rs index a0952900..3832962a 100644 --- a/src/app_window.rs +++ b/src/app_window.rs @@ -2701,6 +2701,7 @@ impl AppWindow { .gap_5() .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() @@ -4847,20 +4848,14 @@ impl AppWindow { pane_content() .flex_row() .gap_5() - .child( - div() - .id("modes-list") - .w(px(PANE_LIST_WIDTH_COMPACT)) - .h_full() - .flex_none() - .flex() - .flex_col() - .child( - 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), ), ) @@ -6271,6 +6266,7 @@ impl AppWindow { 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 { @@ -6915,6 +6911,7 @@ impl AppWindow { .child( pane_list( "commands-list", + PANE_LIST_WIDTH, no_commands.then_some(if query.is_empty() { "No commands yet." } else { @@ -7093,6 +7090,7 @@ impl AppWindow { .gap_5() .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.", diff --git a/src/design_tokens.rs b/src/design_tokens.rs index cda331d3..3aa5caad 100644 --- a/src/design_tokens.rs +++ b/src/design_tokens.rs @@ -243,7 +243,8 @@ mod tests { } /// Raw styling values belong here. A pane that writes a literal colour, - /// font size, line height, or radius bypasses the design system. + /// 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)] = &[ @@ -274,6 +275,8 @@ mod tests { ".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 { diff --git a/src/desktop_ui.rs b/src/desktop_ui.rs index ef6931a1..757de57a 100644 --- a/src/desktop_ui.rs +++ b/src/desktop_ui.rs @@ -26,13 +26,13 @@ pub(crate) enum NavigationIcon { } impl NavigationIcon { - /// Outline symbols only, so no sidebar item reads heavier than another. + /// 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", - Self::VoiceAction => "wand.and.stars", + Self::VoiceAction => "sparkle", Self::Commands => "command", Self::History => "clock", Self::Meetings => "calendar", @@ -89,7 +89,7 @@ fn navigation_icon(icon: NavigationIcon, selected: bool) -> AnyElement { .into_any_element() } -/// The one inline icon set. On macOS every icon is an outline SF Symbol at +/// 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 { @@ -309,11 +309,13 @@ pub(crate) fn pane_content() -> Div { .flex_col() } -/// The list column of a list+detail pane: a [`PANE_LIST_WIDTH`] scrolling -/// card with its empty notice and load error ahead of its rows. Callers -/// decide when `empty` applies. +/// 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, + width: f32, empty: Option<&'static str>, error_title: &'static str, error: Option, @@ -321,7 +323,7 @@ pub(crate) fn pane_list( ) -> Stateful
{ panel() .id(id) - .w(px(PANE_LIST_WIDTH)) + .w(px(width)) .h_full() .flex_none() .overflow_y_scroll() @@ -330,9 +332,11 @@ pub(crate) fn pane_list( 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(PANE_LIST_WIDTH - 2.0)) + .w(px(width - 2.0)) .p_2() .flex() .flex_col() @@ -389,9 +393,12 @@ pub(crate) fn list_row_meta(text: impl IntoElement, selected: bool) -> Div { /// One line that ends in an ellipsis when it overflows. /// -/// GPUI truncates only text first measured against a definite width, so the -/// line is laid out absolutely inside a one-line box that takes the -/// available width and contributes none of its own. Add `flex_1` in rows. +/// GPUI truncates text against the width it is measured at. The line is laid +/// out absolutely inside a one-line box, so it is measured only at the box's +/// width and contributes no width of its own; add `flex_1` in rows. It wraps +/// (clamped to one line) instead of using GPUI's `truncate`, because GPUI keeps +/// the first measurement of non-wrapping text, and block ancestors sizing +/// themselves to their content lay the line out at intermediate widths first. pub(crate) fn truncated_line(step: TypeStep, text: impl IntoElement) -> Div { div() .relative() @@ -406,7 +413,9 @@ pub(crate) fn truncated_line(step: TypeStep, text: impl IntoElement) -> Div { .top_0() .left_0() .right_0() - .truncate() + .overflow_hidden() + .text_ellipsis() + .line_clamp(1) .child(text), ) } @@ -1098,45 +1107,160 @@ mod layout_tests { const LONG: &str = "The parser needs a retry when the socket drops during the reconnect"; - /// A list-like row: truncating title beside a fixed timestamp, inside a - /// fixed-width column, as History and Meetings render it. - struct TruncatedRow { + /// Renders `build` around one measured text so a test can read how GPUI + /// shaped (and possibly truncated) it. + struct Probe { + text: &'static str, + build: fn(StyledText) -> AnyElement, layout: Rc>>, } - impl Render for TruncatedRow { + impl Render for Probe { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { - let text = StyledText::new(LONG); + let text = StyledText::new(self.text); *self.layout.borrow_mut() = Some(text.layout().clone()); - div().w(px(PANE_LIST_WIDTH)).flex().flex_col().child( - list_row(false) - .flex_row() - .gap_3() - .child( - div() - .flex_1() - .min_w_0() - .flex() - .flex_col() - .child(truncated_line(BODY, text)) - .child(list_row_meta("Dictation · Zed", false)), - ) - .child(div().flex_none().child("1d ago")), - ) + div().size_full().child((self.build)(text)) } } - #[gpui::test] - fn truncated_line_ends_in_an_ellipsis_inside_flex_rows(cx: &mut TestAppContext) { + /// The shaped text and the share of its box it fills. + fn shape( + cx: &mut TestAppContext, + text: &'static str, + build: fn(StyledText) -> AnyElement, + ) -> (String, f32) { let layout = Rc::new(RefCell::new(None)); let (_, cx) = cx.add_window_view({ let layout = layout.clone(); - |_, _| TruncatedRow { layout } + move |_, _| Probe { + text, + build, + layout, + } }); - cx.simulate_resize(size(px(600.0), px(200.0))); + cx.simulate_resize(size(px(1000.0), px(400.0))); cx.run_until_parked(); - let text = layout.borrow().as_ref().unwrap().text(); + let layout = layout.borrow().clone().unwrap(); + let shaped = layout.text(); + let end = layout.position_for_index(shaped.len()).unwrap().x - layout.bounds().left(); + (shaped, end / layout.bounds().size.width) + } + + #[gpui::test] + fn truncated_line_fills_its_row_and_ends_in_an_ellipsis(cx: &mut TestAppContext) { + // History and Meetings list rows: a title beside a fixed timestamp. + let (text, filled) = shape(cx, LONG, |text| { + div() + .w(px(PANE_LIST_WIDTH)) + .flex() + .flex_col() + .child( + list_row(false) + .flex_row() + .gap_3() + .child( + div() + .flex_1() + .min_w_0() + .flex() + .flex_col() + .child(truncated_line(BODY, text)) + .child(list_row_meta("Dictation · Zed", false)), + ) + .child(div().flex_none().child("1d ago")), + ) + .into_any_element() + }); assert!(text.ends_with('…'), "not truncated: {text:?}"); - assert!(text.len() < LONG.len()); + assert!(filled > 0.8, "{text:?} fills only {filled:.2} of its line"); + } + + #[gpui::test] + fn truncated_line_keeps_short_text_beside_a_tag(cx: &mut TestAppContext) { + // The meeting detail metadata row. + let (text, _) = shape(cx, "Today · 8:18 PM · 38 min · Zoom", |text| { + div() + .w(px(700.0)) + .px_6() + .flex() + .flex_col() + .child( + div() + .flex() + .items_center() + .gap_2() + .child(tag(Tone::Success, "Ready")) + .child(truncated_line(FOOTNOTE, text).flex_1()), + ) + .into_any_element() + }); + assert_eq!(text, "Today · 8:18 PM · 38 min · Zoom"); + } + + #[gpui::test] + fn truncated_line_keeps_short_text_in_a_scrolling_list(cx: &mut TestAppContext) { + // The Modes list: rows inside a scrolling card. + let (text, _) = shape(cx, "Work notes", |text| { + pane_list( + "probe-list", + PANE_LIST_WIDTH_COMPACT, + None, + "", + None, + vec![ + list_row(true) + .child(list_row_title(text, true)) + .into_any_element(), + ], + ) + .into_any_element() + }); + assert_eq!(text, "Work notes"); + } + + #[gpui::test] + fn truncated_line_survives_intrinsic_sizing_of_block_ancestors(cx: &mut TestAppContext) { + // A flex item's automatic minimum width lays its block subtree out at + // min-content width first; the line must still use its final width. + let (text, _) = shape(cx, "Uppercase transformation", |text| { + div() + .w(px(600.0)) + .flex() + .child( + div().flex_1().child( + panel().child( + div() + .flex() + .items_center() + .child(truncated_line(BODY, text).flex_1()) + .child(icon_button(ButtonKind::Ghost, Icon::Close)), + ), + ), + ) + .into_any_element() + }); + assert_eq!(text, "Uppercase transformation"); + } + + #[gpui::test] + fn truncated_line_keeps_short_text_in_a_disclosure(cx: &mut TestAppContext) { + // The OpenCode model picker trigger beside its Refresh button. + let (text, _) = shape(cx, "claude-sonnet-4-5", |text| { + div() + .w(px(500.0)) + .flex() + .gap_3() + .child( + disclosure_frame() + .w_full() + .flex_shrink() + .child(truncated_line(FOOTNOTE, text).flex_1()) + .child(div().flex_none().child("Anthropic")) + .child(icon(Icon::ChevronDown, TEXT_TERTIARY)), + ) + .child(button(ButtonKind::Secondary, "Refresh")) + .into_any_element() + }); + assert_eq!(text, "claude-sonnet-4-5"); } } From 939484755cdc5014e16360dc76f9543f4aefa23e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 18 Oct 2026 00:09:33 -0700 Subject: [PATCH 06/14] fix(ui): shape truncated lines at paint time against their final width --- src/desktop_ui.rs | 211 ++++++++++++++++++++-------------------------- 1 file changed, 93 insertions(+), 118 deletions(-) diff --git a/src/desktop_ui.rs b/src/desktop_ui.rs index 757de57a..8c91124d 100644 --- a/src/desktop_ui.rs +++ b/src/desktop_ui.rs @@ -3,7 +3,8 @@ //! does which job. use gpui::{ - AnyElement, Div, ElementId, FontWeight, IntoElement, Rgba, Stateful, div, prelude::*, px, rgb, + AnyElement, App, Div, ElementId, FontWeight, IntoElement, Pixels, Rgba, ShapedLine, + SharedString, Stateful, Window, canvas, div, prelude::*, px, rgb, }; #[cfg(target_os = "linux")] @@ -372,7 +373,7 @@ pub(crate) fn list_row(selected: bool) -> Div { } /// The title line of a [`list_row`]. -pub(crate) fn list_row_title(text: impl IntoElement, selected: bool) -> Div { +pub(crate) fn list_row_title(text: impl Into, selected: bool) -> Div { truncated_line(BODY, text) .font_weight(FontWeight::MEDIUM) .text_color(rgb(if selected { @@ -383,7 +384,7 @@ pub(crate) fn list_row_title(text: impl IntoElement, selected: bool) -> Div { } /// The metadata line of a [`list_row`]. -pub(crate) fn list_row_meta(text: impl IntoElement, selected: bool) -> Div { +pub(crate) fn list_row_meta(text: impl Into, selected: bool) -> Div { truncated_line(CAPTION, text).text_color(rgb(if selected { TEXT_SECONDARY } else { @@ -391,35 +392,45 @@ pub(crate) fn list_row_meta(text: impl IntoElement, selected: bool) -> Div { })) } -/// One line that ends in an ellipsis when it overflows. +/// One line that ends in an ellipsis when it overflows. Add `flex_1` in rows. /// -/// GPUI truncates text against the width it is measured at. The line is laid -/// out absolutely inside a one-line box, so it is measured only at the box's -/// width and contributes no width of its own; add `flex_1` in rows. It wraps -/// (clamped to one line) instead of using GPUI's `truncate`, because GPUI keeps -/// the first measurement of non-wrapping text, and block ancestors sizing -/// themselves to their content lay the line out at intermediate widths first. -pub(crate) fn truncated_line(step: TypeStep, text: impl IntoElement) -> Div { +/// 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) -> Div { + let text = text.into(); div() - .relative() .w_full() .min_w_0() .h(px(step.line_height)) .flex_shrink() .text_step(step) .child( - div() - .absolute() - .top_0() - .left_0() - .right_0() - .overflow_hidden() - .text_ellipsis() - .line_clamp(1) - .child(text), + 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(), ) } +/// 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() @@ -760,7 +771,7 @@ pub(crate) fn busy_label(label: impl IntoElement) -> Div { } /// A dropdown trigger: a secondary control with its value and a chevron. -pub(crate) fn disclosure_button(label: impl IntoElement) -> Div { +pub(crate) fn disclosure_button(label: impl Into) -> Div { disclosure_frame() .w(px(DISCLOSURE_WIDTH)) .child(truncated_line(FOOTNOTE, label).flex_1()) @@ -1047,8 +1058,7 @@ mod tests { #[cfg(all(test, target_os = "macos"))] mod layout_tests { use super::*; - use gpui::{Context, Render, StyledText, TestAppContext, TextLayout, Window, size}; - use std::{cell::RefCell, rc::Rc}; + use gpui::{Context, Render, TestAppContext, Window, size}; struct SettingsRow; @@ -1107,54 +1117,61 @@ mod layout_tests { const LONG: &str = "The parser needs a retry when the socket drops during the reconnect"; - /// Renders `build` around one measured text so a test can read how GPUI - /// shaped (and possibly truncated) it. - struct Probe { - text: &'static str, - build: fn(StyledText) -> AnyElement, - layout: Rc>>, + struct Blank; + + impl Render for Blank { + fn render(&mut self, _: &mut Window, _: &mut Context) -> 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) -> impl IntoElement { - let text = StyledText::new(self.text); - *self.layout.borrow_mut() = Some(text.layout().clone()); - div().size_full().child((self.build)(text)) + div().size_full().child((self.0)()) } } - /// The shaped text and the share of its box it fills. - fn shape( - cx: &mut TestAppContext, - text: &'static str, - build: fn(StyledText) -> AnyElement, - ) -> (String, f32) { - let layout = Rc::new(RefCell::new(None)); - let (_, cx) = cx.add_window_view({ - let layout = layout.clone(); - move |_, _| Probe { - text, - build, - layout, - } - }); + fn line_width(cx: &mut TestAppContext, 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(); - let layout = layout.borrow().clone().unwrap(); - let shaped = layout.text(); - let end = layout.position_for_index(shaped.len()).unwrap().x - layout.bounds().left(); - (shaped, end / layout.bounds().size.width) + f32::from(cx.debug_bounds("line").unwrap().size.width) + } + + fn line(step: TypeStep, text: &'static str) -> Div { + truncated_line(step, text).debug_selector(|| "line".into()) } #[gpui::test] - fn truncated_line_fills_its_row_and_ends_in_an_ellipsis(cx: &mut TestAppContext) { - // History and Meetings list rows: a title beside a fixed timestamp. - let (text, filled) = shape(cx, LONG, |text| { - div() - .w(px(PANE_LIST_WIDTH)) - .flex() - .flex_col() - .child( + 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() @@ -1164,21 +1181,22 @@ mod layout_tests { .min_w_0() .flex() .flex_col() - .child(truncated_line(BODY, text)) + .child(line(BODY, LONG)) .child(list_row_meta("Dictation · Zed", false)), ) - .child(div().flex_none().child("1d ago")), - ) - .into_any_element() + .child(div().flex_none().child("1d ago")) + .into_any_element(), + ], + ) + .into_any_element() }); - assert!(text.ends_with('…'), "not truncated: {text:?}"); - assert!(filled > 0.8, "{text:?} fills only {filled:.2} of its line"); + assert!(width > 180.0 && width < PANE_LIST_WIDTH, "{width}"); } #[gpui::test] - fn truncated_line_keeps_short_text_beside_a_tag(cx: &mut TestAppContext) { + fn truncated_line_takes_the_row_beside_a_tag(cx: &mut TestAppContext) { // The meeting detail metadata row. - let (text, _) = shape(cx, "Today · 8:18 PM · 38 min · Zoom", |text| { + let width = line_width(cx, || { div() .w(px(700.0)) .px_6() @@ -1190,39 +1208,18 @@ mod layout_tests { .items_center() .gap_2() .child(tag(Tone::Success, "Ready")) - .child(truncated_line(FOOTNOTE, text).flex_1()), + .child(line(FOOTNOTE, "Today · 8:18 PM · 38 min · Zoom").flex_1()), ) .into_any_element() }); - assert_eq!(text, "Today · 8:18 PM · 38 min · Zoom"); - } - - #[gpui::test] - fn truncated_line_keeps_short_text_in_a_scrolling_list(cx: &mut TestAppContext) { - // The Modes list: rows inside a scrolling card. - let (text, _) = shape(cx, "Work notes", |text| { - pane_list( - "probe-list", - PANE_LIST_WIDTH_COMPACT, - None, - "", - None, - vec![ - list_row(true) - .child(list_row_title(text, true)) - .into_any_element(), - ], - ) - .into_any_element() - }); - assert_eq!(text, "Work notes"); + 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 lays its block subtree out at - // min-content width first; the line must still use its final width. - let (text, _) = shape(cx, "Uppercase transformation", |text| { + // 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() @@ -1232,35 +1229,13 @@ mod layout_tests { div() .flex() .items_center() - .child(truncated_line(BODY, text).flex_1()) + .child(line(BODY, "Smart punctuation").flex_1()) .child(icon_button(ButtonKind::Ghost, Icon::Close)), ), ), ) .into_any_element() }); - assert_eq!(text, "Uppercase transformation"); - } - - #[gpui::test] - fn truncated_line_keeps_short_text_in_a_disclosure(cx: &mut TestAppContext) { - // The OpenCode model picker trigger beside its Refresh button. - let (text, _) = shape(cx, "claude-sonnet-4-5", |text| { - div() - .w(px(500.0)) - .flex() - .gap_3() - .child( - disclosure_frame() - .w_full() - .flex_shrink() - .child(truncated_line(FOOTNOTE, text).flex_1()) - .child(div().flex_none().child("Anthropic")) - .child(icon(Icon::ChevronDown, TEXT_TERTIARY)), - ) - .child(button(ButtonKind::Secondary, "Refresh")) - .into_any_element() - }); - assert_eq!(text, "claude-sonnet-4-5"); + assert!(width > 500.0, "{width}"); } } From 113b59acd0af3881005c3e509bdd2bdb2a9fc5c9 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 18 Oct 2026 14:24:41 -0700 Subject: [PATCH 07/14] feat(ui): add overflow-menu, detail-header, and baseline primitives - Icon::Ellipsis, dropdown_menu, danger_menu_item, disabled_menu_item, and menu_separator for one reusable overflow menu - detail_header / detail_title / detail_body: one fixed, edge-to-edge ruled header for every detail card - list_section_label for group headings in list cards - baseline_offset puts smaller text on a larger line's baseline - MENU_WIDTH / MENU_OFFSET tokens; section labels inset to card text; pane_list centres its empty state --- src/design_tokens.rs | 24 +++++++++ src/desktop_ui.rs | 126 +++++++++++++++++++++++++++++++++++++++---- 2 files changed, 140 insertions(+), 10 deletions(-) diff --git a/src/design_tokens.rs b/src/design_tokens.rs index 3aa5caad..1ac3c33a 100644 --- a/src/design_tokens.rs +++ b/src/design_tokens.rs @@ -85,6 +85,19 @@ pub(crate) const KEYCAP_SIDE_BADGE: TypeStep = TypeStep { 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 { @@ -150,6 +163,9 @@ pub(crate) const CONTROL_HEIGHT: f32 = 30.0; pub(crate) const CONTROL_HEIGHT_SMALL: f32 = 24.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 = SPACE_1; 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; @@ -242,6 +258,14 @@ mod tests { } } + #[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. diff --git a/src/desktop_ui.rs b/src/desktop_ui.rs index 8c91124d..9ee67c8b 100644 --- a/src/desktop_ui.rs +++ b/src/desktop_ui.rs @@ -98,6 +98,7 @@ pub(crate) enum Icon { ChevronDown, Close, DragHandle, + Ellipsis, Globe, Plus, } @@ -110,6 +111,7 @@ impl Icon { Self::ChevronDown => "chevron.down", Self::Close => "xmark", Self::DragHandle => "line.3.horizontal", + Self::Ellipsis => "ellipsis", Self::Globe => "globe", Self::Plus => "plus", } @@ -122,6 +124,7 @@ impl Icon { Self::ChevronDown => "⌄", Self::Close => "×", Self::DragHandle => "≡", + Self::Ellipsis => "…", Self::Globe => "◎", Self::Plus => "+", } @@ -328,7 +331,9 @@ pub(crate) fn pane_list( .h_full() .flex_none() .overflow_y_scroll() - .when_some(empty, |list, message| list.child(empty_state(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)) }) @@ -358,6 +363,15 @@ pub(crate) fn pane_detail(content: impl IntoElement) -> Div { .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() + .pt(px(if first { SPACE_1 } else { SPACE_3 })) + .pb_1() +} + /// A rounded, selectable row inside a list column. pub(crate) fn list_row(selected: bool) -> Div { div() @@ -471,9 +485,72 @@ pub(crate) fn section_label(label: impl IntoElement) -> Div { .child(label) } -/// A [`section_label`] spaced to head a settings card. +/// 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_1().into_any_element() + 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, + actions: Vec, +) -> Div { + div() + .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() + .items_center() + .gap_2() + .children(actions), + ) + }) +} + +/// The title of a [`detail_header`]. +pub(crate) fn detail_title(text: impl Into) -> Div { + div().text_headline().child(text.into()) +} + +/// The scrolling content under a [`detail_header`]. +pub(crate) fn detail_body(id: impl Into) -> Stateful
{ + div() + .id(id) + .flex_1() + .min_h(px(0.0)) + .flex() + .flex_col() + .overflow_y_scroll() + .px_6() + .pt_5() + .pb_6() } pub(crate) fn settings_copy(title: &'static str, description: impl IntoElement) -> AnyElement { @@ -539,6 +616,7 @@ pub(crate) fn detail_row(label: &'static str, value: impl Into) -> AnyEl div() .w(px(92.0)) .flex_none() + .pt(px(baseline_offset(BODY, FOOTNOTE))) .text_footnote() .text_color(rgb(TEXT_TERTIARY)) .child(label), @@ -844,8 +922,43 @@ pub(crate) fn menu() -> Div { .occlude() } +/// 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)) +} + /// 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() .h(px(MENU_ITEM_HEIGHT)) @@ -857,13 +970,6 @@ pub(crate) fn menu_item(selected: bool) -> Div { .gap_2() .rounded(RADIUS_CONTROL) .text_footnote() - .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))) } pub(crate) const SOUND_VOLUME_STEPS: [(&str, f32); 5] = [ From 7ea7ae0b7192fcf6b537863ed86bb2e248efbfca Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 18 Oct 2026 14:24:41 -0700 Subject: [PATCH 08/14] feat(ui): meetings overflow menu, neutral recording button, polish pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Meetings detail keeps Copy transcript and Rename visible and moves Copy summary, Reveal, Summarize/Regenerate, and Delete… into a … menu. Delete keeps its two-step confirmation in the action row; refusal rules are unchanged. Stop & transcribe is a Secondary button with a red dot and the elapsed time; the sidebar Update is Secondary with an accent dot. Polish: shared detail headers in Meetings, History, and Commands; timestamp and detail-row baselines; section labels on the card text edge; wider side segments; transcript labels on the bubble text; one menu width; Commands context moved into the detail; ... -> …; no closing period on one-phrase descriptions. Adds the --open-meeting-actions preview flag. --- src/app_window.rs | 490 +++++++++++++++++++++++----------------------- src/main.rs | 5 + 2 files changed, 253 insertions(+), 242 deletions(-) diff --git a/src/app_window.rs b/src/app_window.rs index 3832962a..b29ac2a6 100644 --- a/src/app_window.rs +++ b/src/app_window.rs @@ -30,14 +30,16 @@ use crate::desktop_transcription_picker::{ transcription_selection_is_active, }; use crate::desktop_ui::{ - ButtonKind, Icon, NavigationIcon, Tone, busy_label, button, detail_placeholder, detail_row, - disclosure_button, disclosure_frame, empty_state, error_message, error_panel, hotkey_keycaps, - icon, icon_button, inline_error, list_row, list_row_meta, list_row_title, listener_status, - menu, menu_item, 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_panel, settings_row, - settings_section_label, sidebar_frame, sliding_segmented_control, sliding_segmented_item, - small_button, status_dot, tag, toggle, truncated_line, 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_panel, 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}; @@ -82,7 +84,8 @@ fn sidebar_update_button( preview: bool, ) -> Option> { (status == crate::sparkle::UpdateStatus::UpdateAvailable).then(|| { - button(ButtonKind::Primary, "Update") + button(ButtonKind::Secondary, status_dot(ACCENT_TEXT)) + .child("Update") .id("check-for-updates-sidebar") .on_click(move |_, window, cx| { if !preview { @@ -104,11 +107,9 @@ fn microphone_picker_menu( dismiss: impl Fn(&MouseDownEvent, &mut Window, &mut App) + 'static, ) -> gpui::Stateful
{ let choose = Rc::new(choose); - menu() + dropdown_menu() .id("microphone-picker") .debug_selector(|| "microphone-picker".into()) - .absolute() - .top(px(CONTROL_HEIGHT + 4.0)) .right_0() .w(px(DISCLOSURE_WIDTH)) .max_h(px(240.0)) @@ -390,6 +391,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, } @@ -562,6 +564,7 @@ struct TranscriptLine { struct MeetingPreview { recording: bool, confirm_delete: bool, + open_actions: bool, empty: bool, } @@ -1023,6 +1026,8 @@ pub struct AppWindow { meeting_search_text: HashMap, meeting_rename: Option<(String, ProcessingInput)>, meeting_delete_armed: Option, + /// The detail's overflow menu of less common meeting actions is open. + meeting_actions_open: bool, meeting_action_error: Option, meeting_copied: Option<(String, &'static str)>, /// Deterministic transcripts for the Meetings preview. @@ -1137,6 +1142,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; @@ -1576,6 +1582,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, @@ -2342,6 +2349,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; } @@ -2473,6 +2481,7 @@ impl AppWindow { if preview.confirm_delete { self.meeting_delete_armed = Some(id); } + self.meeting_actions_open = preview.open_actions; } } @@ -2611,12 +2620,10 @@ impl AppWindow { self.history_retention_open, |control| { control.child(deferred( - menu() + dropdown_menu() .id("history-retention-menu") - .absolute() - .top(px(CONTROL_HEIGHT + 4.0)) .right_0() - .w(px(160.0)) + .w(px(MENU_WIDTH)) .on_mouse_down_out(cx.listener(|this, _, _, cx| { this.history_retention_open = false; cx.notify(); @@ -2742,84 +2749,60 @@ impl AppWindow { if entry.total_ms > 0 { latency.push_str(&format!(" · {} ms total", entry.total_ms)); } + let section = |label: &'static str, first: bool| { + div() + .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() + detail_body("history-detail") .child( - div() - .flex() - .items_center() - .gap_3() - .child(div().text_headline().child(entry.kind.label())) - .child( - div() - .text_caption() - .text_color(rgb(TEXT_TERTIARY)) - .child(event_age(entry.timestamp_ms)), - ), - ) - .child( - div() - .pt_4() - .flex() - .items_center() - .gap_2() - .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), - ))), + section("Final text", true).child( + div() + .pt_2() + .text_body() + .text_color(rgb(TEXT_PRIMARY)) + .child(entry.final_text.clone()), + ), ) - .child( - div() - .mt_5() - .pt_5() - .border_t_1() - .border_color(rgb(LINE)) - .child(section_label("Final text")) - .child( + .when(show_raw, |detail| { + detail.child( + section("Raw transcript", false).child( div() .pt_2() .text_body() - .text_color(rgb(TEXT_PRIMARY)) - .child(entry.final_text.clone()), + .text_color(rgb(TEXT_TERTIARY)) + .child(entry.raw_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_2() - .text_body() - .text_color(rgb(TEXT_TERTIARY)) - .child(entry.raw_text.clone()), - ), ) }) .child( div() .mt_5() - .pt_5() .border_t_1() .border_color(rgb(LINE)) .when_some(entry.application.clone(), |detail, application| { @@ -3074,7 +3057,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 = [40.0, 50.0, 40.0]; 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); @@ -3867,7 +3850,7 @@ impl AppWindow { 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." + "Keeps the microphone open for instant start and pre-roll" }, microphone_mode, )) @@ -4183,7 +4166,7 @@ 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, )); @@ -4198,7 +4181,7 @@ impl AppWindow { .into_any_element(); permission_items.push(( "Input Monitoring", - "Recognize the dictation shortcut anywhere.", + "Recognize the dictation shortcut anywhere", input_monitoring, )); } @@ -4215,7 +4198,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, )); @@ -4288,7 +4271,7 @@ impl AppWindow { .flex() .flex_col() .gap_2() - .child(section_label("Permissions needed").px_1()) + .child(section_label("Permissions needed").px_4()) .child(settings_panel().children(permission_rows)), ) }) @@ -4297,7 +4280,7 @@ impl AppWindow { .flex() .flex_col() .gap_2() - .child(section_label("Local models").px_1()) + .child(section_label("Local models").px_4()) .child( settings_panel().child( settings_row( @@ -4406,9 +4389,9 @@ impl AppWindow { 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, ) @@ -4532,7 +4515,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, ) }); @@ -4661,7 +4644,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, ), @@ -4869,11 +4852,10 @@ impl AppWindow { .absolute() .left(position.x) .top(position.y) - .w(px(148.0)) + .w(px(MENU_WIDTH)) .child( - menu_item(false) + danger_menu_item() .id("context-delete-mode") - .text_color(rgb(DANGER)) .child("Delete mode…") .on_mouse_down( MouseButton::Left, @@ -5122,7 +5104,7 @@ impl AppWindow { .flex_col() .gap(px(SECTION_GAP)) .child(basics) - .child(section_label("Text processing").pt_2().px_1()) + .child(section_label("Text processing").pt_2().px_4()) .child(corrections) .child(processing) .child(transformations) @@ -5402,7 +5384,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()) } @@ -5683,7 +5665,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(), @@ -6168,11 +6150,20 @@ impl AppWindow { } fn render_meetings(&mut self, cx: &mut Context) -> AnyElement { - let action = match meeting::active(&self.meetings).map(|meeting| meeting.status) { + 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(), - Some(MeetingStatus::Recording) => button(ButtonKind::Primary, "Stop & transcribe") + // 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; @@ -6218,13 +6209,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( - section_label(day.clone()) - .pt(px(if rows.is_empty() { SPACE_1 } else { SPACE_3 })) - .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(); @@ -6337,20 +6322,28 @@ impl AppWindow { }; 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 = Vec::new(); + let mut menu_items: Vec = 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" - }, + }), ) + // 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(), @@ -6379,23 +6372,33 @@ impl AppWindow { .into_any_element(), ); } + 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) + })) + .into_any_element(), + ); + } if !active { let export_path = meeting.export_path.clone().filter(|path| path.exists()); let reveal_id = id.clone(); - actions.push( - action( + 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()) }), ) + .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(), ); } @@ -6404,19 +6407,24 @@ impl AppWindow { && self.apple_intelligence_availability().is_available() { if notes_running { - actions.push(busy_label("Summarizing…").into_any_element()); + 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() { + 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())) @@ -6429,37 +6437,63 @@ 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 { let delete_id = id.clone(); actions.push( action_of( - if armed { - ButtonKind::Danger - } else { - ButtonKind::Secondary - }, + 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" }, ) .on_click( @@ -6467,16 +6501,14 @@ impl AppWindow { ) .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 { @@ -6496,7 +6528,7 @@ impl AppWindow { })), ) .into_any_element(), - _ => div().text_headline().child(title).into_any_element(), + _ => detail_title(title).into_any_element(), }; let mut meta = vec![ @@ -6542,15 +6574,15 @@ impl AppWindow { MeetingTranscript::Empty => empty_state("No transcript selected."), MeetingTranscript::Loaded(lines) if lines.is_empty() => { empty_state(match meeting.status { - MeetingStatus::Starting => "Preparing local audio capture...", + 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." @@ -6605,56 +6637,20 @@ 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_color(rgb(TEXT_TERTIARY)) - .when_some( - meeting_status_chip( - meeting.status, - self.meeting_stop_pending, - true, - ), - |row, chip| row.child(chip), - ) - .child(truncated_line(FOOTNOTE, meta.join(" · ")).flex_1()), - ) - .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() @@ -6843,13 +6839,7 @@ impl AppWindow { }); let mut groups: Vec = Vec::new(); for task in tasks { - groups.push( - section_label(task.clone()) - .px_3() - .pt_3() - .pb_1() - .into_any_element(), - ); + groups.push(list_section_label(task.clone(), groups.is_empty()).into_any_element()); groups.extend( visible .iter() @@ -6923,22 +6913,14 @@ impl AppWindow { ) .flex_1() .min_h(px(0.0)), - ) - .child( - truncated_line( - CAPTION, - format!("Current context: {context_label}"), - ) - .px_1() - .text_color(rgb(TEXT_TERTIARY)), ), ) .child( - pane_detail( - self.render_command_detail(effective_selected.as_deref()), - ) - .id("command-detail") - .overflow_y_scroll(), + pane_detail(self.render_command_detail( + effective_selected.as_deref(), + &context_label, + )) + .id("command-detail"), ), ), ), @@ -6946,7 +6928,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 { @@ -6958,21 +6940,21 @@ impl AppWindow { command.aliases.join("\n") }; div() - .p_6() - .child(div().text_headline().child(command.phrase.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() + detail_body("command-detail-body") .pt_1() - .text_body() - .text_color(rgb(TEXT_TERTIARY)) - .child(command.description.clone()), - ) - .child( - div() - .pt_4() .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() } @@ -7763,7 +7745,7 @@ fn preview_history() -> Option { fn voice_action_description(enabled: bool) -> &'static str { if enabled { - "Use the shortcut below with OpenCode to run spoken instructions." + "Use the shortcut below with OpenCode to run spoken instructions" } else { "Off by default. Its saved shortcut is not reserved until you enable Voice Action." } @@ -8537,6 +8519,7 @@ fn timestamped_list_row( .child( div() .flex_none() + .pt(px(baseline_offset(BODY, CAPTION))) .text_caption() .text_color(rgb(if selected { TEXT_SECONDARY @@ -8652,8 +8635,9 @@ 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_caption() @@ -9030,6 +9014,7 @@ mod tests { screen_recording_missing: false, meeting_recording: false, confirm_meeting_delete: false, + open_meeting_actions: false, meetings_empty: false, }), window, @@ -9065,6 +9050,7 @@ mod tests { screen_recording_missing: false, meeting_recording: false, confirm_meeting_delete: false, + open_meeting_actions: false, meetings_empty: false, }), window, @@ -9082,7 +9068,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(), @@ -9098,6 +9084,26 @@ 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, diff --git a/src/main.rs b/src/main.rs index 9dcc3d37..3f7a23e6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -239,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, @@ -512,6 +515,7 @@ fn main() -> Result<()> { screen_recording_missing, meeting_recording, confirm_meeting_delete, + open_meeting_actions, meetings_empty, meeting_panel, } => { @@ -582,6 +586,7 @@ fn main() -> Result<()> { screen_recording_missing, meeting_recording, confirm_meeting_delete, + open_meeting_actions, meetings_empty, }), ) From 105942ba7d4e4abe283f9e8d75325addbfb38683 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 18 Oct 2026 14:24:41 -0700 Subject: [PATCH 09/14] docs: document the accent and overflow-menu rules --- AGENTS.md | 7 +++-- docs/design-system.md | 54 +++++++++++++++++++++++++++++++++------ docs/features/meetings.md | 20 ++++++++++----- 3 files changed, 64 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f91f1efc..690530eb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -403,7 +403,9 @@ CoreAudio formats, AppleScript details, or event serialization. `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. - Extend a token or component instead of adding a parallel helper, and verify + 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. @@ -510,7 +512,8 @@ preview. Targets include `settings`, `modes`, `voice-action`, `commands`, 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/docs/design-system.md b/docs/design-system.md index 1e3a5d0a..fdce51ac 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -33,7 +33,13 @@ outside the system: its glossy capsule is hand-tuned in HUD Lab. 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. **Verify by looking.** Capture every pane you touched with +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 @@ -83,6 +89,21 @@ 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. @@ -110,6 +131,10 @@ 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 @@ -118,6 +143,7 @@ rounded_full // dots, avatars, meeting capsules and their button 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) @@ -135,10 +161,12 @@ ICON_SIZE 14 · ICON_SIZE_SMALL 11 · TOGGLE 28×16 · DISABLED_OPACITY 0.4 | 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` → `settings_panel` → `settings_row(title, description, control)` | -| Label above a group | `section_label` (caption, semibold, tertiary) | +| 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, …)` | @@ -147,16 +175,22 @@ ICON_SIZE 14 · ICON_SIZE_SMALL 11 · TOGGLE 28×16 · DISABLED_OPACITY 0.4 | 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)` | +| 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 detail card: `detail_placeholder` | +| 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 puts the armed Danger button and +Cancel in the action row. + 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. @@ -174,6 +208,9 @@ Meeting capsules are pill-shaped. They use the shared buttons with 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. @@ -183,8 +220,8 @@ Meeting capsules are pill-shaped. They use the shared buttons with ## Icons -- Use `Icon` (`ArrowRight`, `ChevronDown`, `Close`, `DragHandle`, `Globe`, - `Plus`) via `icon(Icon, colour)` at `ICON_SIZE_SMALL`, or `sized_icon` for a +- Use `Icon` (`ArrowRight`, `ChevronDown`, `Close`, `DragHandle`, `Ellipsis`, + `Globe`, `Plus`) via `icon(Icon, colour)` at `ICON_SIZE_SMALL`, or `sized_icon` for a different size. 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 @@ -207,8 +244,9 @@ 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, no clipped text without "…", one primary button -per view, and sentence case. +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 diff --git a/docs/features/meetings.md b/docs/features/meetings.md index 73b3bbd6..2c2ff424 100644 --- a/docs/features/meetings.md +++ b/docs/features/meetings.md @@ -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, shown in the action row -> 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 ``` From d5a3b7a732893203ba3ec7d55e75b2dc7f74e562 Mon Sep 17 00:00:00 2001 From: Kit Langton <kit.langton@gmail.com> Date: Sun, 18 Oct 2026 14:56:17 -0700 Subject: [PATCH 10/14] fix(ui): history detail rows, delete confirmation row, side segments - History metadata rows sit directly in the detail body so values keep the full width (they collapsed to one character per line) - An armed meeting delete replaces the action row; actions wrap - Left / Either / Right selector width follows its segment widths - Ellipsis draws one icon size up to match the other glyphs optically --- docs/design-system.md | 7 +-- docs/features/meetings.md | 2 +- src/app_window.rs | 95 +++++++++++++++++++++++++++++---------- src/desktop_ui.rs | 36 ++++++++++++++- 4 files changed, 111 insertions(+), 29 deletions(-) diff --git a/docs/design-system.md b/docs/design-system.md index fdce51ac..372b0e6d 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -188,8 +188,8 @@ ICON_SIZE 14 · ICON_SIZE_SMALL 11 · TOGGLE 28×16 · DISABLED_OPACITY 0.4 | 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 puts the armed Danger button and -Cancel in the action row. +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 @@ -222,7 +222,8 @@ Meeting capsules are pill-shaped. They use the shared buttons with - Use `Icon` (`ArrowRight`, `ChevronDown`, `Close`, `DragHandle`, `Ellipsis`, `Globe`, `Plus`) via `icon(Icon, colour)` at `ICON_SIZE_SMALL`, or `sized_icon` for a - different size. Use `NavigationIcon` for the sidebar. + different size. `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). diff --git a/docs/features/meetings.md b/docs/features/meetings.md index 2c2ff424..3b6083f3 100644 --- a/docs/features/meetings.md +++ b/docs/features/meetings.md @@ -218,7 +218,7 @@ 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… (overflow menu) -> "Trash recording & Markdown" (or "Trash recording") - / Cancel, shown in the action row + / Cancel, which replace the action row until answered -> exported Markdown, then Application Support/meetings/<id>, to the Trash -> refused while recording, transcribing, or writing notes // meetings.delete ``` diff --git a/src/app_window.rs b/src/app_window.rs index b29ac2a6..a48181e0 100644 --- a/src/app_window.rs +++ b/src/app_window.rs @@ -72,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; @@ -2751,6 +2755,7 @@ impl AppWindow { } 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)) }) @@ -2800,26 +2805,19 @@ impl AppWindow { ), ) }) - .child( - div() - .mt_5() - .border_t_1() - .border_color(rgb(LINE)) - .when_some(entry.application.clone(), |detail, application| { - detail.child(detail_row("Application", application)) - }) - .when_some(entry.processing.clone(), |detail, processing| { - let mut summary = format!( - "{} · {} ms", - processing.profile, processing.latency_ms - ); - if let Some(fallback) = &processing.fallback { - summary.push_str(&format!(" · fell back: {fallback}")); - } - detail.child(detail_row("Processing", summary)) - }) - .child(detail_row("Latency", latency)), - ), + .child(div().mt_5().border_t_1().border_color(rgb(LINE))) + .when_some(entry.application.clone(), |detail, application| { + detail.child(detail_row("Application", application)) + }) + .when_some(entry.processing.clone(), |detail, processing| { + let mut summary = + format!("{} · {} ms", processing.profile, processing.latency_ms); + if let Some(fallback) = &processing.fallback { + summary.push_str(&format!(" · fell back: {fallback}")); + } + detail.child(detail_row("Processing", summary)) + }) + .child(detail_row("Latency", latency)), ) .into_any_element() } @@ -3057,7 +3055,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 = [40.0, 50.0, 40.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); @@ -6481,6 +6479,8 @@ impl AppWindow { ); } 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_of( @@ -9058,6 +9058,52 @@ mod tests { ) } + fn history_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::History, + 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, + }), + 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] fn meeting_turns_alternate_sides_inside_the_detail(cx: &mut gpui::TestAppContext) { let (_, cx) = cx.add_window_view(meetings_fixture); @@ -9099,7 +9145,10 @@ mod tests { cx.update(|_, cx| { view.update(cx, |view, _| { view.select_meeting("preview-design".into()); - assert!(!view.meeting_actions_open, "a new selection closes the menu"); + assert!( + !view.meeting_actions_open, + "a new selection closes the menu" + ); }) }); } diff --git a/src/desktop_ui.rs b/src/desktop_ui.rs index 9ee67c8b..1dbfc95d 100644 --- a/src/desktop_ui.rs +++ b/src/desktop_ui.rs @@ -104,6 +104,15 @@ pub(crate) enum Icon { } 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 { @@ -131,9 +140,9 @@ impl Icon { } } -/// An inline icon at [`ICON_SIZE_SMALL`] in `color`. +/// An inline icon at [`ICON_SIZE_SMALL`] (optically matched) in `color`. pub(crate) fn icon(icon: Icon, color: u32) -> AnyElement { - sized_icon(icon, ICON_SIZE_SMALL, color) + sized_icon(icon, icon.optical_size(), color) } #[cfg(target_os = "macos")] @@ -527,6 +536,7 @@ pub(crate) fn detail_header( .debug_selector(|| "detail-actions".into()) .pt_2() .flex() + .flex_wrap() .items_center() .gap_2() .children(actions), @@ -623,6 +633,7 @@ pub(crate) fn detail_row(label: &'static str, value: impl Into<String>) -> AnyEl ) .child( div() + .debug_selector(|| "detail-row-value".into()) .flex_1() .min_w_0() .text_body() @@ -1299,6 +1310,27 @@ mod layout_tests { assert!(width > 180.0 && width < PANE_LIST_WIDTH, "{width}"); } + fn value_width(cx: &mut TestAppContext, 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("detail-row-value").unwrap().size.width) + } + + #[gpui::test] + fn detail_rows_fill_a_detail_body(cx: &mut TestAppContext) { + let width = value_width(cx, || { + 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. From d2c78dec43d1ca415578c47bdf08c0dea26b3ab5 Mon Sep 17 00:00:00 2001 From: Kit Langton <kit.langton@gmail.com> Date: Sun, 18 Oct 2026 15:01:33 -0700 Subject: [PATCH 11/14] fix(ui): detail rows wrap long values inside the detail body --- src/desktop_ui.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/desktop_ui.rs b/src/desktop_ui.rs index 1dbfc95d..516b676b 100644 --- a/src/desktop_ui.rs +++ b/src/desktop_ui.rs @@ -615,7 +615,9 @@ pub(crate) fn settings_panel() -> Div { /// A label and value inside a detail card. pub(crate) fn detail_row(label: &'static str, value: impl Into<String>) -> AnyElement { + // A definite width, so long values wrap instead of sizing the row to them. div() + .w_full() .py_3() .flex() .items_start() From fed84b6791805a5f6b1bbd81e43eff23f27f6afb Mon Sep 17 00:00:00 2001 From: Kit Langton <kit.langton@gmail.com> Date: Sun, 18 Oct 2026 15:06:30 -0700 Subject: [PATCH 12/14] fix(ui): keep the detail body a block so detail rows wrap --- src/app_window.rs | 36 +++++++++++++++++++++++------------- src/desktop_ui.rs | 5 ++--- 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/src/app_window.rs b/src/app_window.rs index a48181e0..8e801ee1 100644 --- a/src/app_window.rs +++ b/src/app_window.rs @@ -2805,19 +2805,29 @@ impl AppWindow { ), ) }) - .child(div().mt_5().border_t_1().border_color(rgb(LINE))) - .when_some(entry.application.clone(), |detail, application| { - detail.child(detail_row("Application", application)) - }) - .when_some(entry.processing.clone(), |detail, processing| { - let mut summary = - format!("{} · {} ms", processing.profile, processing.latency_ms); - if let Some(fallback) = &processing.fallback { - summary.push_str(&format!(" · fell back: {fallback}")); - } - detail.child(detail_row("Processing", summary)) - }) - .child(detail_row("Latency", latency)), + .child( + div() + .w_full() + .mt_5() + .flex() + .flex_col() + .border_t_1() + .border_color(rgb(LINE)) + .when_some(entry.application.clone(), |rows, application| { + rows.child(detail_row("Application", application)) + }) + .when_some(entry.processing.clone(), |rows, processing| { + let mut summary = format!( + "{} · {} ms", + processing.profile, processing.latency_ms + ); + if let Some(fallback) = &processing.fallback { + summary.push_str(&format!(" · fell back: {fallback}")); + } + rows.child(detail_row("Processing", summary)) + }) + .child(detail_row("Latency", latency)), + ), ) .into_any_element() } diff --git a/src/desktop_ui.rs b/src/desktop_ui.rs index 516b676b..0b029470 100644 --- a/src/desktop_ui.rs +++ b/src/desktop_ui.rs @@ -549,14 +549,13 @@ pub(crate) fn detail_title(text: impl Into<SharedString>) -> Div { div().text_headline().child(text.into()) } -/// The scrolling content under a [`detail_header`]. +/// 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)) - .flex() - .flex_col() .overflow_y_scroll() .px_6() .pt_5() From 8ff17c1abd3bb464e2e65c5510b001eb4adeca30 Mon Sep 17 00:00:00 2001 From: Kit Langton <kit.langton@gmail.com> Date: Sun, 18 Oct 2026 15:11:51 -0700 Subject: [PATCH 13/14] fix(ui): detail rows fill their column again --- src/desktop_ui.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/desktop_ui.rs b/src/desktop_ui.rs index 0b029470..ab660dab 100644 --- a/src/desktop_ui.rs +++ b/src/desktop_ui.rs @@ -614,9 +614,7 @@ pub(crate) fn settings_panel() -> Div { /// A label and value inside a detail card. pub(crate) fn detail_row(label: &'static str, value: impl Into<String>) -> AnyElement { - // A definite width, so long values wrap instead of sizing the row to them. div() - .w_full() .py_3() .flex() .items_start() From 4b7c6278c5af78d381469f4e640717506a50db43 Mon Sep 17 00:00:00 2001 From: Kit Langton <kit.langton@gmail.com> Date: Sun, 18 Oct 2026 16:18:16 -0700 Subject: [PATCH 14/14] refactor(ui): simplify design tokens, shared components, and meeting error banners --- docs/design-system.md | 8 +- src/app_window.rs | 667 ++++++++++++++++++----------------------- src/design_tokens.rs | 15 +- src/desktop_ui.rs | 43 ++- src/linux_app.rs | 12 +- src/meeting_watcher.rs | 64 ++-- 6 files changed, 364 insertions(+), 445 deletions(-) diff --git a/docs/design-system.md b/docs/design-system.md index 372b0e6d..3f7c68f2 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -165,7 +165,7 @@ ICON_SIZE 14 · ICON_SIZE_SMALL 11 · TOGGLE 28×16 · DISABLED_OPACITY 0.4 | 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` → `settings_panel` → `settings_row(title, description, control)` | +| 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, …)` | @@ -221,9 +221,9 @@ Meeting capsules are pill-shaped. They use the shared buttons with ## Icons - Use `Icon` (`ArrowRight`, `ChevronDown`, `Close`, `DragHandle`, `Ellipsis`, - `Globe`, `Plus`) via `icon(Icon, colour)` at `ICON_SIZE_SMALL`, or `sized_icon` for a - different size. `Ellipsis` draws at `ICON_SIZE` so its dots match the other - glyphs optically. Use `NavigationIcon` for the sidebar. + `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). diff --git a/src/app_window.rs b/src/app_window.rs index 8e801ee1..4126475e 100644 --- a/src/app_window.rs +++ b/src/app_window.rs @@ -37,9 +37,9 @@ use crate::desktop_ui::{ 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_panel, settings_row, settings_section_label, - sidebar_frame, sliding_segmented_control, sliding_segmented_item, small_button, status_dot, - tag, toggle, truncated_line, window_frame, + 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}; @@ -132,23 +132,10 @@ fn microphone_picker_menu( choose(&device, window, cx); }) })) - .when_some(error, |picker, error| { - picker.child( - div() - .px_2() - .py_1() - .text_caption() - .text_color(rgb(DANGER)) - .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() @@ -3477,26 +3464,22 @@ impl AppWindow { 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 = 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()) - }, - )), - ) + .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(), ) } @@ -3792,202 +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(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 }), - ) - .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( - button(ButtonKind::Secondary, "Clear") - .id("disable-paste-last-hotkey") - .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(inline_error(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"), + ), + ), ) } @@ -4001,7 +3986,7 @@ 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 { button(ButtonKind::Secondary, "Open settings") .id("meeting-screen-recording") @@ -4014,7 +3999,7 @@ impl AppWindow { })) .into_any_element() }; - settings_panel() + panel() .child( settings_row( "Meetings (beta)", @@ -4151,7 +4136,7 @@ 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::Ready => tag(Tone::Success, "Ready").into_any_element(), PermissionState::NeedsRequest => button(ButtonKind::Secondary, "Allow") .id("setup-microphone") .on_click(cx.listener(|_this, _, _, cx| { @@ -4224,7 +4209,7 @@ 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 { button(ButtonKind::Primary, "Choose model") .id("setup-models") @@ -4280,7 +4265,7 @@ impl AppWindow { .flex_col() .gap_2() .child(section_label("Permissions needed").px_4()) - .child(settings_panel().children(permission_rows)), + .child(panel().children(permission_rows)), ) }) .child( @@ -4290,7 +4275,7 @@ impl AppWindow { .gap_2() .child(section_label("Local models").px_4()) .child( - settings_panel().child( + panel().child( settings_row( if let Some((title, _)) = model_notice { title @@ -4369,7 +4354,7 @@ impl AppWindow { ) .mt_4(); Some( - settings_panel() + panel() .p_4() .child(settings_copy(copy.title, copy.description)) .when_some(copy.error, |card, error| { @@ -4381,11 +4366,11 @@ impl AppWindow { } else { Some(self.render_voice_action_processing(window, cx)) }; - settings_pane_titled( + settings_pane( "Voice Action", "voice-action-scroll", div() - .child(settings_panel().mt_6().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| { @@ -4393,7 +4378,7 @@ impl AppWindow { }) .child(settings_section_label("Capture")) .child( - settings_panel().child( + panel().child( settings_row( "Shortcut", if enabled { @@ -5110,7 +5095,7 @@ impl AppWindow { .w_full() .flex() .flex_col() - .gap(px(SECTION_GAP)) + .gap_2() .child(basics) .child(section_label("Text processing").pt_2().px_4()) .child(corrections) @@ -5177,7 +5162,7 @@ impl AppWindow { .child( div() .id(("drag-mode-transformation", target_index)) - .w(px(SPACE_5)) + .w_5() .h_full() .flex_none() .flex() @@ -5915,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()), @@ -6275,29 +6260,25 @@ 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(px(PANE_GUTTER)).child( - div() - .w_full() - .max_w(px(PANE_CONTENT_WIDTH)) - .py_3() - .border_b_1() - .border_color(rgb(LINE)) - .text_footnote() - .text_color(rgb(DANGER)) - .child(error), - ), - ) - }) .child( pane_body().child( pane_content() .id("meetings-workspace") - .flex_row() - .gap_5() - .child(list) - .child(pane_detail(self.render_meeting_detail(cx))), + .gap_3() + .when_some(self.meeting_runtime_error.clone(), |column, error| { + column.child( + error_panel("Meeting could not be recorded.", error).flex_none(), + ) + }) + .child( + div() + .flex_1() + .min_h(px(0.0)) + .flex() + .gap_5() + .child(list) + .child(pane_detail(self.render_meeting_detail(cx))), + ), ), ) .into_any_element() @@ -6549,26 +6530,30 @@ 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(|| { @@ -6661,19 +6646,7 @@ impl AppWindow { .child( detail_body("meeting-transcript") .track_scroll(&self.meeting_transcript_scroll) - .children(errors.into_iter().map(|error| { - div() - .mb_4() - .px_3() - .py_2() - .rounded(RADIUS_CONTROL) - .border_1() - .border_color(rgb(DANGER_BORDER)) - .bg(rgb(DANGER_SURFACE)) - .text_footnote() - .text_color(rgb(DANGER)) - .child(error) - })) + .children(errors.map(|(message, error)| error_panel(message, error).mb_4())) .when_some(notes, |content, notes| content.child(notes)) .child( div() @@ -6796,7 +6769,7 @@ impl AppWindow { .gap_2() .child(config_control) .when_some(copy_prompt, |controls, prompt| controls.child(prompt)) - .child(div().mx_1().w(px(1.0)).h(px(SPACE_5)).bg(rgb(LINE))) + .child(div().mx_1().w(px(1.0)).h_5().bg(rgb(LINE))) .child( div() .id("commands-enabled") @@ -7908,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 } } @@ -8331,10 +8304,6 @@ fn command_model_notice(status: &CommandModelStatus) -> Option<(&'static str, &' } } -fn setup_ready_badge() -> AnyElement { - tag(Tone::Success, "Ready").into_any_element() -} - const fn permission_warning_copy(kind: PermissionKind) -> (&'static str, &'static str) { match kind { PermissionKind::Microphone => ( @@ -8996,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(), @@ -9004,104 +8977,58 @@ 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, - open_meeting_actions: 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, - open_meeting_actions: 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 { - 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::History, - 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, - }), - window, - cx, - ) + preview_fixture(pane_preview(DeveloperPane::History), window, cx) } #[gpui::test] @@ -9559,8 +9486,10 @@ mod tests { .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"), diff --git a/src/design_tokens.rs b/src/design_tokens.rs index 1ac3c33a..8e445e54 100644 --- a/src/design_tokens.rs +++ b/src/design_tokens.rs @@ -138,13 +138,10 @@ pub(crate) trait Typography: Styled + Sized { impl<E: Styled> Typography for E {} -// Spacing: a 4 px grid. Use GPUI's spacing utilities on these steps only: -// `*_1` 4, `*_2` 8, `*_3` 12, `*_4` 16, `*_5` 20, `*_6` 24, `*_8` 32. -pub(crate) const SPACE_1: f32 = 4.0; +// 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; -pub(crate) const SPACE_3: f32 = 12.0; -pub(crate) const SPACE_5: f32 = 20.0; -pub(crate) const SPACE_8: f32 = 32.0; // Radii. pub(crate) const RADIUS_SMALL: Pixels = px(4.0); @@ -161,11 +158,12 @@ pub(crate) fn icon_radius(size: f32) -> Pixels { // 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 = SPACE_1; +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; @@ -186,7 +184,7 @@ 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 = SPACE_8; +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. @@ -199,7 +197,6 @@ pub(crate) const MODAL_HEIGHT: f32 = 520.0; 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; -pub(crate) const SECTION_GAP: f32 = SPACE_2; #[cfg(test)] mod tests { diff --git a/src/desktop_ui.rs b/src/desktop_ui.rs index ab660dab..193805a0 100644 --- a/src/desktop_ui.rs +++ b/src/desktop_ui.rs @@ -141,12 +141,9 @@ impl Icon { } /// An inline icon at [`ICON_SIZE_SMALL`] (optically matched) in `color`. -pub(crate) fn icon(icon: Icon, color: u32) -> AnyElement { - sized_icon(icon, icon.optical_size(), color) -} - #[cfg(target_os = "macos")] -pub(crate) fn sized_icon(icon: Icon, size: f32, color: u32) -> AnyElement { +pub(crate) fn icon(icon: Icon, color: u32) -> AnyElement { + let size = icon.optical_size(); div() .size(px(size)) .flex_none() @@ -163,8 +160,10 @@ pub(crate) fn sized_icon(icon: Icon, size: f32, color: u32) -> AnyElement { .into_any_element() } +/// An inline icon at [`ICON_SIZE_SMALL`] (optically matched) in `color`. #[cfg(target_os = "linux")] -pub(crate) fn sized_icon(icon: Icon, size: f32, color: u32) -> AnyElement { +pub(crate) fn icon(icon: Icon, color: u32) -> AnyElement { + let size = icon.optical_size(); div() .size(px(size)) .flex_none() @@ -377,7 +376,8 @@ pub(crate) fn pane_detail(content: impl IntoElement) -> Div { pub(crate) fn list_section_label(label: impl IntoElement, first: bool) -> Div { section_label(label) .px_3() - .pt(px(if first { SPACE_1 } else { SPACE_3 })) + .when(first, |label| label.pt_1()) + .when(!first, |label| label.pt_3()) .pb_1() } @@ -458,6 +458,7 @@ fn fit_line(text: SharedString, width: Pixels, window: &mut Window, cx: &mut App pub(crate) fn panel() -> Div { div() .w_full() + .relative() .rounded(RADIUS_PANEL) .border_1() .border_color(rgb(LINE)) @@ -608,10 +609,6 @@ pub(crate) fn settings_row( .child(control) } -pub(crate) fn settings_panel() -> Div { - panel().relative() -} - /// A label and value inside a detail card. pub(crate) fn detail_row(label: &'static str, value: impl Into<String>) -> AnyElement { div() @@ -720,9 +717,6 @@ pub(crate) fn hotkey_keycaps(parts: Vec<String>, opacity: f32) -> AnyElement { .into_any_element() } -/// The minimum width of one keycap in [`hotkey_keycaps`]. -pub(crate) const KEYCAP_WIDTH: f32 = 34.0; - fn split_sided_keycap(part: &str) -> (Option<&'static str>, String) { match part { "L⌃" => (Some("L"), "⌃".into()), @@ -1266,11 +1260,19 @@ mod layout_tests { } } - fn line_width(cx: &mut TestAppContext, build: fn() -> AnyElement) -> f32 { + 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("line").unwrap().size.width) + 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 { @@ -1309,16 +1311,9 @@ mod layout_tests { assert!(width > 180.0 && width < PANE_LIST_WIDTH, "{width}"); } - fn value_width(cx: &mut TestAppContext, 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("detail-row-value").unwrap().size.width) - } - #[gpui::test] fn detail_rows_fill_a_detail_body(cx: &mut TestAppContext) { - let width = value_width(cx, || { + let width = measured_width(cx, "detail-row-value", || { div() .w(px(600.0)) .h(px(300.0)) diff --git a/src/linux_app.rs b/src/linux_app.rs index 515ae8e6..77877542 100644 --- a/src/linux_app.rs +++ b/src/linux_app.rs @@ -24,7 +24,7 @@ use crate::desktop_transcription_picker::{ }; use crate::desktop_ui::{ ButtonKind, NavigationIcon, button, disclosure_button, hotkey_keycaps, navigation_item, - pane_content, pane_header, pane_scroll_body, segmented_control, segmented_item, settings_panel, + 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; @@ -1156,7 +1156,7 @@ impl LinuxApp { pane_content() .relative() .child(settings_section_label("Listener")) - .child(settings_panel().child(settings_row( + .child(panel().child(settings_row( "Dictation", "Global hotkey dictation", div().flex().items_center().gap_3() @@ -1177,7 +1177,7 @@ impl LinuxApp { ))) .when_some(snapshot.listener.warning.clone(), |content, warning| { content.child( - div().id("linux-listener-warning").mt_3().p_3().rounded(RADIUS_PANEL).border_1().border_color(rgb(LINE)).bg(rgb(SURFACE)) + 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.")), @@ -1207,7 +1207,7 @@ impl LinuxApp { }) .child(settings_section_label("Dictation")) .child( - settings_panel() + panel() .child(settings_row( "Local transcription", "Language and on-device speech model", @@ -1259,7 +1259,7 @@ impl LinuxApp { ) .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", @@ -1301,7 +1301,7 @@ impl LinuxApp { ) .child(settings_section_label("Application")) .child( - settings_panel() + panel() .child(settings_row( "Hex", "Private local dictation for Linux", diff --git a/src/meeting_watcher.rs b/src/meeting_watcher.rs index 610c543b..83a7e8b9 100644 --- a/src/meeting_watcher.rs +++ b/src/meeting_watcher.rs @@ -1293,20 +1293,30 @@ impl Render for MeetingPanel { 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 } => { @@ -1318,14 +1328,16 @@ impl Render for MeetingPanel { div() .flex() .gap_2() - .child(secondary_button( + .child(capsule_button( + ButtonKind::Secondary, "keep-recording", "Keep recording", |window, _| { window.remove_window(); }, )) - .child(primary_button( + .child(capsule_button( + ButtonKind::Primary, "stop-meeting", "Stop & transcribe", move |window, _| { @@ -1373,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) { @@ -1391,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, _| { @@ -1437,22 +1451,6 @@ impl Render for MeetingPanel { } /// Capsule buttons are the shared buttons in the capsule's pill shape. -fn primary_button( - id: &'static str, - label: &'static str, - on_click: impl Fn(&mut Window, &mut App) + 'static, -) -> impl IntoElement { - capsule_button(ButtonKind::Primary, id, label, on_click) -} - -fn secondary_button( - id: &'static str, - label: &'static str, - on_click: impl Fn(&mut Window, &mut App) + 'static, -) -> impl IntoElement { - capsule_button(ButtonKind::Secondary, id, label, on_click) -} - fn capsule_button( kind: ButtonKind, id: &'static str,