mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-10-05 08:02:13 +00:00
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:
+232
-63
@@ -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::*;
|
||||
|
||||
Reference in New Issue
Block a user