From 6fb4bb2fe76ebc88aac0e8e3be12adcb3d827862 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:43:51 +0800 Subject: [PATCH] feat(palette): restyle the command palette after the v4 design - Card: the switcher's 12px corner, 112px drop from the top (shorter windows still scale it up), 600px max width. - Search row keeps the list's own field; an esc keycap sits in its trailing corner while the field is empty. - Rows are 32px with an 8px corner, 8px list inset and 10px padding. The keyboard row takes the popover's neutral selected step and a medium title instead of the accent wash, via a palette row element in place of ListItem. - Section headings: 28px, 11.5/16rem medium caption ink, on the rows' text column. Shortcuts are per-key 18px faint keycaps from ui::dialog. - New 40px footer with the switcher's keycap hints (navigate, open). - Empty state: headline in body ink, hint in caption ink. --- src/ui/palette.rs | 295 ++++++++++++++++++++++++++++++++++++---------- 1 file changed, 232 insertions(+), 63 deletions(-) diff --git a/src/ui/palette.rs b/src/ui/palette.rs index a673e772..60536795 100644 --- a/src/ui/palette.rs +++ b/src/ui/palette.rs @@ -1,10 +1,10 @@ use gpui::{ - App, Context, Entity, EventEmitter, MouseButton, MouseDownEvent, SharedString, Subscription, - Task, Window, div, prelude::*, px, + App, Context, Entity, EventEmitter, FontWeight, MouseButton, MouseDownEvent, SharedString, + Subscription, Task, Window, div, prelude::*, px, rems, }; use gpui_component::{ - ActiveTheme as _, IndexPath, h_flex, - list::{List, ListDelegate, ListEvent, ListItem, ListState}, + ActiveTheme as _, IndexPath, Selectable, h_flex, + list::{List, ListDelegate, ListEvent, ListState}, v_flex, }; @@ -12,6 +12,7 @@ use uuid::Uuid; use crate::core::config::{Config, RightPanelTab, TabBarPosition}; use crate::core::ssh_profile::parse_quick_connect; +use crate::ui::dialog::{CARD_RADIUS, FOOTER_H, KEYCAP, keycap}; use crate::ui::i18n::{L10nKey, alias_translations, t, t_fmt}; #[derive(Clone, PartialEq, Eq)] @@ -786,6 +787,10 @@ pub struct PaletteDelegate { /// and an empty query falls back to the grouped command list. quick_connect_root: bool, selected: Option, + /// Whether the search field holds anything. Only the chrome reads it: the + /// field's own clear button takes the trailing corner the `esc` keycap + /// sits in while it is empty. + typed: bool, } #[derive(Clone, Copy, PartialEq, Eq)] @@ -804,6 +809,7 @@ impl PaletteDelegate { input: None, quick_connect_root: false, selected: Some(IndexPath::default()), + typed: false, } } @@ -894,6 +900,7 @@ impl PaletteDelegate { input: Some(PaletteInput::SshConnect), quick_connect_root: false, selected: Some(IndexPath::default()), + typed: false, } } @@ -916,7 +923,7 @@ impl PaletteDelegate { } impl ListDelegate for PaletteDelegate { - type Item = ListItem; + type Item = PaletteRow; fn sections_count(&self, _cx: &App) -> usize { self.sections.len().max(1) @@ -935,6 +942,7 @@ impl ListDelegate for PaletteDelegate { window: &mut Window, cx: &mut Context>, ) -> Task<()> { + self.typed = !query.is_empty(); if let Some(PaletteInput::SshConnect) = self.input { self.sections = vec![Section { title: None, @@ -997,12 +1005,15 @@ impl ListDelegate for PaletteDelegate { cx: &mut Context>, ) -> Option { let title = self.sections.get(section)?.title.clone()?; + // The sidebar's group heading: medium, a half step under the rows, in + // caption ink — never capitals, never a rule under it. Some( h_flex() - .h(px(PALETTE_ROW_H)) - .px(px(PALETTE_LABEL_INSET)) + .h(px(HEADER_H)) + .px(px(LABEL_INSET)) .items_center() - .text_xs() + .text_size(rems(crate::ui::right_panel::HEADING)) + .font_weight(FontWeight::MEDIUM) .text_color(cx.theme().muted_foreground) .child(title), ) @@ -1021,16 +1032,25 @@ impl ListDelegate for PaletteDelegate { } else { t(crate::ui::i18n::L10nKey::PaletteTryDifferentSearch) }; + // The headline in body ink and the way out under it in caption ink: + // two greys of the same size read as one sentence cut in half. + let theme = cx.theme(); v_flex() - .py_8() - .gap_1() + .py(px(32.)) + .px(px(LABEL_INSET)) + .gap(px(4.)) .items_center() - .text_sm() - .text_color(cx.theme().muted_foreground) + .text_size(rems(ROW_TEXT)) + .text_color(theme.foreground) .child(crate::ui::i18n::t( crate::ui::i18n::L10nKey::NoMatchingCommands, )) - .child(div().text_xs().child(hint)) + .child( + div() + .text_size(rems(ROW_META)) + .text_color(theme.muted_foreground) + .child(hint), + ) } fn render_item( @@ -1040,10 +1060,10 @@ impl ListDelegate for PaletteDelegate { cx: &mut Context>, ) -> Option { let cmd = self.sections.get(ix.section)?.commands.get(ix.row)?.clone(); - - let (kbd_bg, border, muted) = { + let picked = Some(ix) == self.selected; + let (fg, muted) = { let t = cx.theme(); - (t.secondary.opacity(0.6), t.border, t.muted_foreground) + (t.foreground, t.muted_foreground) }; let keys = cmd @@ -1051,55 +1071,68 @@ impl ListDelegate for PaletteDelegate { .key_spec(cx) .map(|spec| crate::ui::keymap::key_tokens(&spec)); - let mut left = h_flex().items_center().gap_2().child(cmd.title.clone()); + // The title holds its width longest; the subtitle beside it is what + // truncates first. + let mut left = h_flex() + .flex_1() + .min_w_0() + .items_center() + .gap(px(8.)) + .child( + div() + .flex_shrink_0() + .max_w_full() + .truncate() + .text_color(fg) + .when(picked, |d| d.font_weight(FontWeight::MEDIUM)) + .child(cmd.title.clone()), + ); if let Some(subtitle) = cmd.subtitle.clone() { - left = left.child(div().text_xs().text_color(muted).child(subtitle)); + left = left.child( + div() + .min_w_0() + .truncate() + .text_size(rems(ROW_META)) + .text_color(muted) + .child(subtitle), + ); } let mut row = h_flex() .w_full() .items_center() .justify_between() + .gap(px(12.)) .child(left); if cmd.kind.edit_variant().is_some() { row = row.child( h_flex() + .flex_shrink_0() .items_center() - .gap_1() - .text_xs() + .gap(px(6.)) + .text_size(rems(ROW_META)) .text_color(muted) .child(crate::ui::i18n::t(crate::ui::i18n::L10nKey::EditHint)) - .child(crate::ui::keymap::key_tokens(EDIT_GESTURE).join("")), + .child(keycap( + crate::ui::keymap::key_tokens(EDIT_GESTURE).join(""), + cx, + )), ); } if let Some(tokens) = keys { - row = row.child(h_flex().gap_1().children(tokens.into_iter().map(move |t| { - div() - .flex() - .items_center() - .justify_center() - .min_w(px(20.)) - .h(px(20.)) - .px_1() - .rounded_md() - .bg(kbd_bg) - .border_1() - .border_color(border) - .text_xs() - .text_color(muted) - .child(t) - }))); + row = row.child( + h_flex() + .flex_shrink_0() + .gap(px(3.)) + .children(tokens.into_iter().map(|t| keycap(t, cx))), + ); } - Some( - ListItem::new(("palette-row", ix.section * 1000 + ix.row)) - .selected(Some(ix) == self.selected) - .h(px(PALETTE_ROW_H)) - .mx(px(PALETTE_ROW_MX)) - .rounded(crate::ui::rounding::ROW_RADIUS) - .text_size(gpui::rems(13. / 16.)) - .child(row), - ) + Some(PaletteRow { + id: ("palette-row", ix.section * 1000 + ix.row).into(), + selected: picked, + child: row.into_any_element(), + }) } fn set_selected_index( @@ -1327,14 +1360,48 @@ impl PaletteView { impl EventEmitter for PaletteView {} -const PALETTE_ROW_H: f32 = 34.; +// The palette is the workspace switcher's sibling (see `switcher.rs`): the +// same card, the same corner, the same keycaps and footer, and the same +// neutral step for the row the keyboard is on. The numbers below are the ones +// the switcher uses wherever the two overlays show the same thing, so opening +// one after the other does not move the chrome. -/// Left inset of a row's *label*, so a section header can start on the same -/// pixel column as the rows it introduces. A row is a `ListItem` inset by -/// `PALETTE_ROW_MX` whose own padding is `px_3`; a header has neither, so it -/// has to carry the sum itself. -const PALETTE_ROW_MX: f32 = 5.; -const PALETTE_LABEL_INSET: f32 = PALETTE_ROW_MX + 12.; +/// How tall a command row is. One line where the switcher's rows carry two; +/// 32 keeps an 18px keycap clear of the row's edges by the same 7px the +/// switcher's footer gives its own. +const ROW_H: f32 = 32.; + +/// A section heading: the right panel's 28px heading row. +const HEADER_H: f32 = 28.; + +/// How far the list sits in from the card's edges, and how far a row's text +/// then sits in from its own fill — the switcher's `COLUMN_PAD` and +/// `ROW_PAD`. +const LIST_PAD: f32 = 8.; +const ROW_PAD: f32 = 10.; + +/// Where a row's label starts, so a section heading lands on the same column +/// as the rows under it. A heading has neither the row's inset nor its +/// padding, so it carries the sum. +const LABEL_INSET: f32 = LIST_PAD + ROW_PAD; + +/// The row fill's corner — the switcher's `LIST_RADIUS`. +const ROW_RADIUS: f32 = 8.; + +/// The card's breathing room from the window's bottom edge, and its width. +/// Its corner and its footer are `ui::dialog`'s, the switcher's numbers. +const CARD_MARGIN: f32 = 24.; +const CARD_MAX_W: f32 = 600.; + +/// The search row gpui-component's `List` draws above the rows: a 32px field +/// with 6px above and below and a 1px rule. Three short of the switcher's 48 — +/// the row belongs to the list, and the list does not take a height for it. +const SEARCH_H: f32 = 32. + 6. * 2. + 1.; + +/// A command title, and the subtitle, hints and keycaps beside it: the +/// switcher's row name and its metadata line. +const ROW_TEXT: f32 = 13. / 16.; +const ROW_META: f32 = 11.5 / 16.; /// The chord that opens the selected row for editing instead of running it. /// @@ -1366,23 +1433,45 @@ impl Render for PaletteView { let scrim = crate::ui::presets::scrim_fill(cx); let viewport = window.viewport_size(); - let top = (viewport.height.as_f32() * 0.16).clamp(16., 120.); - // Reserve space for the search field and the card's padding, including - // when a split-screen window is shorter than the full command list. - let list_max_h = px((viewport.height.as_f32() - top - 88.) - .max(PALETTE_ROW_H) - .min(PALETTE_ROW_H * PALETTE_VISIBLE_ROWS + 4.)); + // The switcher's drop from the top on a full-size window; a short one + // still brings the card up to keep its rows. + let top = (viewport.height.as_f32() * 0.16).clamp(16., crate::ui::switcher::CARD_TOP); + // Reserve space for the search row, the footer and the card's margin, + // including when a split-screen window is shorter than the full + // command list. + let list_max_h = px( + (viewport.height.as_f32() - top - SEARCH_H - FOOTER_H - CARD_MARGIN) + .max(ROW_H) + .min(ROW_H * PALETTE_VISIBLE_ROWS + LIST_PAD * 2.), + ); + let typed = self.list.read(cx).delegate().typed; let card = v_flex() - .w(px((viewport.width.as_f32() - 32.).clamp(0., 600.))) + .relative() + .w(px((viewport.width.as_f32() - 32.).clamp(0., CARD_MAX_W))) .map(|panel| crate::ui::theme::floating_surface(panel, cx)) + .rounded(px(CARD_RADIUS)) .overflow_hidden() - .pb_1() .child( List::new(&self.list) .search_placeholder(self.search_placeholder()) - .py_1() + .py(px(LIST_PAD)) .max_h(list_max_h), - ); + ) + // The switcher's `esc` cap in the search row's trailing corner. + // Laid over the row rather than inside it — the row is the list's + // own — and only while the field is empty, since typing brings up + // the field's clear button in the same spot. No handlers, so a + // click on it still reaches the field under it. + .when(!typed, |card| { + card.child( + div() + .absolute() + .top(px((SEARCH_H - 1. - KEYCAP) / 2.)) + .right(px(14.)) + .child(keycap("esc", cx)), + ) + }) + .child(self.render_footer(cx)); div() .absolute() @@ -1412,6 +1501,86 @@ impl Render for PaletteView { } } +impl PaletteView { + /// The switcher's footer, minus its New workspace button: the keys that + /// move and run, as caps with a word after each. + fn render_footer(&self, cx: &App) -> impl IntoElement + use<> { + let theme = cx.theme(); + let hint = |keys: Vec, label: &'static str| { + h_flex() + .items_center() + .gap(px(6.)) + .children(keys) + .child(label) + }; + h_flex() + .flex_none() + .items_center() + .justify_end() + .gap(px(16.)) + .h(px(FOOTER_H)) + .px(px(14.)) + .border_t_1() + .border_color(theme.border) + .overflow_hidden() + .text_size(rems(ROW_META)) + .text_color(theme.muted_foreground) + .child(hint( + vec![keycap("↑", cx), keycap("↓", cx)], + t(L10nKey::SwitcherHintNavigate), + )) + .child(hint(vec![keycap("↵", cx)], t(L10nKey::SwitcherHintOpen))) + } +} + +/// A palette row. +/// +/// Its own element rather than gpui-component's `ListItem`, which paints the +/// keyboard row in the theme's `list_active` — the accent wash menus use. The +/// palette's keyboard row is where the cursor is, not an action, so it takes +/// the popover's neutral selected step the switcher's rows do. The list moves +/// the selection under the pointer, so hover and selection are one bar; the +/// hover step here only shows while a row is under the pointer and the +/// selection has not caught up with it. +#[derive(IntoElement)] +pub struct PaletteRow { + id: gpui::ElementId, + selected: bool, + child: gpui::AnyElement, +} + +impl Selectable for PaletteRow { + fn selected(mut self, selected: bool) -> Self { + self.selected = selected; + self + } + + fn is_selected(&self) -> bool { + self.selected + } +} + +impl RenderOnce for PaletteRow { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let sf = cx.global::().popover; + let (hover, picked) = (gpui::rgb(sf.hover), gpui::rgb(sf.selected)); + h_flex() + .id(self.id) + .items_center() + .h(px(ROW_H)) + .mx(px(LIST_PAD)) + .px(px(ROW_PAD)) + .rounded(px(ROW_RADIUS)) + .overflow_hidden() + .cursor_pointer() + .text_size(rems(ROW_TEXT)) + .text_color(cx.theme().foreground) + .when(self.selected, |r| r.bg(picked)) + .when(!self.selected, |r| r.hover(move |s| s.bg(hover))) + .child(self.child) + } +} + #[cfg(test)] mod tests { use super::*;