diff --git a/src/ui/dialog.rs b/src/ui/dialog.rs index caf80d99..d80f60f8 100644 --- a/src/ui/dialog.rs +++ b/src/ui/dialog.rs @@ -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, text: impl Into, @@ -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, Option, 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| { diff --git a/src/ui/presets.rs b/src/ui/presets.rs index c631db2e..10561cf8 100644 --- a/src/ui/presets.rs +++ b/src/ui/presets.rs @@ -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, }, }, } diff --git a/src/ui/prompt.rs b/src/ui/prompt.rs index 505bac36..d238a73d 100644 --- a/src/ui/prompt.rs +++ b/src/ui/prompt.rs @@ -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) -> impl IntoElement { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> 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) } } diff --git a/src/ui/switcher.rs b/src/ui/switcher.rs index 383000ff..4f41c429 100644 --- a/src/ui/switcher.rs +++ b/src/ui/switcher.rs @@ -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),