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.
This commit is contained in:
l0ng-ai
2026-09-24 20:43:51 +08:00
parent bcda518168
commit 6fb4bb2fe7
+232 -63
View File
@@ -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<IndexPath>,
/// 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<ListState<Self>>,
) -> 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<ListState<Self>>,
) -> Option<impl IntoElement> {
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<ListState<Self>>,
) -> Option<Self::Item> {
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<PaletteEvent> 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<gpui::AnyElement>, 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::<crate::ui::presets::Surfaces>().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::*;