feat(ui): bring the modal cards onto the v5 design

- Confirmations take v5's alert shape: title and detail as one
  paragraph, answers beneath, no header row or footer rule, 360px wide
  and set lower, at eye height.
- Dialog buttons take the field's 7px corner and 14px padding; a
  secondary answer rests on the well's faint fill instead of bare text.
- The scrim dims rather than blacks out: 45% dark, 14% light.
- The New Workspace form joins the other cards: 12px corner, borderless
  wells for its fields, an esc cap and the shared 40px footer.
This commit is contained in:
l0ng-ai
2026-09-28 12:28:21 +08:00
parent ce94b627a7
commit 0b8a2805db
4 changed files with 89 additions and 69 deletions
+15 -10
View File
@@ -44,10 +44,11 @@ pub(crate) const FIELD_H: f32 = 28.;
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.
/// A button: the field's height and the field's corner — the v5 confirm
/// card's 28px pill-less rectangle, on every card alike.
pub(crate) const BUTTON_H: f32 = 28.;
const BUTTON_RADIUS: f32 = 6.;
const BUTTON_RADIUS: f32 = 7.;
const BUTTON_PAD: f32 = 14.;
/// A key named in a hint: an 18px cap on the faint fill.
pub(crate) const KEYCAP: f32 = 18.;
@@ -207,8 +208,9 @@ pub(crate) enum Tone {
/// 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.
/// Everything else: the well's faint fill at rest, one rung up under the
/// pointer. A bare word with no shape read as a link, not as the second
/// answer to the question.
Secondary,
/// The action a card warns about. Red, and only on the one site that has
/// earned it — overriding a changed host key.
@@ -218,9 +220,9 @@ pub(crate) enum Tone {
/// 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
/// Disabled, every 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.
/// cannot run.
pub(crate) fn button(
id: impl Into<ElementId>,
text: impl Into<SharedString>,
@@ -233,7 +235,6 @@ pub(crate) fn button(
let theme = cx.theme();
let surface: Hsla = gpui::rgb(rungs.base).into();
let (fill, hover, ink): (Option<Hsla>, Option<Hsla>, Hsla) = match (tone, enabled) {
(Tone::Secondary, false) => (None, None, theme.muted_foreground),
// The surface's own next rung, like a well: `muted` is a step off the
// window, and on a dark popover it was the card's own colour.
(_, false) => (
@@ -253,7 +254,11 @@ pub(crate) fn button(
theme.danger_foreground,
)
}
(Tone::Secondary, true) => (None, Some(gpui::rgb(rungs.hover).into()), theme.foreground),
(Tone::Secondary, true) => (
Some(gpui::rgb(rungs.hover).into()),
Some(gpui::rgb(rungs.selected).into()),
theme.foreground,
),
};
h_flex()
.id(id)
@@ -261,7 +266,7 @@ pub(crate) fn button(
.items_center()
.justify_center()
.h(px(BUTTON_H))
.px(px(12.))
.px(px(BUTTON_PAD))
.rounded(px(BUTTON_RADIUS))
.text_size(rems(TAB_TEXT))
.when(tone != Tone::Secondary, |b| {
+6 -3
View File
@@ -420,11 +420,14 @@ impl Theme {
sidebar,
rail,
popover: self.surface(m.popover),
// The v5 scrim: a dim, not a blackout. Dark lands on near-black at
// 45%; light on a warm near-black at 14%, just enough to say the
// window is waiting without turning the page grey.
scrim: Scrim {
ink: mix(m.background, 0x000000, 0.82),
ink: mix(m.background, 0x000000, 0.92),
alpha: match self.dark {
true => 0.55,
false => 0.30,
true => 0.45,
false => 0.14,
},
},
}
+50 -47
View File
@@ -12,15 +12,17 @@
//! buttons, so the same app had two ideas of what a dialog looks like, and
//! which one you got depended on which question it was.
//!
//! So all three platforms use this. It is laid out like the other cards, sits
//! where they sit, wraps its text, and answers Return and Escape the way the
//! native dialogs did — which the call sites were written against.
//! So all three platforms use this. It is the other cards' surface, corner,
//! scrim and buttons in v5's alert shape — title and detail as one paragraph,
//! answers beneath, no header row — wraps its text, and answers Return and
//! Escape the way the native dialogs did, which the call sites were written
//! against.
use gpui::{
App, Context, EventEmitter, FocusHandle, Focusable, PromptButton, PromptHandle, PromptLevel,
PromptResponse, RenderablePromptHandle, Window, div, prelude::*, px, rems,
};
use gpui_component::{ActiveTheme as _, h_flex};
use gpui_component::{ActiveTheme as _, h_flex, v_flex};
use crate::ui::dialog::{self, Tone};
use crate::ui::right_panel::{TAB_TEXT, TEXT};
@@ -43,9 +45,12 @@ fn build(
handle.with_view(prompt, window, cx)
}
/// A confirmation card is a sentence and two buttons; the worktree form, with
/// three fields, is 440.
const WIDTH: f32 = 420.;
/// A confirmation card is a sentence and two buttons — v5's alert width. The
/// worktree form, with three fields, is 440.
const ALERT_W: f32 = 360.;
/// How far down the alert sits, at most.
const ALERT_TOP: f32 = 180.;
pub(crate) struct TextPrompt {
message: String,
@@ -131,13 +136,10 @@ impl Focusable for TextPrompt {
}
impl Render for TextPrompt {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = cx.theme();
let muted = theme.muted_foreground;
let border = theme.border;
let rungs = dialog::popover_rungs(cx);
let escapable = self.cancel_answer().is_some();
let has_detail = self.detail.is_some();
// Answer 0 goes rightmost, where the native dialogs put it and where
// `confirm_answers` expects it to land.
@@ -162,47 +164,48 @@ impl Render for TextPrompt {
}
}
// The other cards' title row, except that the title wraps: theirs are
// names, which truncate cleanly, and this one is a question, which
// cut short no longer asks anything. One line still sits in the
// 48px row exactly where theirs does.
let title = h_flex()
.flex_none()
.items_center()
.gap(px(10.))
.min_h(px(dialog::HEADER_H))
.py(px(12.))
.pl(px(dialog::INSET))
.pr(px(14.))
.when(has_detail, |row| row.border_b_1().border_color(border))
// The v5 confirm card: no title row and no footer rule — a question
// is one thought, not a form, so it is set as a paragraph with its
// answers under it. The title wraps: cut short, a question no longer
// asks anything.
let text = v_flex()
.gap(px(4.))
.min_w_0()
.child(
div()
.flex_1()
.min_w_0()
.text_size(rems(TEXT))
.font_weight(gpui::FontWeight::MEDIUM)
.line_height(rems(TEXT * 1.4))
.font_weight(gpui::FontWeight::SEMIBOLD)
.child(self.message.clone()),
)
.when(escapable, |row| row.child(dialog::keycap("esc", cx)));
let card = dialog::card(WIDTH, cx)
.max_w(gpui::relative(0.9))
.child(title)
.children(self.detail.clone().map(|detail| {
dialog::body().child(
div()
.text_size(rems(TAB_TEXT))
.line_height(rems(TAB_TEXT * 1.5))
.text_color(muted)
.child(detail),
)
}))
.child(
dialog::footer(cx)
.children(apart)
.when(self.answers.len() >= 3, |row| row.child(div().flex_1()))
.children(packed),
);
div()
.text_size(rems(TAB_TEXT))
.line_height(rems(TAB_TEXT * 1.45))
.text_color(muted)
.child(detail)
}));
let answers = h_flex()
.items_center()
.gap(px(8.))
.children(apart)
.child(div().flex_1())
.children(packed);
let card = dialog::card(ALERT_W, cx)
.max_w(gpui::relative(0.9))
.gap(px(16.))
.pt(px(20.))
.px(px(20.))
.pb(px(16.))
.child(text)
.child(answers);
// Lower than the switcher's drop: an alert is read, not typed into,
// and v5 sets it at eye height. A short window pulls it up rather
// than pushing the buttons off the bottom.
let top = (window.viewport_size().height.as_f32() * 0.22).clamp(16., ALERT_TOP);
div()
.id("text-prompt")
@@ -235,7 +238,7 @@ impl Render for TextPrompt {
.flex_col()
.items_center()
.justify_start()
.pt(px(crate::ui::switcher::CARD_TOP))
.pt(px(top))
.child(card)
}
}
+18 -9
View File
@@ -2684,11 +2684,15 @@ impl Tty7App {
)
.child(
div()
.flex_1()
.min_w_0()
.truncate()
.text_size(gpui::rems(13. / 16.))
.font_weight(gpui::FontWeight::MEDIUM)
.text_color(fg)
.child(t(L10nKey::AppMenuNewWorkspace)),
);
)
.child(div().pr(px(6.)).child(keycap("esc", cx)));
let label_col = |text: &'static str| {
div()
@@ -2698,6 +2702,9 @@ impl Tty7App {
.text_color(muted)
.child(text)
};
// The other cards' well — no outline, the faint fill is the shape —
// so this form and the worktree prompt draw a field the same way.
let well = crate::ui::dialog::well_fill(cx);
let field = |inner: gpui::Div| {
inner
.flex_1()
@@ -2705,10 +2712,9 @@ impl Tty7App {
.items_center()
.gap(px(6.))
.px(px(8.))
.h(px(30.))
.h(px(crate::ui::dialog::FIELD_H))
.rounded(crate::ui::rounding::ROW_RADIUS)
.border_1()
.border_color(border)
.bg(well)
};
let name_row = h_flex()
@@ -2743,7 +2749,7 @@ impl Tty7App {
false => field(h_flex())
.id("switcher-form-host")
.cursor_pointer()
.hover(move |r| r.bg(hover))
.hover(move |r| r.bg(picked_bg))
.child(host_glyph(chosen_local, chosen_dot))
.child(
div()
@@ -2868,7 +2874,7 @@ impl Tty7App {
.gap(px(8.))
.child(
label_col(t(L10nKey::SwitcherFormHost))
.h(px(30.))
.h(px(crate::ui::dialog::FIELD_H))
.flex()
.items_center(),
)
@@ -2876,8 +2882,8 @@ impl Tty7App {
let footer = h_flex()
.items_center()
.px(px(12.))
.py(px(8.))
.h(px(crate::ui::dialog::FOOTER_H))
.px(px(crate::ui::dialog::INSET))
.border_t_1()
.border_color(border)
.text_size(gpui::rems(11. / 16.))
@@ -2890,11 +2896,14 @@ impl Tty7App {
v_flex()
.w(px(card_w))
.map(|panel| crate::ui::theme::floating_surface(panel, cx))
.rounded(px(CARD_RADIUS))
.overflow_hidden()
.child(header)
.child(
v_flex()
.p(px(12.))
.px(px(crate::ui::dialog::INSET))
.pt(px(16.))
.pb(px(18.))
.gap(px(10.))
.child(name_row)
.child(host_row),