diff --git a/docs/design-system.md b/docs/design-system.md index 6e7a5424..2227b62c 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -105,6 +105,16 @@ Git 的新增/修改标记种子为绿 `#2F8A52` / `#4CC27A`、琥珀 `#B7791F` 「机器 · 路径 · 时间」次级文字,右侧标签数 + 状态词。右栏为预览:28px 标题行, 44px 标签页行(18px 品牌头像、分支与增删数全部次级、「当前」字样、工作中 5px 绿点 与侧栏同步闪烁)。当前行只用中性 selected 阶,不用强调色。 +- **对话框**(SSH 认证/主机密钥、新建 worktree):`ui::dialog` 统一外壳,同切换器的 12px 圆角、 + 浮层阴影与遮罩、距顶 112px;标题行 48px(正文字号 medium,右侧 `esc` 键帽,下接细线), + 内容区左右 18px、字段间距 14px;字段名 11.5/16rem medium 辅助色,输入框为无边框 28px + 淡底井(`muted`、圆角 7)。页脚 40px 细线分隔,按钮 28px / 圆角 6、右对齐、主操作在最右: + 主按钮为反色中性(同提交按钮),次按钮透明、hover 取所在表面的 hover 阶;不可用时实心按钮 + 退回淡底 + 辅助色且不响应点击。主机名与指纹放在淡底信息井里用等宽字。唯一的红色按钮是 + 「主机密钥已变更」的 Override,该卡片也是唯一带红色描边的对话框。 +- **浮动提示条**:边缘保持中性细线,严重程度只由行首 6px 状态点表达(警告琥珀、断开红)。 +- **首页**:快捷操作列表 340px 宽,行高 28px、圆角 7,静止无底、hover 取窗口 hover 阶; + 快捷键用 18px 淡底键帽。 - **终端**:ANSI 是用户内容,不拿来充当 UI 错误/成功配色;应用设计调整不得反转状态含义。 ## 验证要求 diff --git a/src/ui/dialog.rs b/src/ui/dialog.rs new file mode 100644 index 00000000..3bb2596b --- /dev/null +++ b/src/ui/dialog.rs @@ -0,0 +1,249 @@ +//! Chrome for the small modal cards the app draws itself — the SSH sheet, the +//! worktree prompt — and the buttons and key hints the home page shares with +//! them. +//! +//! The workspace switcher is the reference: the same 12px card, 48px header +//! row with an `esc` cap, hairline-divided footer and faint keycaps. Each of +//! these cards used to spell its own padding, title weight and button variants +//! out a builder call at a time, and they had drifted — a 20px inset here, a +//! semibold title there, the accent-filled primary everywhere. One place now, +//! so two sheets on screen at once cannot disagree about what a dialog is. + +use gpui::{ + App, ClickEvent, Div, ElementId, Hsla, SharedString, Stateful, Window, div, prelude::*, px, + rems, +}; +use gpui_component::input::Input; +use gpui_component::{ActiveTheme as _, Sizable as _, h_flex, v_flex}; + +use crate::ui::presets::Surface; +use crate::ui::right_panel::{HEADING, TAB_TEXT, TEXT}; + +/// The card's corner — the switcher's, a notch rounder than a menu: a dialog +/// is the one thing on screen while it is up. +pub(crate) const CARD_RADIUS: f32 = 12.; + +/// The title row, the height of the switcher's search row, so a prompt that +/// opens where the switcher just closed puts its first line in the same place. +pub(crate) const HEADER_H: f32 = 48.; + +/// The button row. A 28px button in it leaves 6px above and below, the same +/// fit as the switcher's footer and its New workspace button. +pub(crate) const FOOTER_H: f32 = 40.; + +/// How far the title and the fields sit in from the card's edge — the +/// switcher's search-row inset, so the text columns of the two line up. +pub(crate) const INSET: f32 = 18.; + +/// A text field: a borderless well on the faint fill, the sidebar search's +/// shape. The field's own `.small()` padding (8px) is what sets the text in. +pub(crate) const FIELD_H: f32 = 28.; + +/// Space between a field's label and its well, and between one field and the +/// next. Four-pixel grid: tight inside a pair, a step wider between pairs. +const LABEL_GAP: f32 = 6.; +const FIELD_GAP: f32 = 14.; + +/// A button: the field's height, a pixel less rounded so a button beside a +/// field reads as a different kind of thing. +pub(crate) const BUTTON_H: f32 = 28.; +const BUTTON_RADIUS: f32 = 6.; + +/// A key named in a hint: an 18px cap on the faint fill. +pub(crate) const KEYCAP: f32 = 18.; + +/// The ladder a dialog's hovers are taken from. Cards sit on the popover +/// surface; the home page passes the window's own. +pub(crate) fn popover_rungs(cx: &App) -> Surface { + cx.global::().popover +} + +/// The card itself, opaque and occluding. Callers add the header, body and +/// footer as children, in that order. +pub(crate) fn card(width: f32, cx: &App) -> Div { + v_flex() + .occlude() + .w(px(width)) + .map(|panel| crate::ui::theme::floating_surface(panel, cx)) + .rounded(px(CARD_RADIUS)) + .overflow_hidden() +} + +/// The title row: the title at body size in medium, and the `esc` cap on the +/// right that says how to back out. Every card here answers Escape. +pub(crate) fn header(title: impl IntoElement, cx: &App) -> Div { + h_flex() + .flex_none() + .items_center() + .gap(px(10.)) + .h(px(HEADER_H)) + .pl(px(INSET)) + .pr(px(14.)) + .border_b_1() + .border_color(cx.theme().border) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_size(rems(TEXT)) + .font_weight(gpui::FontWeight::MEDIUM) + .child(title), + ) + .child(keycap("esc", cx)) +} + +/// The card's content column. +pub(crate) fn body() -> Div { + v_flex() + .gap(px(FIELD_GAP)) + .px(px(INSET)) + .pt(px(16.)) + .pb(px(18.)) +} + +/// The button row: answers on the right, the action last, over a hairline. +pub(crate) fn footer(cx: &App) -> Div { + h_flex() + .flex_none() + .items_center() + .justify_end() + .gap(px(6.)) + .h(px(FOOTER_H)) + .px(px(6.)) + .border_t_1() + .border_color(cx.theme().border) +} + +/// A field's name: the compact section-heading step, medium, secondary ink. +pub(crate) fn label(text: impl IntoElement, cx: &App) -> Div { + div() + .text_size(rems(HEADING)) + .font_weight(gpui::FontWeight::MEDIUM) + .text_color(cx.theme().muted_foreground) + .child(text) +} + +/// A text field in its well. The input loses its own border and fill — the +/// well is the shape — and keeps its padding, which is the text's inset. +pub(crate) fn field(input: Input, cx: &App) -> Div { + h_flex() + .items_center() + .h(px(FIELD_H)) + .rounded(crate::ui::rounding::ROW_RADIUS) + .bg(cx.theme().muted) + .child( + div() + .flex_1() + .min_w_0() + .child(input.appearance(false).small()), + ) +} + +/// A label over its field. +pub(crate) fn labelled(name: impl IntoElement, input: Input, cx: &App) -> Div { + v_flex() + .gap(px(LABEL_GAP)) + .child(label(name, cx)) + .child(field(input, cx)) +} + +/// A block of read-only detail — a host and its key — set on the field's +/// fill, so it reads as a value to check rather than a line of prose. +pub(crate) fn well(cx: &App) -> Div { + v_flex() + .gap(px(4.)) + .px(px(10.)) + .py(px(8.)) + .rounded(crate::ui::rounding::ROW_RADIUS) + .bg(cx.theme().muted) +} + +/// A key named in a hint: a small faint cap, never a button outline. The same +/// cap the switcher's header and footer draw. +pub(crate) fn keycap(label: impl Into, cx: &App) -> gpui::AnyElement { + let theme = cx.theme(); + div() + .flex_shrink_0() + .min_w(px(KEYCAP)) + .h(px(KEYCAP)) + .px(px(4.)) + .flex() + .items_center() + .justify_center() + .rounded(px(4.)) + .bg(theme.muted) + .text_size(rems(11. / 16.)) + .text_color(theme.muted_foreground) + .child(label.into()) + .into_any_element() +} + +/// What a button is asking for. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Tone { + /// The one answer a card is for: an inverted neutral, the surface's ink as + /// the fill — the Commit button's paint, not the accent. The accent is + /// already spent on focus and on the rail; a dialog of blue buttons made + /// every sheet look like a call to action. + Primary, + /// Everything else: no fill at rest, the surface's hover rung under the + /// pointer. + Secondary, + /// The action a card warns about. Red, and only on the one site that has + /// earned it — overriding a changed host key. + Danger, +} + +/// A dialog button. The click handler is attached only while `enabled`: a +/// disabled button has to be inert, not merely grey. +/// +/// Disabled, a filled tone sinks to the faint fill with secondary ink — the +/// same fall the Commit button takes — so it never advertises an action that +/// cannot run, and a secondary one just loses its ink. +pub(crate) fn button( + id: impl Into, + text: impl Into, + tone: Tone, + enabled: bool, + rungs: Surface, + cx: &App, + on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, +) -> Stateful
{ + let theme = cx.theme(); + let surface: Hsla = gpui::rgb(rungs.base).into(); + let (fill, hover, ink): (Option, Option, Hsla) = match (tone, enabled) { + (Tone::Secondary, false) => (None, None, theme.muted_foreground), + (_, false) => (Some(theme.muted), None, theme.muted_foreground), + (Tone::Primary, true) => { + let fg = theme.foreground; + (Some(fg), Some(fg.blend(surface.opacity(0.14))), surface) + } + (Tone::Danger, true) => { + let red = theme.danger; + ( + Some(red), + Some(red.blend(surface.opacity(0.14))), + theme.danger_foreground, + ) + } + (Tone::Secondary, true) => (None, Some(gpui::rgb(rungs.hover).into()), theme.foreground), + }; + h_flex() + .id(id) + .flex_none() + .items_center() + .justify_center() + .h(px(BUTTON_H)) + .px(px(12.)) + .rounded(px(BUTTON_RADIUS)) + .text_size(rems(TAB_TEXT)) + .when(tone != Tone::Secondary, |b| { + b.font_weight(gpui::FontWeight::MEDIUM) + }) + .text_color(ink) + .when_some(fill, |b, fill| b.bg(fill)) + .when_some(hover, |b, hover| b.hover(move |s| s.bg(hover))) + .when(enabled, |b| b.cursor_pointer().on_click(on_click)) + .child(text.into()) +} diff --git a/src/ui/home.rs b/src/ui/home.rs index 3f972977..8c1785ba 100644 --- a/src/ui/home.rs +++ b/src/ui/home.rs @@ -4,9 +4,8 @@ use gpui::{ Animation, AnimationExt as _, App, Context, KeyDownEvent, Keystroke, MouseButton, div, prelude::*, px, }; -use gpui_component::button::{Button, ButtonVariants as _}; use gpui_component::kbd::Kbd; -use gpui_component::{ActiveTheme as _, IconName, Sizable as _, h_flex, v_flex}; +use gpui_component::{ActiveTheme as _, IconName, h_flex, v_flex}; use crate::core::actions::{NewTab, OpenSettings, ReopenClosedTab, TogglePalette, ToggleSwitcher}; use crate::core::session::{SessionPane, SessionTab}; @@ -35,6 +34,16 @@ const HOME_SHORTCUTS: [&str; 5] = [ "OpenSettings", ]; +/// The shortcut list under the logo. Rows are the sidebar's 28px step rather +/// than the old 42px buttons: five of them stacked at that height read as a +/// wall of controls on a page whose only job is to say "press ⌘T". +const HOME_LIST_W: f32 = 340.; +const HOME_ROW_H: f32 = 28.; + +/// A row's text inset: its hover fill bleeds this far past the label and the +/// cap on either side. +const HOME_ROW_PAD: f32 = 10.; + const CLOSED_LABEL_MAX: usize = 20; fn closed_tab_label(tab: &SessionTab) -> Option { @@ -204,7 +213,14 @@ impl Tty7App { let closed_hint = self.closed.last().and_then(closed_tab_label); let nothing_to_reopen = self.closed.is_empty(); - let mut list = v_flex().gap_2().w(px(340.)).text_sm().text_color(muted); + // Rows on the sidebar's shape: no fill at rest, the window's hover + // rung under the pointer, the chord in a faint cap at the far end. + let hover = gpui::rgb(cx.global::().window.hover); + let mut list = v_flex() + .gap(px(2.)) + .w(px(HOME_LIST_W)) + .text_size(gpui::rems(crate::ui::right_panel::TEXT)) + .text_color(muted); for action in HOME_SHORTCUTS { if action == "ReopenClosedTab" && nothing_to_reopen { continue; @@ -212,22 +228,24 @@ impl Tty7App { let emphasized = closed_hint.is_some() && action == "ReopenClosedTab"; let label = home_shortcut_label(action, closed_hint.as_deref()); list = list.child( - Button::new(action) - .ghost() + h_flex() + .id(action) .w_full() - .min_h(gpui::rems(2.625)) - .rounded(crate::ui::rounding::CARD_RADIUS) - .child( - h_flex() - .w_full() - .items_center() - .justify_between() - .text_color(muted) - .when(emphasized || action == "NewTab", |row| { - row.text_color(foreground) - }) - .child(label) - .children(key_stroke(action, cx).map(Kbd::new)), + .h(px(HOME_ROW_H)) + .px(px(HOME_ROW_PAD)) + .gap(px(12.)) + .items_center() + .justify_between() + .rounded(crate::ui::rounding::ROW_RADIUS) + .cursor_pointer() + .hover(move |row| row.bg(hover)) + .when(emphasized || action == "NewTab", |row| { + row.text_color(foreground) + }) + .child(div().min_w_0().truncate().child(label)) + .children( + key_stroke(action, cx) + .map(|stroke| crate::ui::dialog::keycap(Kbd::format(&stroke), cx)), ) .on_click(move |_, window, cx| { let command: Box = match action { @@ -310,17 +328,18 @@ impl Tty7App { .child(crate::ui::remote_workspace::status_message(message)) .when_some(action, |this, (label, action)| { this.child( - Button::new("home-remote-status-action") - .flex_shrink_0() - .label(label) - .ghost() - .small() - .on_click(cx.listener(move |this, _, window, cx| { + crate::ui::dialog::button( + "home-remote-status-action", + label, + crate::ui::dialog::Tone::Secondary, + true, + crate::ui::dialog::popover_rungs(cx), + cx, + cx.listener(move |this, _, window, cx| { this.run_strip_action(action.clone(), window, cx); - })) - .on_mouse_down(MouseButton::Left, |_, _, cx| { - cx.stop_propagation() }), + ) + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()), ) }), ) diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 4ec0d0af..08736575 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,6 +1,7 @@ pub mod app; pub mod assets; pub mod code_editor; +pub mod dialog; pub mod diff_list; pub mod diff_overlay; pub mod diff_rows; diff --git a/src/ui/notice.rs b/src/ui/notice.rs index cd18b14f..4e8e8d2f 100644 --- a/src/ui/notice.rs +++ b/src/ui/notice.rs @@ -14,29 +14,44 @@ //! anchor is a column: a second notice stacks above the first instead of //! landing on it. -use gpui::{AnyElement, App, Div, Hsla, div, prelude::*}; +use gpui::{AnyElement, App, Div, Hsla, div, prelude::*, px}; use gpui_component::{ActiveTheme as _, h_flex, v_flex}; -/// Chrome for one floating notice. `accent` is the border, and is the only -/// thing that says how bad this one is; the rest of the pill is the same +/// Chrome for one floating notice. `accent` is the leading dot, and is the +/// only thing that says how bad this one is; the rest of the pill is the same /// whatever went wrong. +/// +/// The severity used to tint the pill's whole edge. A coloured outline is +/// chrome wearing a status, and at 40% it was too faint to read as one — a +/// dot is the shape the rail and the switcher already use for "this state, +/// in this colour", and it leaves the edge the same hairline every other +/// floating surface has. pub(crate) fn pill(accent: Hsla, cx: &App) -> Div { let theme = cx.theme(); h_flex() .occlude() .items_center() - .gap_2() - .px_4() - .py_2() + .gap(px(8.)) + .min_h(px(PILL_H)) + .pl(px(12.)) + .pr(px(14.)) + .py(px(6.)) .map(|panel| crate::ui::theme::floating_surface(panel, cx)) - .border_color(accent.opacity(0.4)) // Off the right panel's ramp on purpose: these float over the // terminal, not inside a panel, and are sized against the terminal's // own text. .text_xs() .text_color(theme.muted_foreground) + .child(div().flex_none().size(px(DOT)).rounded_full().bg(accent)) } +/// A one-line pill's floor. A longer reason still wraps it taller; this only +/// stops a short one from shrinking to a sliver around 12px text. +const PILL_H: f32 = 32.; + +/// The severity dot, the rail's status-dot size. +const DOT: f32 = 6.; + /// Anchors whatever notices are up as one bottom-centred column, so two of /// them stack rather than collide. `None` when there is nothing to show, which /// is what lets the caller keep using `when_some`. diff --git a/src/ui/ssh_prompt.rs b/src/ui/ssh_prompt.rs index 1f9f226f..4806594f 100644 --- a/src/ui/ssh_prompt.rs +++ b/src/ui/ssh_prompt.rs @@ -1,17 +1,23 @@ use gpui::{ - AnyElement, Context, Entity, FocusHandle, IntoElement, ParentElement as _, Styled as _, - Subscription, Window, div, prelude::*, px, + AnyElement, App, Context, Entity, FocusHandle, IntoElement, ParentElement as _, Styled as _, + Subscription, Window, div, prelude::*, px, rems, }; -use gpui_component::button::{Button, ButtonVariants as _}; use gpui_component::checkbox::Checkbox; use gpui_component::input::{Input, InputEvent, InputState}; -use gpui_component::{ActiveTheme as _, Disableable as _, Sizable as _, h_flex, v_flex}; +use gpui_component::{ActiveTheme as _, Sizable as _, h_flex, v_flex}; use crate::core::keychain::{CredentialStore as _, OsCredentialStore}; use crate::daemon::protocol::{AuthPromptKind, AuthResponse, SshPhase}; use crate::terminal::view::TerminalView; use super::app::Tty7App; +use super::dialog::{self, Tone}; +use super::i18n::{L10nKey, t, t_fmt}; +use super::right_panel::{META, META_MONO, TEXT}; + +/// The sheet's width, and the width of the banners stacked above it, so the +/// column reads as one. +const SHEET_W: f32 = 440.; #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct KiRow { @@ -690,7 +696,7 @@ impl Tty7App { return None; } - let mut stack = v_flex().gap_2().items_center(); + let mut stack = v_flex().gap(px(8.)).items_center(); for (ix, banner) in self.ssh_prompt.banners.iter().enumerate() { stack = stack.child(self.render_ssh_banner(ix, banner, cx)); @@ -726,120 +732,129 @@ impl Tty7App { fn render_ssh_banner(&self, ix: usize, text: &str, cx: &mut Context) -> AnyElement { h_flex() .occlude() - .w(px(460.)) - .gap_2() - .p_2() + .w(px(SHEET_W)) + .items_center() + .gap(px(10.)) + .min_h(px(dialog::HEADER_H)) + .pl(px(dialog::INSET)) + .pr(px(10.)) + .py(px(6.)) .map(|panel| crate::ui::theme::floating_surface(panel, cx)) - .child(div().flex_1().text_sm().child(text.to_string())) + .rounded(px(dialog::CARD_RADIUS)) .child( - Button::new(("ssh-banner-dismiss", ix)) - .label(crate::ui::i18n::t(crate::ui::i18n::L10nKey::Dismiss)) - .small() - .ghost() - .on_click(cx.listener(move |this, _, _w, cx| this.dismiss_ssh_banner(ix, cx))), + div() + .flex_1() + .min_w_0() + .text_size(rems(TEXT)) + .child(text.to_string()), ) + .child(dialog::button( + ("ssh-banner-dismiss", ix), + t(L10nKey::Dismiss), + Tone::Secondary, + true, + dialog::popover_rungs(cx), + cx, + cx.listener(move |this, _, _w, cx| this.dismiss_ssh_banner(ix, cx)), + )) .into_any_element() } fn render_ssh_sheet(&self, model: &PromptModel, cx: &mut Context) -> AnyElement { - let danger = cx.theme().danger; + let theme = cx.theme(); + let (danger, muted, fg) = (theme.danger, theme.muted_foreground, theme.foreground); + let rungs = dialog::popover_rungs(cx); let (title, danger_sheet) = match model { PromptModel::Password { user, host, .. } => ( - crate::ui::i18n::t_fmt( - crate::ui::i18n::L10nKey::SshPromptPasswordFor, + t_fmt( + L10nKey::SshPromptPasswordFor, &[("user", user), ("host", host)], ), false, ), PromptModel::KeyPassphrase { key_path, .. } => ( - crate::ui::i18n::t_fmt( - crate::ui::i18n::L10nKey::SshPromptPassphraseFor, - &[("key_path", key_path)], - ), + t_fmt(L10nKey::SshPromptPassphraseFor, &[("key_path", key_path)]), false, ), PromptModel::KeyboardInteractive { name, .. } => { let label = if name.is_empty() { - crate::ui::i18n::t(crate::ui::i18n::L10nKey::SshPromptTwoFactor).to_string() + t(L10nKey::SshPromptTwoFactor).to_string() } else { name.clone() }; (label, false) } PromptModel::HostKeyUnknown { host, .. } => ( - crate::ui::i18n::t_fmt( - crate::ui::i18n::L10nKey::SshPromptUnknownHost, - &[("host", host)], - ), + t_fmt(L10nKey::SshPromptUnknownHost, &[("host", host)]), false, ), - PromptModel::HostKeyChanged { .. } => ( - crate::ui::i18n::t(crate::ui::i18n::L10nKey::SshPromptHostKeyChanged).to_string(), - true, - ), + PromptModel::HostKeyChanged { .. } => { + (t(L10nKey::SshPromptHostKeyChanged).to_string(), true) + } }; - let mut card = v_flex() - .occlude() + // One line of the sheet's prose: caption size, in the ink it is told. + let line = + |text: String, ink: gpui::Hsla| div().text_size(rems(META)).text_color(ink).child(text); + // A host line or a fingerprint, set in mono inside the detail well. + let mono = |text: String, ink: gpui::Hsla| { + div() + .text_size(rems(META_MONO)) + .font_family("monospace") + .text_color(ink) + .child(text) + }; + + let card = dialog::card(SHEET_W, cx) .track_focus(&self.ssh_prompt.focus_handle) .key_context("SshPrompt") - .w(px(440.)) - .gap_4() - .p_5() - .map(|panel| crate::ui::theme::floating_surface(panel, cx)) + // The changed-key sheet is the one card allowed a coloured edge: + // it is what a man-in-the-middle looks like. .when(danger_sheet, |panel| panel.border_color(danger)) .on_key_down(cx.listener(|this, ev: &gpui::KeyDownEvent, window, cx| { if ev.keystroke.key == "escape" { this.cancel_ssh_prompt(window, cx); } - })); + })) + .child(dialog::header( + div() + .when(danger_sheet, |d| d.text_color(danger)) + .child(title), + cx, + )); - card = card.child( - div() - .text_sm() - .font_weight(gpui::FontWeight::SEMIBOLD) - .when(danger_sheet, |d| d.text_color(danger)) - .child(title), - ); - - card = match model { + let (body, footer) = match model { PromptModel::Password { rejected, .. } => { - let mut c = card; - if *rejected { - c = c.child(div().text_xs().text_color(danger).child(crate::ui::i18n::t( - crate::ui::i18n::L10nKey::StoredPasswordRejected, - ))); - } - c.child(self.render_ssh_input(0)) - .child(self.render_ssh_remember(cx)) - .child(self.render_ssh_actions( - crate::ui::i18n::t(crate::ui::i18n::L10nKey::SshPromptConnect), - cx, - )) + let body = dialog::body() + .when(*rejected, |c| { + c.child(line(t(L10nKey::StoredPasswordRejected).to_string(), danger)) + }) + .child(self.render_ssh_input(0, cx)) + .child(self.render_ssh_remember(cx)); + ( + body, + self.render_ssh_actions(t(L10nKey::SshPromptConnect), cx), + ) } PromptModel::KeyPassphrase { comment, rejected, .. } => { - let mut c = card; - if *rejected { - c = c.child(div().text_xs().text_color(danger).child(crate::ui::i18n::t( - crate::ui::i18n::L10nKey::StoredPassphraseRejected, - ))); - } - if !comment.is_empty() { - c = c.child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(comment.clone()), - ); - } - c.child(self.render_ssh_input(0)) - .child(self.render_ssh_remember(cx)) - .child(self.render_ssh_actions( - crate::ui::i18n::t(crate::ui::i18n::L10nKey::SshPromptUnlock), - cx, - )) + let body = dialog::body() + .when(*rejected, |c| { + c.child(line( + t(L10nKey::StoredPassphraseRejected).to_string(), + danger, + )) + }) + .when(!comment.is_empty(), |c| { + c.child(line(comment.clone(), muted)) + }) + .child(self.render_ssh_input(0, cx)) + .child(self.render_ssh_remember(cx)); + ( + body, + self.render_ssh_actions(t(L10nKey::SshPromptUnlock), cx), + ) } PromptModel::KeyboardInteractive { instructions, @@ -847,23 +862,26 @@ impl Tty7App { stored_rejected, .. } => { - let mut c = card; - if *stored_rejected { - c = c.child(div().text_xs().text_color(danger).child(crate::ui::i18n::t( - crate::ui::i18n::L10nKey::StoredPasswordRejected, - ))); - } - if !instructions.is_empty() { - c = c.child(div().text_xs().child(instructions.clone())); - } + let mut body = dialog::body() + .when(*stored_rejected, |c| { + c.child(line(t(L10nKey::StoredPasswordRejected).to_string(), danger)) + }) + .when(!instructions.is_empty(), |c| { + c.child(line(instructions.clone(), fg)) + }); + // Each prompt is the name of the field under it. for (i, row) in prompts.iter().enumerate() { - c = c.child(div().text_xs().child(row.text.clone())); - c = c.child(self.render_ssh_input(i)); + body = body.child( + v_flex() + .gap(px(6.)) + .child(dialog::label(row.text.clone(), cx)) + .child(self.render_ssh_input(i, cx)), + ); } - c.child(self.render_ssh_actions( - crate::ui::i18n::t(crate::ui::i18n::L10nKey::SshPromptSubmit), - cx, - )) + ( + body, + self.render_ssh_actions(t(L10nKey::SshPromptSubmit), cx), + ) } PromptModel::HostKeyUnknown { algorithm, @@ -871,51 +889,49 @@ impl Tty7App { port, host, previously_known_as, - } => card - .child(div().text_xs().child(format!("{host}:{port} {algorithm}"))) - .child( - div() - .text_xs() - .font_family("monospace") - .child(fingerprint.clone()), - ) - // A host that already has an entry under another algorithm is - // the ordinary way a server grows an ed25519 key beside its old - // ssh-rsa one. Saying so is the difference between "who is - // this?" and "this is the host you know, with a second key". - .when_some(previously_known_as.as_ref(), |c, previous| { - c.child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(crate::ui::i18n::t_fmt( - crate::ui::i18n::L10nKey::SshPromptHostKeyNewAlgorithm, - &[("previous_algorithm", previous), ("algorithm", algorithm)], - )), + } => { + let body = dialog::body() + .child( + dialog::well(cx) + .child(mono(format!("{host}:{port} {algorithm}"), muted)) + .child(mono(fingerprint.clone(), fg)), ) - }) - .child( - h_flex() - .justify_end() - .gap_2() - .child( - Button::new("ssh-hk-abort") - .label(crate::ui::i18n::t(crate::ui::i18n::L10nKey::Abort)) - .small() - .on_click(cx.listener(|this, _, window, cx| { - this.cancel_ssh_prompt(window, cx) - })), - ) - .child( - Button::new("ssh-hk-trust") - .label(crate::ui::i18n::t(crate::ui::i18n::L10nKey::Trust)) - .small() - .primary() - .on_click(cx.listener(|this, _, window, cx| { - this.trust_ssh_host_key(window, cx) - })), - ), - ), + // A host that already has an entry under another algorithm + // is the ordinary way a server grows an ed25519 key beside + // its old ssh-rsa one. Saying so is the difference between + // "who is this?" and "this is the host you know, with a + // second key". + .when_some(previously_known_as.as_ref(), |c, previous| { + c.child(line( + t_fmt( + L10nKey::SshPromptHostKeyNewAlgorithm, + &[("previous_algorithm", previous), ("algorithm", algorithm)], + ), + muted, + )) + }); + let footer = dialog::footer(cx) + .child(dialog::button( + "ssh-hk-abort", + t(L10nKey::Abort), + Tone::Secondary, + true, + rungs, + cx, + cx.listener(|this, _, window, cx| this.cancel_ssh_prompt(window, cx)), + )) + .child(dialog::button( + "ssh-hk-trust", + t(L10nKey::Trust), + Tone::Primary, + true, + rungs, + cx, + cx.listener(|this, _, window, cx| this.trust_ssh_host_key(window, cx)), + )) + .into_any_element(); + (body, footer) + } PromptModel::HostKeyChanged { algorithm, fingerprint, @@ -935,89 +951,82 @@ impl Tty7App { .map(|i| i.read(cx).value().to_string()) .unwrap_or_default(); let can_override = changed_confirmed(&typed); - card.child(div().text_xs().text_color(danger).child(crate::ui::i18n::t( - crate::ui::i18n::L10nKey::SshPromptHostKeyChangedBody, - ))) - .child(div().text_xs().child(format!("{host}:{port} {algorithm}"))) - .child( - div() - .text_xs() - .font_family("monospace") - .child(crate::ui::i18n::t_fmt( - crate::ui::i18n::L10nKey::SshPromptNewKey, - &[("fingerprint", &fingerprint)], - )), - ) - .child( - div() - .text_xs() - .font_family("monospace") - .text_color(cx.theme().muted_foreground) - .child(crate::ui::i18n::t_fmt( - crate::ui::i18n::L10nKey::SshPromptOldKey, - &[("old_fingerprint", &old_fingerprint)], - )), - ) - .child(div().text_xs().child(crate::ui::i18n::t( - crate::ui::i18n::L10nKey::HostKeyOverrideMessage, - ))) - .child(self.render_ssh_input(0)) - // Only once they have typed something: an empty field is not a - // mistake to be corrected, it is where everyone starts. - .when(!typed.trim().is_empty() && !can_override, |c| { - c.child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(crate::ui::i18n::t( - crate::ui::i18n::L10nKey::SshPromptTypeYesToOverride, + let body = dialog::body() + .child(line( + t(L10nKey::SshPromptHostKeyChangedBody).to_string(), + danger, + )) + .child( + dialog::well(cx) + .child(mono(format!("{host}:{port} {algorithm}"), muted)) + .child(mono( + t_fmt(L10nKey::SshPromptNewKey, &[("fingerprint", &fingerprint)]), + fg, + )) + .child(mono( + t_fmt( + L10nKey::SshPromptOldKey, + &[("old_fingerprint", &old_fingerprint)], + ), + muted, )), ) - }) - .child( - h_flex() - .justify_end() - .gap_2() - // Abort stays the emphasized one and now also sits - // where the eye lands last: a changed host key is the - // one prompt where the safe answer wants both. - // - // Override is the app's one `danger` button, and it is - // the site that earns it: the sheet is what a - // man-in-the-middle looks like, and this was the only - // control in the product that could act on that with - // no colour on it at all. It is disabled until the word - // is typed, so it greys until armed and then goes red — - // the emphasis arrives exactly when the button does. - .child( - Button::new("ssh-hkc-override") - .label(crate::ui::i18n::t(crate::ui::i18n::L10nKey::Override)) - .small() - .danger() - .disabled(!can_override) - .on_click(cx.listener(|this, _, window, cx| { - this.submit_ssh_prompt(window, cx) - })), - ) - .child( - Button::new("ssh-hkc-abort") - .label(crate::ui::i18n::t(crate::ui::i18n::L10nKey::Abort)) - .small() - .primary() - .on_click(cx.listener(|this, _, window, cx| { - this.cancel_ssh_prompt(window, cx) - })), - ), - ) + .child( + v_flex() + .gap(px(6.)) + .child(line(t(L10nKey::HostKeyOverrideMessage).to_string(), fg)) + .child(self.render_ssh_input(0, cx)) + // Only once they have typed something: an empty + // field is not a mistake to be corrected, it is + // where everyone starts. + .when(!typed.trim().is_empty() && !can_override, |c| { + c.child(line( + t(L10nKey::SshPromptTypeYesToOverride).to_string(), + muted, + )) + }), + ); + // Abort stays the emphasized one and also sits where the eye + // lands last: a changed host key is the one prompt where the + // safe answer wants both. + // + // Override is the app's one `danger` button, and it is the + // site that earns it: the sheet is what a man-in-the-middle + // looks like, and this was the only control in the product + // that could act on that with no colour on it at all. It is + // inert until the word is typed, so it stays faint until armed + // and then goes red — the emphasis arrives exactly when the + // button does. + let footer = dialog::footer(cx) + .child(dialog::button( + "ssh-hkc-override", + t(L10nKey::Override), + Tone::Danger, + can_override, + rungs, + cx, + cx.listener(|this, _, window, cx| this.submit_ssh_prompt(window, cx)), + )) + .child(dialog::button( + "ssh-hkc-abort", + t(L10nKey::Abort), + Tone::Primary, + true, + rungs, + cx, + cx.listener(|this, _, window, cx| this.cancel_ssh_prompt(window, cx)), + )) + .into_any_element(); + (body, footer) } }; - card.into_any_element() + card.child(body).child(footer).into_any_element() } - fn render_ssh_input(&self, ix: usize) -> AnyElement { + fn render_ssh_input(&self, ix: usize, cx: &App) -> AnyElement { match self.ssh_prompt.inputs.get(ix) { - Some(state) => Input::new(state).small().into_any_element(), + Some(state) => dialog::field(Input::new(state), cx).into_any_element(), None => div().into_any_element(), } } @@ -1026,9 +1035,8 @@ impl Tty7App { h_flex() .child( Checkbox::new("ssh-remember") - .label(crate::ui::i18n::t( - crate::ui::i18n::L10nKey::RememberKeychain, - )) + .label(t(L10nKey::RememberKeychain)) + .small() .checked(self.ssh_prompt.remember) .on_click(cx.listener(|this, _, _w, cx| this.toggle_ssh_remember(cx))), ) @@ -1040,27 +1048,26 @@ impl Tty7App { /// `ui::confirm_answers` settled that arrangement for every native alert /// the app raises; a sheet the app draws itself has no reason to mirror it. fn render_ssh_actions(&self, submit_label: &str, cx: &mut Context) -> AnyElement { - let submit_label = submit_label.to_string(); - h_flex() - .justify_end() - .gap_2() - .child( - Button::new("ssh-cancel") - .label(crate::ui::i18n::t(crate::ui::i18n::L10nKey::Cancel)) - .small() - .on_click( - cx.listener(|this, _, window, cx| this.cancel_ssh_prompt(window, cx)), - ), - ) - .child( - Button::new("ssh-submit") - .label(submit_label) - .small() - .primary() - .on_click( - cx.listener(|this, _, window, cx| this.submit_ssh_prompt(window, cx)), - ), - ) + let rungs = dialog::popover_rungs(cx); + dialog::footer(cx) + .child(dialog::button( + "ssh-cancel", + t(L10nKey::Cancel), + Tone::Secondary, + true, + rungs, + cx, + cx.listener(|this, _, window, cx| this.cancel_ssh_prompt(window, cx)), + )) + .child(dialog::button( + "ssh-submit", + submit_label.to_string(), + Tone::Primary, + true, + rungs, + cx, + cx.listener(|this, _, window, cx| this.submit_ssh_prompt(window, cx)), + )) .into_any_element() } } diff --git a/src/ui/worktree_prompt.rs b/src/ui/worktree_prompt.rs index dad2096e..7b43bfa4 100644 --- a/src/ui/worktree_prompt.rs +++ b/src/ui/worktree_prompt.rs @@ -1,13 +1,12 @@ -use gpui::{AnyElement, Context, Entity, Subscription, Window, div, prelude::*, px}; -use gpui_component::button::{Button, ButtonVariants as _}; +use gpui::{AnyElement, Context, Entity, Subscription, Window, div, prelude::*, px, rems}; use gpui_component::input::{Input, InputEvent, InputState}; -use gpui_component::{ - ActiveTheme as _, Disableable as _, Sizable as _, WindowExt as _, h_flex, v_flex, -}; +use gpui_component::{ActiveTheme as _, WindowExt as _}; use crate::core::worktree::{WorktreeDefaults, WorktreeRequest}; use crate::ui::app::Tty7App; +use crate::ui::dialog::{self, Tone}; use crate::ui::i18n::{L10nKey, t, t_fmt}; +use crate::ui::right_panel::META_MONO; pub(crate) struct WorktreePrompt { host: crate::ui::host_ops::SharedHost, @@ -135,12 +134,6 @@ impl Tty7App { ) -> Option { let p = self.worktree_prompt.as_ref()?; let muted = cx.theme().muted_foreground; - let field = |label: &'static str, input: &Entity| { - v_flex() - .gap_1() - .child(div().text_xs().text_color(muted).child(label)) - .child(Input::new(input).small()) - }; let name_now = p.name.read(cx).value().trim().to_string(); let branch_now = p.branch.read(cx).value().trim().to_string(); // Submitting falls back from one field to the other, so either alone @@ -164,54 +157,59 @@ impl Tty7App { .display() .to_string(); - let card = v_flex() - .occlude() - .w(px(440.)) - .gap_4() - .p_5() - .map(|panel| crate::ui::theme::floating_surface(panel, cx)) + let rungs = dialog::popover_rungs(cx); + let card = dialog::card(440., cx) + .child(dialog::header(t(L10nKey::WorktreePromptTitle), cx)) .child( - div() - .text_sm() - .font_weight(gpui::FontWeight::SEMIBOLD) - .child(t(L10nKey::WorktreePromptTitle)), - ) - .child(field(t(L10nKey::WorktreePromptName), &p.name)) - .child( - div() - .text_xs() - .font_family("monospace") - .text_color(muted) - .child(preview), - ) - .child(field(t(L10nKey::WorktreePromptBranch), &p.branch)) - .child(field(t(L10nKey::WorktreePromptBase), &p.base)) - .child( - h_flex() - .justify_end() - .gap_2() + dialog::body() + // The path preview hangs off the Name field it follows, + // closer to it than the next field is. .child( - Button::new("worktree-cancel") - .label(t(L10nKey::Cancel)) - .small() - .on_click(cx.listener(|this, _, window, cx| { - this.cancel_worktree_prompt(window, cx) - })), + dialog::labelled(t(L10nKey::WorktreePromptName), Input::new(&p.name), cx) + .child( + div() + .truncate() + .text_size(rems(META_MONO)) + .font_family("monospace") + .text_color(muted) + .child(preview), + ), ) - .child( - Button::new("worktree-create") - .label(if p.busy { - t(L10nKey::WorktreePromptCreating) - } else { - t(L10nKey::WorktreePromptCreate) - }) - .small() - .primary() - .disabled(p.busy || nothing_to_name) - .on_click(cx.listener(|this, _, window, cx| { - this.submit_worktree_prompt(window, cx) - })), - ), + .child(dialog::labelled( + t(L10nKey::WorktreePromptBranch), + Input::new(&p.branch), + cx, + )) + .child(dialog::labelled( + t(L10nKey::WorktreePromptBase), + Input::new(&p.base), + cx, + )), + ) + .child( + dialog::footer(cx) + .child(dialog::button( + "worktree-cancel", + t(L10nKey::Cancel), + Tone::Secondary, + true, + rungs, + cx, + cx.listener(|this, _, window, cx| this.cancel_worktree_prompt(window, cx)), + )) + .child(dialog::button( + "worktree-create", + if p.busy { + t(L10nKey::WorktreePromptCreating) + } else { + t(L10nKey::WorktreePromptCreate) + }, + Tone::Primary, + !(p.busy || nothing_to_name), + rungs, + cx, + cx.listener(|this, _, window, cx| this.submit_worktree_prompt(window, cx)), + )), ); Some(