From 541a887a21182aeea83011632f0ecb39ad634086 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:43:59 +0800 Subject: [PATCH] feat(settings): restyle the settings page after the v4 design - Nav: the rail's tinted fill and surface ladder behind a divider hairline; a 28px filled search well; 28px rows in 7px pills, the current one on the selected rung at medium weight instead of the accent; match counts in muted ink; the modified-only filter toggles like a nav row. - Pages: the title sits in the 48px title-bar band at 16/16rem; group headings are 11.5/16rem medium muted on a 28px line; sections are split by a 0.5px divider with 16px either side. - Rows: labels in body ink at regular weight, descriptions at 12/16rem muted, 28px floor with 8px padding; a search hit wears the faint neutral fill rather than the accent tint. - Controls: text fields and dropdowns are 28px filled pills with no outline; buttons, segmented tracks and steppers are 26px with a 6px radius on the same fill. The one primary action per view (save theme draft, connect, install update) is the inverted neutral fill of the commit button. Switches and sliders keep the accent. - SSH: host list header with 26px tiles and a filled search, 22px group headings, 42px two-line host rows; the form's labels are a muted, right-aligned column level with 28px fields; disclosure headers use a chevron on a 28px band. - Theme cards are filled and unoutlined, taking the selected rung while open; the theme panel keeps the content fill with a divider edge and its title in the title-bar band. Keycaps are filled with no outline and shortcut rows are divided by 0.5px hairlines. - right_panel::SECTION_GAP is now shared; docs/design-system.md updated. --- docs/design-system.md | 14 +- src/ui/diff_overlay.rs | 2 +- src/ui/right_panel.rs | 2 +- src/ui/settings.rs | 1694 +++++++++++++++++++++++++--------------- 4 files changed, 1081 insertions(+), 631 deletions(-) diff --git a/docs/design-system.md b/docs/design-system.md index 6e7a5424..38e84768 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -60,7 +60,7 @@ Git 的新增/修改标记种子为绿 `#2F8A52` / `#4CC27A`、琥珀 `#B7791F` - 行内辅助按钮至少保留现有 24px 命中区域;增大图标不能挤掉文本预算。 - 工具图标使用中性文字色。品牌用小图形识别,饱和色留给状态点。 - 字号跟随界面缩放;正文 14/16rem,说明 12/16rem,章节标题 16/16rem。 -- 左侧栏分组标题为 11.5/16rem、medium、辅助色;右侧紧凑分组标题为 12/16rem、semibold;设置页章节标题仍为 16/16rem。Session 字段名和值同为正文尺寸,以颜色区分层级。 +- 左侧栏分组标题为 11.5/16rem、medium、辅助色;右侧紧凑分组标题为 12/16rem、semibold;设置页只有页标题为 16/16rem semibold,页内分组标题与信息页同为 11.5/16rem medium 辅助色。Session 字段名和值同为正文尺寸,以颜色区分层级。 - 辅助文字必须在实际的内容、侧栏、浮层背景上均达到 4.5:1;普通路径、数量和说明不再叠加透明度。静态 PID 使用中性文字。 - 放大界面字号时,Git 行高与设置导航宽度同步调整;窄侧栏优先保留分支名,长文件名须明确省略并可查看完整路径。 - 侧栏当前标签页标题用 medium(测宽与渲染同一字重),分组标题用 medium;正文和辅助说明保持 regular。 @@ -96,7 +96,17 @@ Git 的新增/修改标记种子为绿 `#2F8A52` / `#4CC27A`、琥珀 `#B7791F` 目录右对齐、从开头省略。History 区以细线分隔,标题 32px;提交行 26px,1px 连线 + 7px 节点, HEAD 实心、其余空心环;整页只有一条 lane 时连线与节点改用中性色;时间列固定宽度右对齐, HEAD 引用用淡底胶囊。 -- **设置**:章节标题 > 标签 > 描述;开关/滑块/主操作共享强调色;分隔线弱于控件边界。 +- **设置**:页标题 > 分组标题 > 标签 > 描述。左侧导航用 `Neutrals.rail` 底与其 surface 阶,右缘细分隔线; + 搜索框 28px 浅底(圆角 7);导航行 28px、圆角 7、左右内边距 8,当前项为 selected 阶 + medium,不用强调色。 + 页标题立在 48px 标题栏带内;分组标题 28px、11.5/16rem medium 辅助色;分组之间是 0.5px 分隔线,上下各 16px。 + 设置行:标签正文色 regular,描述 12/16rem 辅助色,行最低 28px、内边距 8(hover/搜索命中的底色外扩 8px); + 搜索命中行用浅中性底,不用强调色。输入框与下拉框 28px,按钮、分段控件、步进器 26px(圆角 6); + 输入框/下拉框/分段轨道/次要按钮都用同一浅底(surface 半阶)且无描边,分段当前格为 selected 阶 + medium。 + 每个视图唯一的主操作(保存主题草稿、连接、安装更新)用反色中性,同 Git 提交按钮;不可执行时回到浅底。 + 开关与滑块仍用强调色。SSH 主机列表:标题行带 26px 图标按钮,28px 浅底搜索,分组标题 22px, + 主机行 42px 两行(标题正文、地址 11.5/16rem 辅助色);主机表单字段名为辅助色右对齐标签列。 + 主题卡片为浅底无描边,展开时为 selected 阶;主题面板与内容同色、左缘细分隔线,标题立在标题栏带内。 + 快捷键键帽为浅底无描边,行间 0.5px 分隔线。 - **菜单与命令面板**:亮度抬升、有限阴影;当前键盘行清楚,普通项不着色;小窗口内可滚动。 - **工作区切换器**:760px 浮层,距顶 112px,圆角 12px;搜索行 48px(右侧 `esc` 键帽), 主体 420px 分两栏、只用细线分隔,页脚 40px(左「新建工作区」幽灵按钮,右键帽提示 diff --git a/src/ui/diff_overlay.rs b/src/ui/diff_overlay.rs index 61e746e7..e1cebb5e 100644 --- a/src/ui/diff_overlay.rs +++ b/src/ui/diff_overlay.rs @@ -852,7 +852,7 @@ impl Tty7App { /// The two views, as a switch rather than a control. /// - /// Not [`Tty7App::segmented_on`]: that one is a bordered track, which is + /// Not [`Tty7App::segmented_on`]: that one is a filled track, which is /// right in a settings row, where it ends a line of prose and has to /// announce itself as something you operate. On a title bar it was the /// only bordered thing on the strip — the close tile beside it is a bare diff --git a/src/ui/right_panel.rs b/src/ui/right_panel.rs index 17a00739..856875a9 100644 --- a/src/ui/right_panel.rs +++ b/src/ui/right_panel.rs @@ -86,7 +86,7 @@ const PROC_ROW_H: f32 = 26.; const PROC_INDENT: f32 = 14.; /// The space between two sections of the Info tab. -const SECTION_GAP: f32 = 16.; +pub(crate) const SECTION_GAP: f32 = 16.; /// The Session label column's floor, gap included, at the default 16px rem. const INFO_LABEL_MIN: f32 = 76.; diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 6e66e41e..08e47627 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -11,7 +11,6 @@ use gpui_component::link::Link; use gpui_component::menu::{ContextMenuExt as _, DropdownMenu as _, PopupMenu, PopupMenuItem}; use gpui_component::notification::{Notification, NotificationType}; use gpui_component::select::{SearchableVec, Select, SelectEvent, SelectState}; -use gpui_component::sidebar::{Sidebar, SidebarCollapsible, SidebarMenu, SidebarMenuItem}; use gpui_component::slider::{Slider, SliderState}; use gpui_component::{ ActiveTheme as _, Disableable as _, Icon, IconName, IndexPath, Selectable as _, Sizable as _, @@ -34,14 +33,13 @@ use crate::core::ssh_profile::{ }; use crate::daemon::protocol::{SshTestNeed, SshTestReport}; use crate::ui::app::{ - FONT_SIZE_STEP, LINE_HEIGHT_STEP, TILE_GLYPH_LINE, TILE_SIZE, TITLE_BAR_HEIGHT, ThemeEdit, - Tty7App, UI_FONT_SIZE_STEP, + CONTENT_INSET, FONT_SIZE_STEP, LINE_HEIGHT_STEP, TILE_GLYPH, TILE_GLYPH_LINE, TILE_SIZE, + TITLE_BAR_HEIGHT, ThemeEdit, Tty7App, UI_FONT_SIZE_STEP, }; use crate::ui::host_ops::HostId; use crate::ui::i18n::{L10nKey, t, t_fmt, t_plural}; use crate::ui::presets; use crate::ui::rounding; -use crate::ui::rounding::RoundedCorners as _; /// The settings nav, the SSH host list, the theme panel, and the padding each /// page sets — the chrome a row has to share the window with. @@ -62,16 +60,16 @@ const PAGE_PAD: f32 = 80.; const NARROWEST_WINDOW: f32 = 640.; /// The narrowest each list is still itself: a nav item that still shows a label -/// beside its icon (40 of icon, gap and padding, then the longest label), a -/// host row that still shows a name, a theme card that is still a recognisable -/// picture of a theme. +/// beside its icon (64 of list inset, row padding, icon and gap, then the +/// longest label), a host row that still shows a name, a theme card that is +/// still a recognisable picture of a theme. /// /// The nav floor is sized for the longest nav label in *any* locale, not the -/// one the developer happens to be reading. `SidebarMenuItem` clips its label -/// rather than eliding it, so a floor that fits English cuts a glyph in half in -/// Chinese and Japanese: at 140 the zh-CN "窗口与标签页" lost the right half of -/// its last character. The widest is ja-JP "ウィンドウとタブ" — 8 full-width -/// kana beside the icon, which is 36 more than the 6-glyph Chinese label needs. +/// one the developer happens to be reading: at 140 the zh-CN "窗口与标签页" +/// lost the right half of its last character, back when the nav clipped its +/// labels instead of eliding them. The widest is ja-JP "ウィンドウとタブ" — 8 +/// full-width kana, 112 at the default 14px, which is exactly what the row's +/// 64 leave of this. const NAV_W_MIN: f32 = 176.; const SSH_LIST_W_MIN: f32 = 180.; const THEME_PANEL_W_MIN: f32 = 240.; @@ -243,6 +241,48 @@ fn ui_scale(cx: &App) -> f32 { /// without being truncated. const FIELD_W: f32 = 260.; +/// How tall a text field or a dropdown is: the rail's search field and +/// workspace chip, so a field in settings is the same object as the one a +/// reader types a tab filter into. +const FIELD_H: f32 = 28.; + +/// How tall a button, a segmented track or a stepper is, and how round: the +/// rail's header tiles and the Changes tab's commit button. A notch under +/// [`FIELD_H`], because a field holds text a reader types and a control only +/// holds its own label. +const CONTROL_H: f32 = crate::ui::tab_strip::RAIL_TILE; +const CONTROL_RADIUS: f32 = crate::ui::tab_strip::RAIL_TILE_RADIUS; + +/// One line of navigation — a nav item, a host, a section heading — at the +/// right panel's Session row height. +const ROW_H: f32 = 28.; + +/// How far a row pads its text in from its own edge, which is how far its +/// hover fill bleeds past that text. The list around it sits `CONTENT_INSET` +/// in from its column, so the text lands 20px in and the fill 12px, the same +/// as the tab rail's rows. +const ROW_PAD: f32 = 8.; + +/// How far a segmented control's cells sit inside its track, and apart from +/// each other: enough for the track to show round the picked cell, so the +/// pick reads as a chip resting in a groove rather than a painted-in slot. +const SEGMENT_INSET: f32 = 2.; + +/// The liveness dot a host row leads with, and the gutter it stands in on +/// every row — the Defaults row and the group headings keep the column, so +/// every title starts on the same one. +const SSH_DOT: f32 = 6.; + +/// The page's type ladder, in rems: the right panel's, so a label here is the +/// size of a label there. +const TEXT: f32 = crate::ui::right_panel::TEXT; +const META: f32 = crate::ui::right_panel::META; +const HEADING: f32 = crate::ui::right_panel::HEADING; + +/// The space between two sections of a page, which is also what sets a +/// hairline apart from the rows on either side of it. +const SECTION_GAP: f32 = crate::ui::right_panel::SECTION_GAP; + /// The host editor's own two numbers: the column its labels stand in, and how /// wide a field beside one grows to. The label column fits the longest field /// name in any locale at the default interface font; the field is wider than @@ -1950,6 +1990,139 @@ fn field_note(message: impl Into, cx: &App) -> Div { .child(message.into()) } +/// The faint fill every value-holding control on the page wears: half a rung +/// up the surface's own ladder, the Changes tab's message box. Half and not a +/// whole one because the hover rung is what a row wears while the pointer is +/// on it, and a field wears its fill all the time. +fn field_fill(sf: presets::Surface) -> gpui::Hsla { + rgb(presets::mix(sf.base, sf.hover, 0.5)).into() +} + +/// A control that holds a value — a text field, a dropdown — as a filled +/// neutral pill with no outline, so every box a reader types or picks in is +/// one shape. +/// +/// The outline is painted transparent rather than dropped, so the control +/// keeps the 1px its own layout was measured with; a `Select` paints its +/// focus ring after this, so keyboard focus still shows on a dropdown. +fn filled(control: S, cx: &App) -> S { + control + .bg(field_fill(cx.global::().window)) + .border_color(cx.theme().transparent) + .rounded(rounding::ROW_RADIUS) +} + +/// A text field at [`FIELD_H`]. A floor rather than a height, so a field that +/// grows with its content still can. +fn field(input: Input, cx: &App) -> Input { + filled(input, cx).min_h(px(FIELD_H)) +} + +/// A dropdown at [`FIELD_H`]. +fn dropdown(select: S, cx: &App) -> S { + filled(select, cx).h(px(FIELD_H)) +} + +/// A secondary action: the fields' own [`field_fill`], the page's ink, no +/// outline. +/// +/// The fill is set on the button as well as on its variant, because a +/// `Custom` variant thins its resting colour to a fifth of itself and a +/// quiet button would come out as no button at all. Disabled, it keeps the +/// fill and loses its label to the variant's greyed ink — still a button, +/// just not one that is asking for anything. +fn quiet_button(button: Button, enabled: bool, cx: &App) -> Button { + let sf = cx.global::().window; + let fill = field_fill(sf); + button + .custom( + ButtonCustomVariant::new(cx) + .color(fill) + .foreground(cx.theme().foreground) + .hover(rgb(sf.hover).into()) + .active(rgb(sf.pressed).into()), + ) + .disabled(!enabled) + .h(px(CONTROL_H)) + .px(px(10.)) + .rounded(px(CONTROL_RADIUS)) + .bg(fill) +} + +/// The one action a view is for: an inverted neutral, the page's ink as the +/// fill and its surface as the label — the Changes tab's commit button — so +/// it is the one solid shape on the page without borrowing the accent the +/// switches and the focus rings already spend. With nothing to act on it +/// sinks back to [`quiet_button`] rather than advertise an action that +/// cannot run. +fn primary_button(button: Button, enabled: bool, cx: &App) -> Button { + if !enabled { + return quiet_button(button, false, cx); + } + let fg = cx.theme().foreground; + // The opaque surface, not `theme.background`, which carries the window's + // transparency when one is configured. + let ink: gpui::Hsla = rgb(cx.global::().window.base).into(); + button + .custom( + ButtonCustomVariant::new(cx) + .color(fg) + .foreground(ink) + .hover(fg.blend(ink.opacity(0.14))) + .active(fg.blend(ink.opacity(0.24))), + ) + .h(px(CONTROL_H)) + .px(px(12.)) + .rounded(px(CONTROL_RADIUS)) + .bg(fg) + .font_weight(FontWeight::MEDIUM) +} + +/// An icon-only button in a list's header or at the end of a row: the rail's +/// 26px header tile, neutral glyph, faint hover. +fn settings_tile(button: Button, cx: &App) -> Button { + crate::ui::tab_strip::chrome_tile_sized( + button, + crate::ui::tab_strip::RAIL_TILE, + crate::ui::tab_strip::RAIL_TILE_GLYPH, + false, + cx, + ) + .rounded(px(CONTROL_RADIUS)) +} + +/// The title a page or a detail pane opens with — the one line on the page +/// set above body size, so everything under it can stay quiet. +fn page_title(title: impl Into, cx: &App) -> Div { + div() + .text_base() + .font_weight(FontWeight::SEMIBOLD) + .text_color(cx.theme().foreground) + .child(title.into()) +} + +/// A detail pane's title (a host, the SSH defaults). It stays under the +/// title-bar band rather than in it, as the page titles are: the band is the +/// window's drag handle and the close tile's, and a host's buttons share this +/// line. +fn detail_title(title: impl Into, cx: &App) -> Div { + page_title(title, cx).flex().items_center().min_h(px(ROW_H)) +} + +/// A heading over a run of rows: a half step under captions, medium, in the +/// muted ink — the right panel's Session/Processes headings — so it names +/// the group without competing with the labels under it. +fn heading_text(title: impl Into, cx: &App) -> Div { + div() + .flex() + .items_center() + .min_h(px(ROW_H)) + .text_size(gpui::rems(HEADING)) + .font_weight(FontWeight::MEDIUM) + .text_color(cx.theme().muted_foreground) + .child(title.into()) +} + /// Which of the three proxy fields a connection would actually go through. /// They read as three independent settings and are not: `map_proxy` picks the /// first one filled, in this order, and ignores the rest without a word. @@ -2353,7 +2526,7 @@ impl Tty7App { .unwrap_or(0), ) }); - let mut list = v_flex().gap_3(); + let mut list = v_flex().gap(px(ROW_PAD)); for (index, entry) in matches.iter().enumerate() { let title = entry.title; let section = if title == L10nKey::SettingsSearchKeybindingsTitle { @@ -2368,11 +2541,15 @@ impl Tty7App { let path = format!("{} › {}", t(entry.section.title()), t(title)); let row = v_flex() .id(SharedString::from(format!("search-result-{title:?}"))) - .px_4() - .py_3() + // No frame at rest — a result is a row of the page it came + // from, and a stack of outlined cards read as a form to fill + // in. The frame is kept, transparent, so the keyboard's + // current result can draw its focus ring without moving. + .px(px(CONTENT_INSET)) + .py(px(ROW_PAD)) .rounded(rounding::CARD_RADIUS) .border_1() - .border_color(cx.theme().border.opacity(0.65)) + .border_color(cx.theme().transparent) .anchor_scroll( self.active_settings() .filter(|s| s.search_selection == index) @@ -2381,7 +2558,7 @@ impl Tty7App { .when( self.active_settings() .is_some_and(|s| s.search_selection == index), - |v| v.border_color(cx.theme().primary), + |v| v.border_color(cx.theme().ring), ) .child( Button::new(SharedString::from(format!("search-path-{title:?}"))) @@ -2396,13 +2573,17 @@ impl Tty7App { self.settings_row( t(title), entry.description(), - Button::new(SharedString::from(format!("search-open-{title:?}"))) - .label(t(L10nKey::SettingsOpenSetting)) - .small() - .on_click(cx.listener(move |this, _, window, cx| { - this.navigate_settings(section, Some(title), window, cx) - })) - .into_any_element(), + quiet_button( + Button::new(SharedString::from(format!("search-open-{title:?}"))) + .label(t(L10nKey::SettingsOpenSetting)) + .small(), + true, + cx, + ) + .on_click(cx.listener(move |this, _, window, cx| { + this.navigate_settings(section, Some(title), window, cx) + })) + .into_any_element(), cx, ) .into_any_element() @@ -2495,7 +2676,7 @@ impl Tty7App { // settings is open. let background_layers = crate::ui::app::overlay_surface_layers(cx); let (foreground, header_muted) = (theme.foreground, theme.muted_foreground); - let note_bg = theme.secondary.opacity(0.5); + let note_bg = field_fill(cx.global::().window); let (focus_handle, section, theme_panel_open, search) = match self.active_settings() { Some(s) => ( @@ -2561,87 +2742,157 @@ impl Tty7App { let prof = crate::ui::perf::enabled() .then(|| (std::time::Instant::now(), section.profile_label())); - let nav_item = |label: &'static str, target: SettingsSection, icon: Icon| { - let view = cx.entity(); + // The nav is the page's rail: the tab rail's own tinted fill and its + // surface ladder, a hairline on the side it meets the page, and rows + // that are the tab rail's rows — 7px pills whose current one is a + // neutral rung at medium weight, never the accent. + let rail = cx.global::().rail; + let mut nav_body = v_flex() + .id("settings-nav") + .flex_1() + .min_h_0() + .overflow_y_scroll() + .gap(px(1.)) + .px(px(CONTENT_INSET)); + for (i, target) in SettingsSection::ALL.into_iter().enumerate() { + let current = section.navigation_section() == target; let count = if query.is_empty() { 0 } else { section_match_count(target, &query) }; - let item = SidebarMenuItem::new(label) - .icon(icon) - .active(section.navigation_section() == target) - .on_click(move |_, window, cx| { - view.update(cx, |this, cx| { + let ink: gpui::Hsla = rgb(match current { + true => rail.text_selected, + false => rail.text_resting, + }) + .into(); + nav_body = nav_body.child( + h_flex() + .id(("settings-nav-item", i)) + .h(px(ROW_H)) + .flex_shrink_0() + .items_center() + .gap_2() + .px(px(ROW_PAD)) + .rounded(rounding::ROW_RADIUS) + .cursor_pointer() + .text_size(gpui::rems(TEXT)) + .text_color(ink) + .when(current, |row| { + row.bg(rgb(rail.selected)).font_weight(FontWeight::MEDIUM) + }) + .when(!current, |row| row.hover(|s| s.bg(rgb(rail.hover)))) + .active(|s| s.bg(rgb(rail.pressed))) + .on_click(cx.listener(move |this, _, window, cx| { this.navigate_settings(target, None, window, cx) - }); - }); - if count > 0 { - item.suffix(move |_w, _cx| { - div() - .text_xs() - .text_color(header_muted) - .child(format!("({count})")) - }) - } else { - item - } - }; + })) + .child( + target + .icon() + .size(px(TILE_GLYPH)) + .flex_shrink_0() + .text_color(if current { ink } else { header_muted }), + ) + // Elided rather than clipped: at `NAV_W_MIN` the longest + // label in any locale just fits, and past that a trailing + // "…" says there is more where a clip cut a glyph in half. + .child(div().flex_1().min_w_0().truncate().child(t(target.title()))) + .when(count > 0, |row| { + row.child( + div() + .flex_shrink_0() + .text_size(gpui::rems(META)) + .font_weight(FontWeight::NORMAL) + .text_color(header_muted) + .child(count.to_string()), + ) + }), + ); + } - let nav_body = SettingsSection::ALL - .into_iter() - .fold(SidebarMenu::new().gap_2(), |menu, target| { - menu.child(nav_item(t(target.title()), target, target.icon())) - }); + // The search sits where the rail's does, in the same 28px filled well, + // under the window's own title-bar band. + let nav_search = h_flex() + .w_full() + .flex_shrink_0() + .items_center() + .gap(px(7.)) + .h(px(FIELD_H)) + .pl_2() + .pr_1() + .rounded(rounding::ROW_RADIUS) + .bg(field_fill(rail)) + .child( + Icon::new(IconName::Search) + .size(px(12.)) + .flex_shrink_0() + .text_color(header_muted), + ) + .child( + div() + .flex_1() + .min_w_0() + .child(Input::new(&search).appearance(false).pl_0()), + ); - let sidebar = Sidebar::new("settings-sidebar") - .collapsible(SidebarCollapsible::None) + // A filter, not an action: it toggles like a nav row, and when it is + // on it wears the current row's rung rather than a button's fill. + let modified_only = self.active_settings().is_some_and(|s| s.modified_only); + let modified_filter = h_flex() + .id("settings-modified-filter") + .h(px(ROW_H)) + .items_center() + .px(px(ROW_PAD)) + .rounded(rounding::ROW_RADIUS) + .cursor_pointer() + .text_size(gpui::rems(META)) + .when(modified_only, |row| { + row.bg(rgb(rail.selected)) + .text_color(rgb(rail.text_selected)) + .font_weight(FontWeight::MEDIUM) + }) + .when(!modified_only, |row| { + row.text_color(header_muted) + .hover(|s| s.bg(rgb(rail.hover))) + }) + .active(|s| s.bg(rgb(rail.pressed))) + .on_click(cx.listener(|this, _, _window, cx| { + if let Some(s) = this.active_settings_mut() { + s.modified_only = !s.modified_only; + } + this.autoselect_settings_search(cx); + })) + .child(t(L10nKey::SettingsModifiedOnly)); + + let sidebar = v_flex() + .id("settings-sidebar") .w(px(cols.nav)) - .header( + .h_full() + .flex_shrink_0() + .bg(rgb(rail.base)) + .border_r_1() + .border_color(cx.theme().sidebar_border) + .child( v_flex() .w_full() - .px_2() - .gap_2() - .pt(px(crate::ui::app::TITLE_BAR_HEIGHT)) - .pb_1() + .flex_shrink_0() + .gap(px(10.)) + .px(px(CONTENT_INSET)) + .pt(px(TITLE_BAR_HEIGHT)) + .pb(px(SECTION_GAP)) .child( - div() - .text_xs() - .font_weight(FontWeight::MEDIUM) - .text_color(header_muted) - .child(t(L10nKey::SettingsHeader)), + heading_text(t(L10nKey::SettingsHeader), cx) + .min_h(px(0.)) + .px(px(ROW_PAD)), ) - .child( - h_flex() - .items_center() - .gap_2() - .child( - Icon::empty() - .path("stock/icons/search.svg") - .size(px(16.)) - .text_color(header_muted), - ) - .child( - div() - .flex_1() - .min_w_0() - .child(Input::new(&search).appearance(false).pl_0()), - ), - ), + .child(nav_search), ) .child(nav_body) - .footer( - Button::new("settings-modified-filter") - .label(t(L10nKey::SettingsModifiedOnly)) - .ghost() - .small() - .selected(self.active_settings().is_some_and(|s| s.modified_only)) - .on_click(cx.listener(|this, _, _window, cx| { - if let Some(s) = this.active_settings_mut() { - s.modified_only = !s.modified_only; - } - this.autoselect_settings_search(cx); - })), + .child( + div() + .flex_shrink_0() + .p(px(CONTENT_INSET)) + .child(modified_filter), ); let content = if searching { @@ -2668,10 +2919,10 @@ impl Tty7App { div() .id("settings-no-match") .anchor_scroll(self.active_settings().map(|s| s.search_anchor.clone())) - .mb_6() - .px_3() - .py_2() - .rounded_lg() + .mb(px(SECTION_GAP)) + .px(px(CONTENT_INSET)) + .py(px(ROW_PAD)) + .rounded(rounding::ROW_RADIUS) .bg(note_bg) .text_sm() .text_color(header_muted) @@ -2710,12 +2961,16 @@ impl Tty7App { )), ) .child( - Button::new("retry-ssh-settings-save") - .label(t(L10nKey::SettingsRetrySave)) - .small() - .on_click(cx.listener(|this, _, _window, cx| { - this.persist_settings_config(cx) - })), + quiet_button( + Button::new("retry-ssh-settings-save") + .label(t(L10nKey::SettingsRetrySave)) + .small(), + true, + cx, + ) + .on_click(cx.listener( + |this, _, _window, cx| this.persist_settings_config(cx), + )), ), ) }, @@ -2757,23 +3012,29 @@ impl Tty7App { // this box's laid-out bounds — then leaves most of the page // outside the scroll range. `flex_shrink_0` does not buy // its way out of that; only staying a block does. - div().w_full().px_10().py_8().child( + // + // The page's title stands in the window's title-bar band, + // level with the close tile at the other end, so the page + // under it starts where the nav's search does. + div().w_full().px_10().pb_8().child( div() .w_full() .max_w(px(READING_COLUMN * ui_scale)) .mx_auto() - .children(no_match_note) .child( - div() - .mb_5() - .text_xl() - .font_weight(FontWeight::SEMIBOLD) - .child(t(if searching { + page_title( + t(if searching { L10nKey::SettingsSearchResults } else { section.title() - })), + }), + cx, + ) + .flex() + .items_center() + .h(px(TITLE_BAR_HEIGHT)), ) + .children(no_match_note) .when(self.theme_draft_dirty(), |v| { v.child( v_flex() @@ -2796,14 +3057,16 @@ impl Tty7App { h_flex() .gap_2() .child( - Button::new("save-theme-draft") - .label(t(L10nKey::SettingsSaveChanges)) - .small() - .on_click(cx.listener( - |this, _, window, cx| { - this.save_theme_draft(window, cx); - }, - )), + primary_button( + Button::new("save-theme-draft") + .label(t(L10nKey::SettingsSaveChanges)) + .small(), + true, + cx, + ) + .on_click(cx.listener(|this, _, window, cx| { + this.save_theme_draft(window, cx); + })), ) .child( Button::new("cancel-theme-draft") @@ -2831,14 +3094,16 @@ impl Tty7App { &[("error", &error)], ))) .child( - Button::new("retry-settings-save") - .label(t(L10nKey::SettingsRetrySave)) - .small() - .on_click(cx.listener( - |this, _, _window, cx| { - this.persist_settings_config(cx) - }, - )), + quiet_button( + Button::new("retry-settings-save") + .label(t(L10nKey::SettingsRetrySave)) + .small(), + true, + cx, + ) + .on_click(cx.listener(|this, _, _window, cx| { + this.persist_settings_config(cx) + })), ), ) }, @@ -3077,23 +3342,15 @@ impl Tty7App { } fn header_text(&self, title: &str, cx: &Context) -> Div { - div() - .text_base() - .font_weight(FontWeight::SEMIBOLD) - .text_color(cx.theme().foreground) - .child(title.to_string()) + heading_text(title.to_string(), cx) } - /// A heading *inside* a section — quieter than `section_header`, for - /// breaking a long run of rows into groups you can scan. + /// A heading *inside* a section, for breaking a long run of rows into + /// groups you can scan. The same heading as `section_header` — both are + /// band labels now, set apart by weight and ink rather than by size — with + /// half a section's air above it, since what it divides is one section. fn subgroup_header(&self, key: L10nKey, cx: &Context) -> Div { - div() - .pt_4() - .pb_1() - .text_xs() - .font_weight(FontWeight::MEDIUM) - .text_color(cx.theme().muted_foreground) - .child(t(key)) + heading_text(t(key), cx).mt(px(SECTION_GAP / 2.)) } /// The scroll anchor for the first thing on the page the query matched, @@ -3128,7 +3385,7 @@ impl Tty7App { pub(crate) fn section_header(&self, title: &str, cx: &Context) -> Stateful
{ self.header_text(title, cx) - .mb_4() + .mb_1() .id(settings_header_id(title)) .anchor_scroll(self.first_hit_anchor(title, cx)) } @@ -3140,21 +3397,26 @@ impl Tty7App { cx: &Context, ) -> Stateful
{ v_flex() - .mb_4() - .gap_1() + .mb_2() .id(settings_header_id(title)) .anchor_scroll(self.first_hit_anchor(title, cx)) .child(self.header_text(title, cx)) .child( div() - .text_xs() + .text_size(gpui::rems(META)) .text_color(cx.theme().muted_foreground) .child(desc.into()), ) } + /// The seam between two sections: a half-pixel hairline in the divider + /// ink with `SECTION_GAP` of air either side — a pane edge's line, not a + /// table rule, so a page reads as one surface broken into bands. pub(crate) fn section_rule(&self, cx: &Context) -> Div { - div().h(px(1.)).my_7().bg(cx.theme().sidebar_border) + div() + .h(px(0.5)) + .my(px(SECTION_GAP)) + .bg(cx.theme().sidebar_border) } pub(crate) fn settings_row( @@ -3222,21 +3484,24 @@ impl Tty7App { // Measured, not `flex_wrap`: wrapping made the label column size to its // description, which then ran out past the row on every wide page. let stacked = self.settings_row_under(STACK_ROW_BELOW, cx); + // Label in body ink at regular weight, description a size down in the + // muted ink: the two are told apart by colour, the way the right + // panel's Session fields are, and weight is left for the heading + // above them and the current nav item beside them. let labels = v_flex() - .gap_1() + .gap(px(2.)) .min_w_0() .when(gated, |col| col.opacity(0.45)) .child( div() - .text_sm() - .font_weight(FontWeight::MEDIUM) + .text_size(gpui::rems(TEXT)) .text_color(theme.foreground) .child(label), ) .when(!desc.is_empty(), |col| { col.child( div() - .text_xs() + .text_size(gpui::rems(META)) .text_color(theme.muted_foreground) .child(desc), ) @@ -3279,11 +3544,19 @@ impl Tty7App { .when(!stacked, |row| { row.flex_row().items_center().justify_between().gap_8() }) - .py_3() - .px_3() - .mx_neg_3() - .rounded(rounding::CARD_RADIUS) - .when(hit, |row| row.bg(theme.accent)) + // At least a nav row tall, padded like one and pulled back out by + // the same amount, so the label sits on the column the heading + // above it does and a search hit's fill bleeds past it evenly. + .min_h(px(ROW_H)) + .py(px(ROW_PAD)) + .px(px(ROW_PAD)) + .mx(px(-ROW_PAD)) + .rounded(rounding::ROW_RADIUS) + // A hit is found, not chosen: the faint neutral fill a current row + // wears, never the accent, which the page keeps for its switches. + .when(hit, |row| { + row.bg(field_fill(cx.global::().window)) + }) // Only the first hit on the page carries the anchor: it is the one // the page scrolls to, and a later row claiming it would drag the // view past the matches above. @@ -3375,10 +3648,9 @@ impl Tty7App { cx: &mut Context, on_pick: impl Fn(&mut Self, usize, &mut Window, &mut Context) + 'static, ) -> AnyElement { - let border = cx.theme().border; + let track = field_fill(sf); let id: SharedString = id.into(); let on_pick = std::rc::Rc::new(on_pick); - let count = options.len() + usize::from(custom_label.is_some()); // The display cells: the fixed buckets, then the custom cell if the // live value matched none of them. let cells: Vec<(String, Option)> = options @@ -3387,21 +3659,22 @@ impl Tty7App { .map(|(i, l)| (l.to_string(), Some(i))) .chain(custom_label.map(|l| (l, None))) .collect(); + // A filled track with no outline and no seams — the fields' own faint + // fill — and the picked cell a rung up the surface ladder at medium + // weight: the same "current" a nav item or a host row wears, rather + // than a box drawn around every option. h_flex() .id(gpui::ElementId::Name(id.clone())) - .h(px(24.)) - .rounded(rounding::TRACK_RADIUS) - .border_1() - .border_color(border) - .bg(gpui::rgb(sf.base)) - .overflow_hidden() + .h(px(CONTROL_H)) + .p(px(SEGMENT_INSET)) + .gap(px(SEGMENT_INSET)) + .rounded(px(CONTROL_RADIUS)) + .bg(track) .children(cells.into_iter().enumerate().map(|(i, (label, bucket))| { // A bucket is highlighted only on an exact match, and the // custom cell (`bucket == None`) exactly when no bucket was. let active = bucket == selected; let on_pick = on_pick.clone(); - let corners = - rounding::segment_corners(i, count, rounding::TRACK_RADIUS, rounding::HAIRLINE); let cell = h_flex() .id(gpui::ElementId::NamedInteger(id.clone(), i as u64)) .items_center() @@ -3409,8 +3682,7 @@ impl Tty7App { .h_full() .px_2p5() .text_sm() - .rounded_corners(corners) - .when(i > 0, |s| s.border_l_1().border_color(border)) + .rounded(px(CONTROL_RADIUS - SEGMENT_INSET)) .when(active, |s| { s.bg(gpui::rgb(sf.selected)) .text_color(gpui::rgb(sf.text_selected)) @@ -3441,66 +3713,68 @@ impl Tty7App { return div().into_any_element(); }; let language_select = state.language_select.clone(); - let foreground = cx.theme().foreground; let muted_fg = cx.theme().muted_foreground; - let control_h = px(24.); - let language_control = Select::new(&language_select) - .small() + let language_control = dropdown(Select::new(&language_select).small(), cx) .w(px(FIELD_W)) - .h(control_h) .menu_max_h(px(224.)) .into_any_element(); v_flex() - .child(self.settings_row(t(L10nKey::SettingsLanguage), t(L10nKey::SettingsLanguageDesc), language_control, cx)) + .child(self.settings_row( + t(L10nKey::SettingsLanguage), + t(L10nKey::SettingsLanguageDesc), + language_control, + cx, + )) .child(self.section_rule(cx)) .child(self.render_window_preferences(true, cx)) .when(cfg!(target_os = "macos"), |this| { + // Laid out like a section: its heading, the sentence that + // explains it, then the one button it offers. this.child(self.section_rule(cx)).child( v_flex() - .gap_2() + .items_start() + .child(heading_text(t(L10nKey::SettingsDefaultTerminal), cx)) .child( div() - .text_sm() - .font_weight(FontWeight::SEMIBOLD) - .text_color(foreground) - .child(t(L10nKey::SettingsDefaultTerminal)), - ) - .child( - div() - .text_xs() + .mb(px(ROW_PAD)) + .text_size(gpui::rems(META)) .text_color(muted_fg) .child(t(L10nKey::SettingsDefaultTerminalDesc)), ) .child( - Button::new("set-default-terminal") - .label(t(L10nKey::SettingsDefaultTerminalSet)) - .small() - .on_click(cx.listener(|_, _, window, cx| { - let message = match crate::core::default_terminal::set_as_default_terminal() { - Ok(()) => t(L10nKey::SettingsDefaultTerminalSetSuccess).to_string(), + quiet_button( + Button::new("set-default-terminal") + .label(t(L10nKey::SettingsDefaultTerminalSet)) + .small(), + true, + cx, + ) + .on_click(cx.listener(|_, _, window, cx| { + let message = + match crate::core::default_terminal::set_as_default_terminal() { + Ok(()) => t(L10nKey::SettingsDefaultTerminalSetSuccess) + .to_string(), Err(error) => t_fmt( L10nKey::SettingsDefaultTerminalSetFailed, &[("error", &error)], ), }; - window.push_notification(message, cx); - })), + window.push_notification(message, cx); + })), ), ) }) - .child(self.section_rule(cx)) .child(self.render_settings_maintenance(cx)) .into_any_element() } fn render_settings_appearance(&self, cx: &mut Context) -> AnyElement { - let theme = cx.theme(); - let foreground = theme.foreground; - let border = theme.border; - let hover_bg = gpui::rgb(cx.global::().window.hover); - let stepper_bg = theme.secondary.opacity(0.35); + let foreground = cx.theme().foreground; + let sf = cx.global::().window; + let hover_bg = gpui::rgb(sf.hover); + let stepper_bg = field_fill(sf); let font_size = self.font_size; let (font_select, font_bold_select, font_italic_select, ui_font_select) = match self.active_settings() { @@ -3524,9 +3798,10 @@ impl Tty7App { .any(|(tag, value)| tag == "liga" && *value != 0) }); - let step = move |id: &'static str, glyph: &'static str, slot: usize| { - let corners = - rounding::segment_corners(slot, 3, rounding::TRACK_RADIUS, rounding::HAIRLINE); + // The segmented control's groove: − and + are cells that light up + // under the pointer, and the value between them is plain text on the + // track rather than a third boxed cell. + let step = move |id: &'static str, glyph: &'static str| { h_flex() .id(id) .items_center() @@ -3536,77 +3811,64 @@ impl Tty7App { .text_sm() .cursor_pointer() .text_color(foreground) - .when(slot > 0, |s| s.border_l_1().border_color(border)) - .rounded_corners(corners) + .rounded(px(CONTROL_RADIUS - SEGMENT_INSET)) .hover(|h| h.bg(hover_bg)) + .active(|s| s.bg(gpui::rgb(sf.pressed))) .child(glyph) }; - let control_h = px(24.); let stepper_row = move |dec: Stateful
, value: String, inc: Stateful
| { h_flex() .items_center() - .gap_3() + .h(px(CONTROL_H)) + .p(px(SEGMENT_INSET)) + .rounded(px(CONTROL_RADIUS)) + .bg(stepper_bg) + .child(dec) .child( - h_flex() - .items_center() - .h(control_h) - .rounded(rounding::TRACK_RADIUS) - .bg(stepper_bg) - .border_1() - .border_color(border) - .overflow_hidden() - .child(dec) - .child( - div() - .min_w(px(40.)) - .border_l_1() - .border_color(border) - .py_1() - .text_center() - .text_sm() - .text_color(foreground) - .child(value), - ) - .child(inc), + div() + .min_w(px(40.)) + .text_center() + .text_sm() + .text_color(foreground) + .child(value), ) + .child(inc) .into_any_element() }; let font_size_control = stepper_row( - step("font-dec", "−", 0).on_click( + step("font-dec", "−").on_click( cx.listener(|this, _, _w, cx| this.change_font_size(-FONT_SIZE_STEP, cx)), ), format!("{:.0}", font_size), - step("font-inc", "+", 2) + step("font-inc", "+") .on_click(cx.listener(|this, _, _w, cx| this.change_font_size(FONT_SIZE_STEP, cx))), ); let ui_font_size = self.ui_font_size(cx); let ui_font_size_control = stepper_row( - step("ui-font-dec", "−", 0).on_click( + step("ui-font-dec", "−").on_click( cx.listener(|this, _, _w, cx| this.change_ui_font_size(-UI_FONT_SIZE_STEP, cx)), ), format!("{ui_font_size:.0}"), - step("ui-font-inc", "+", 2).on_click( + step("ui-font-inc", "+").on_click( cx.listener(|this, _, _w, cx| this.change_ui_font_size(UI_FONT_SIZE_STEP, cx)), ), ); let line_height = self.line_height; let line_height_control = stepper_row( - step("lh-dec", "−", 0).on_click( + step("lh-dec", "−").on_click( cx.listener(|this, _, _w, cx| this.change_line_height(-LINE_HEIGHT_STEP, cx)), ), format!("{:.2}", line_height), - step("lh-inc", "+", 2).on_click( + step("lh-inc", "+").on_click( cx.listener(|this, _, _w, cx| this.change_line_height(LINE_HEIGHT_STEP, cx)), ), ); let font_dropdown = |state: &Entity>>| { - Select::new(state) - .small() + dropdown(Select::new(state).small(), cx) .w(px(FIELD_W)) - .h(control_h) .search_placeholder(crate::ui::i18n::t(crate::ui::i18n::L10nKey::SearchFonts)) .menu_max_h(px(224.)) .into_any_element() @@ -3783,10 +4045,8 @@ impl Tty7App { .active_settings() .map(|s| s.window_backdrop_select.clone()) { - Some(select) => Select::new(&select) - .small() + Some(select) => dropdown(Select::new(&select).small(), cx) .w(px(FIELD_W)) - .h(px(24.)) .menu_max_h(px(224.)) .into_any_element(), None => div().into_any_element(), @@ -3865,12 +4125,16 @@ impl Tty7App { .when(overridden, |this| { this.child( h_flex().mt_2().child( - Button::new("follow-theme-window") - .label(t(L10nKey::FollowTheme)) - .small() - .on_click(cx.listener(|this, _, window, cx| { - this.reset_window_overrides(window, cx) - })), + quiet_button( + Button::new("follow-theme-window") + .label(t(L10nKey::FollowTheme)) + .small(), + true, + cx, + ) + .on_click(cx.listener(|this, _, window, cx| { + this.reset_window_overrides(window, cx) + })), ), ) }) @@ -3886,10 +4150,14 @@ impl Tty7App { fn render_custom_themes(&self, cx: &mut Context) -> AnyElement { let editor = self.active_settings().and_then(|s| s.theme_editor.as_ref()); - let folder_button = Button::new("open-themes-folder") - .label(t(L10nKey::SettingsOpenThemesFolder)) - .small() - .on_click(cx.listener(|this, _, w, cx| this.open_themes_folder(w, cx))); + let folder_button = quiet_button( + Button::new("open-themes-folder") + .label(t(L10nKey::SettingsOpenThemesFolder)) + .small(), + true, + cx, + ) + .on_click(cx.listener(|this, _, w, cx| this.open_themes_folder(w, cx))); if let Some(editor) = editor { let label_of = |&(edit, ref state): &(ThemeEdit, Entity)| { @@ -3915,14 +4183,18 @@ impl Tty7App { .gap_2() .w(px(FIELD_W)) .child( - Button::new("pick-theme-image") - .label(if image.is_some() { - t(L10nKey::SettingsChangeThemeImage) - } else { - t(L10nKey::SettingsChooseThemeImage) - }) - .small() - .on_click(cx.listener(|this, _, _w, cx| this.pick_theme_image(cx))), + quiet_button( + Button::new("pick-theme-image") + .label(if image.is_some() { + t(L10nKey::SettingsChangeThemeImage) + } else { + t(L10nKey::SettingsChooseThemeImage) + }) + .small(), + true, + cx, + ) + .on_click(cx.listener(|this, _, _w, cx| this.pick_theme_image(cx))), ) .when_some(image_name, |this, name| { this.child( @@ -3935,12 +4207,16 @@ impl Tty7App { .child(name), ) .child( - Button::new("remove-theme-image") - .label(t(L10nKey::SettingsRemoveThemeImage)) - .small() - .on_click(cx.listener(|this, _, window, cx| { - this.remove_theme_image(window, cx) - })), + quiet_button( + Button::new("remove-theme-image") + .label(t(L10nKey::SettingsRemoveThemeImage)) + .small(), + true, + cx, + ) + .on_click( + cx.listener(|this, _, window, cx| this.remove_theme_image(window, cx)), + ), ) }) .into_any_element(); @@ -4008,12 +4284,16 @@ impl Tty7App { h_flex() .gap_3() .child( - Button::new("duplicate-theme") - .label(t(L10nKey::SettingsDuplicateToEdit)) - .small() - .on_click(cx.listener(|this, _, window, cx| { - this.fork_active_theme(window, cx) - })), + quiet_button( + Button::new("duplicate-theme") + .label(t(L10nKey::SettingsDuplicateToEdit)) + .small(), + true, + cx, + ) + .on_click( + cx.listener(|this, _, window, cx| this.fork_active_theme(window, cx)), + ), ) .child(folder_button), ) @@ -4031,7 +4311,9 @@ impl Tty7App { } fn render_settings_ssh(&self, cx: &mut Context) -> AnyElement { - let border = cx.theme().border; + // A pane meeting a pane: the lighter of the two lines, as between the + // nav and the page. + let divider = cx.theme().sidebar_border; let Some((master_scroll, detail_scroll)) = self .active_settings() .map(|s| (s.ssh_master_scroll.clone(), s.ssh_detail_scroll.clone())) @@ -4070,7 +4352,7 @@ impl Tty7App { .w(px(self.settings_columns_now(cx).ssh_list)) .h_full() .border_r_1() - .border_color(border) + .border_color(divider) .child(crate::ui::scrollbar::with_vertical_scrollbar( "ssh-master-scrollbar", master, @@ -4103,17 +4385,60 @@ impl Tty7App { let live = self.live_ssh_profiles(cx); let menu_app = cx.entity().downgrade(); + // The rail's header, rebuilt for a list of hosts: a heading with its + // two tiles at the trailing end, then the 28px filled search well. let header = v_flex() - .gap_2() - .child(self.header_text(t(L10nKey::SettingsHosts), cx)) + .gap(px(10.)) .child( h_flex() .items_center() - .gap_2() + .justify_between() + .pl(px(ROW_PAD)) + .child(self.header_text(t(L10nKey::SettingsHosts), cx)) .child( - Icon::empty() - .path("stock/icons/search.svg") - .size(px(16.)) + h_flex() + .flex_shrink_0() + .gap(px(2.)) + .child( + settings_tile( + Button::new("ssh-profiles-add").icon(Icon::new(IconName::Plus)), + cx, + ) + .tooltip(t(L10nKey::SettingsNewHost)) + .on_click(cx.listener( + |this, _, window, cx| this.add_new_profile(window, cx), + )), + ) + .child( + settings_tile( + Button::new("ssh-profiles-more") + .icon(Icon::empty().path("stock/icons/ellipsis.svg")), + cx, + ) + .tooltip(t(L10nKey::TabTooltipMore)) + .dropdown_menu_with_anchor( + gpui::Anchor::TopRight, + move |menu, _window, _cx| { + Self::ssh_master_menu(menu, &menu_app) + }, + ), + ), + ), + ) + .child( + h_flex() + .w_full() + .items_center() + .gap(px(7.)) + .h(px(FIELD_H)) + .pl_2() + .pr_1() + .rounded(rounding::ROW_RADIUS) + .bg(field_fill(sf)) + .child( + Icon::new(IconName::Search) + .size(px(12.)) + .flex_shrink_0() .text_color(muted), ) .child( @@ -4121,34 +4446,6 @@ impl Tty7App { .flex_1() .min_w_0() .child(Input::new(&filter).appearance(false).pl_0()), - ) - .child( - h_flex() - .flex_shrink_0() - .gap_0p5() - .child( - Button::new("ssh-profiles-add") - .icon(Icon::new(IconName::Plus)) - .ghost() - .small() - .tooltip(t(L10nKey::SettingsNewHost)) - .on_click(cx.listener(|this, _, window, cx| { - this.add_new_profile(window, cx) - })), - ) - .child( - Button::new("ssh-profiles-more") - .icon(Icon::empty().path("stock/icons/ellipsis.svg")) - .ghost() - .small() - .tooltip(t(L10nKey::TabTooltipMore)) - .dropdown_menu_with_anchor( - gpui::Anchor::TopRight, - move |menu, _window, _cx| { - Self::ssh_master_menu(menu, &menu_app) - }, - ), - ), ), ); @@ -4166,7 +4463,7 @@ impl Tty7App { .then_with(|| a.0.cmp(&b.0)) }); - let mut list = v_flex().gap_0p5().w_full().child(self.render_ssh_row( + let mut list = v_flex().gap(px(1.)).w_full().child(self.render_ssh_row( "ssh-defaults-row", t(L10nKey::SettingsDefaults), t(L10nKey::SettingsInheritedByEveryHost), @@ -4226,8 +4523,9 @@ impl Tty7App { } v_flex() - .p_2() - .gap_2() + .px(px(CONTENT_INSET)) + .pb(px(CONTENT_INSET)) + .gap(px(SECTION_GAP)) .pt(px(crate::ui::app::TITLE_BAR_HEIGHT)) .child(header) .child(list) @@ -4276,20 +4574,21 @@ impl Tty7App { } else { IconName::ChevronDown }; + // The rail's group heading: 22px, a half step under captions, medium, + // muted, with the chevron standing in the dot gutter so the name + // starts on the host titles' column. h_flex() .id(SharedString::from(format!("ssh-group-{key}"))) .items_center() - .gap_1() + .gap_2() .w_full() - .mt_2() - .py_1() - // 8 + 10 + 4 puts the group name on the same column as a host - // title, which sits 8 + 6 + 8 past the list edge — and it hands - // the header the same 8px inset the rows hover with. - .px_2() - .rounded_md() + .h(px(22.)) + .mt(px(SECTION_GAP / 2.)) + .px(px(ROW_PAD)) + .rounded(rounding::ROW_RADIUS) .cursor_pointer() - .text_xs() + .text_size(gpui::rems(HEADING)) + .font_weight(FontWeight::MEDIUM) .text_color(muted) .hover(|s| s.bg(gpui::rgb(sf.hover))) .on_mouse_down( @@ -4299,9 +4598,20 @@ impl Tty7App { this.toggle_ssh_group(owned_key.clone(), cx); }), ) - .child(Icon::new(chevron).size(px(10.))) + .child( + div() + .flex_shrink_0() + .w(px(SSH_DOT)) + .flex() + .justify_center() + .child(Icon::new(chevron).size(px(10.))), + ) .child(div().truncate().child(ssh_group_label(key).to_string())) - .child(div().child(format!("· {count}"))) + .child( + div() + .font_weight(FontWeight::NORMAL) + .child(format!("· {count}")), + ) .child(div().flex_1()) .when(collapsed && live_here > 0, |row| { row.child( @@ -4377,15 +4687,17 @@ impl Tty7App { let group_name = SharedString::from(format!("ssh-row-group-{title}")); let hover_group = group_name.clone(); + // A two-line row, the rail's tab-with-branch row: 42px, 7px pill, + // selected on a neutral rung at medium weight. let row = h_flex() .id(element_id) .group(group_name) .items_center() .gap_2() .w_full() - .py_2() - .px_2() - .rounded_md() + .h(px(42.)) + .px(px(ROW_PAD)) + .rounded(rounding::ROW_RADIUS) .when(selected, |r| r.bg(gpui::rgb(sf.selected))) .when(!selected, |r| r.hover(|s| s.bg(gpui::rgb(sf.hover)))) .on_mouse_down(MouseButton::Left, move |ev, window, cx| { @@ -4399,7 +4711,7 @@ impl Tty7App { .child( div() .flex_shrink_0() - .size(px(6.)) + .size(px(SSH_DOT)) .when_some(dot, |d, live| { d.rounded_full() .when(live, |d| d.bg(success)) @@ -4410,10 +4722,9 @@ impl Tty7App { v_flex() .min_w_0() .flex_1() - .gap_0p5() .child( div() - .text_sm() + .text_size(gpui::rems(TEXT)) .truncate() .when(selected, |d| { d.text_color(gpui::rgb(sf.text_selected)) @@ -4424,7 +4735,7 @@ impl Tty7App { ) .child( div() - .text_xs() + .text_size(gpui::rems(HEADING)) .text_color(muted) .truncate() .child(subtitle.into()), @@ -4445,17 +4756,18 @@ impl Tty7App { s.opacity(0.).group_hover(hover_group, |s| s.opacity(1.)) }) .child( - Button::new(("ssh-prof-menu", row_idx)) - .icon(Icon::empty().path("stock/icons/ellipsis.svg")) - .ghost() - .small() - .tooltip(t(L10nKey::TabTooltipMore)) - .dropdown_menu_with_anchor( - gpui::Anchor::TopRight, - move |menu, _window, cx| { - Self::ssh_profile_row_menu(menu, id, cx.theme().danger, &menu_app) - }, - ), + settings_tile( + Button::new(("ssh-prof-menu", row_idx)) + .icon(Icon::empty().path("stock/icons/ellipsis.svg")), + cx, + ) + .tooltip(t(L10nKey::TabTooltipMore)) + .dropdown_menu_with_anchor( + gpui::Anchor::TopRight, + move |menu, _window, cx| { + Self::ssh_profile_row_menu(menu, id, cx.theme().danger, &menu_app) + }, + ), ), ) .context_menu(move |menu, _window, cx| { @@ -4555,8 +4867,7 @@ impl Tty7App { }; let mut body = v_flex() - .gap_1() - .child(self.header_text(heading, cx)) + .child(detail_title(heading, cx)) .child( div() .text_sm() @@ -4565,7 +4876,7 @@ impl Tty7App { ) .child( h_flex() - .mt_3() + .mt(px(SECTION_GAP)) .w_full() .max_w(px(380.)) .gap_2() @@ -4573,52 +4884,60 @@ impl Tty7App { div() .flex_1() .min_w_0() - .child(Input::new(&input).small().w_full()), + .child(field(Input::new(&input).small(), cx).w_full()), ) .child( - Button::new("ssh-quick-connect") - .label(t(L10nKey::Connect)) - .primary() - .small() - .disabled(parsed.is_none()) - .on_click(cx.listener(|this, _, window, cx| { - this.ssh_quick_connect_from_settings(window, cx) - })), + primary_button( + Button::new("ssh-quick-connect") + .label(t(L10nKey::Connect)) + .small(), + parsed.is_some(), + cx, + ) + .on_click(cx.listener(|this, _, window, cx| { + this.ssh_quick_connect_from_settings(window, cx) + })), ), ); if !unlinked.is_empty() { let n = unlinked.len(); let names = unlinked.join(", "); + // An aside, not a form: the page's faint fill rather than an + // outlined card. body = body.child( h_flex() - .mt_6() + .mt(px(SECTION_GAP * 2.)) .gap_3() .items_center() .w_full() .max_w(px(460.)) - .p_3() - .rounded_lg() - .border_1() - .border_color(cx.theme().border) + .px(px(CONTENT_INSET)) + .py(px(10.)) + .rounded(rounding::ROW_RADIUS) + .bg(field_fill(cx.global::().window)) .child( v_flex() .flex_1() .min_w_0() .gap_0p5() - .child(div().text_sm().font_weight(FontWeight::MEDIUM).child(t_fmt( + .child(div().text_sm().child(t_fmt( L10nKey::SettingsMoreInSshConfig, &[("count", &n.to_string())], ))) .child(div().text_xs().text_color(muted).truncate().child(names)), ) .child( - Button::new("ssh-empty-import") - .label(t(L10nKey::Link)) - .small() - .on_click(cx.listener(|this, _, window, cx| { - this.import_ssh_config_profiles(window, cx) - })), + quiet_button( + Button::new("ssh-empty-import") + .label(t(L10nKey::Link)) + .small(), + true, + cx, + ) + .on_click(cx.listener(|this, _, window, cx| { + this.import_ssh_config_profiles(window, cx) + })), ), ); } @@ -4660,13 +4979,17 @@ impl Tty7App { self.settings_row( t(L10nKey::SettingsImportAliases), t(L10nKey::SettingsImportAliasesDesc), - Button::new("ssh-defaults-import") - .label(t(L10nKey::SettingsImportNow)) - .small() - .on_click(cx.listener(|this, _, window, cx| { - this.import_ssh_config_profiles(window, cx) - })) - .into_any_element(), + quiet_button( + Button::new("ssh-defaults-import") + .label(t(L10nKey::SettingsImportNow)) + .small(), + true, + cx, + ) + .on_click(cx.listener(|this, _, window, cx| { + this.import_ssh_config_profiles(window, cx) + })) + .into_any_element(), cx, ), ); @@ -4674,9 +4997,8 @@ impl Tty7App { v_flex() .child( v_flex() - .gap_1() - .mb_6() - .child(self.header_text(t(L10nKey::SettingsDefaults), cx)) + .mb(px(SECTION_GAP)) + .child(detail_title(t(L10nKey::SettingsDefaults), cx)) .child( div() .text_sm() @@ -5821,19 +6143,12 @@ impl Tty7App { }) .items_start() .justify_between() - .gap_4() + .gap_x_4() .child( v_flex() .min_w_0() .flex_1() - .gap_1() - .child( - div() - .text_lg() - .font_weight(FontWeight::SEMIBOLD) - .truncate() - .child(title), - ) + .child(detail_title(title, cx).truncate()) .child( h_flex() .gap_1p5() @@ -5861,11 +6176,15 @@ impl Tty7App { .max_w_full() .flex_wrap() .gap_2() + .min_h(px(ROW_H)) + .items_center() .child( Button::new("ssh-form-cancel") .label(t(L10nKey::Cancel)) .ghost() .small() + .h(px(CONTROL_H)) + .rounded(px(CONTROL_RADIUS)) .disabled(!dirty) .on_click( cx.listener(|this, _, window, cx| this.cancel_ssh_form(window, cx)), @@ -5875,36 +6194,42 @@ impl Tty7App { // Dials the host exactly as Connect would — proxy, jump // and all — but keeps the answer here instead of // spending a tab on finding out. - Button::new("ssh-form-test") - .label(t(L10nKey::SettingsTestConnection)) - .small() - .disabled(!errors.is_empty() || testing) - .on_click(cx.listener(|this, _, window, cx| { - this.test_ssh_form_connection(window, cx) - })), + quiet_button( + Button::new("ssh-form-test") + .label(t(L10nKey::SettingsTestConnection)) + .small(), + errors.is_empty() && !testing, + cx, + ) + .on_click(cx.listener(|this, _, window, cx| { + this.test_ssh_form_connection(window, cx) + })), ) .child( - Button::new("ssh-form-save") - .label(t(L10nKey::Save)) - .small() - .disabled(!dirty || !errors.is_empty()) - .on_click( - cx.listener(|this, _, window, cx| this.save_ssh_form(window, cx)), - ), + quiet_button( + Button::new("ssh-form-save").label(t(L10nKey::Save)).small(), + dirty && errors.is_empty(), + cx, + ) + .on_click( + cx.listener(|this, _, window, cx| this.save_ssh_form(window, cx)), + ), ) .child( // Connect saves first, so it answers to the same // rules. Before this it answered to none at all, and // an empty host reached the socket layer as a DNS // error about a name nobody typed. - Button::new("ssh-form-connect") - .label(t(L10nKey::Connect)) - .primary() - .small() - .disabled(!errors.is_empty()) - .on_click(cx.listener(|this, _, window, cx| { - this.save_and_connect_profile(window, cx) - })), + primary_button( + Button::new("ssh-form-connect") + .label(t(L10nKey::Connect)) + .small(), + errors.is_empty(), + cx, + ) + .on_click(cx.listener(|this, _, window, cx| { + this.save_and_connect_profile(window, cx) + })), ), ); @@ -5926,20 +6251,24 @@ impl Tty7App { // gets out of the way the moment anything is typed. let core = v_flex() .gap_1() - .child(self.ssh_field_row( - t(L10nKey::SettingsName), - Input::new(&form.name).small().w_full().into_any_element(), - vec![], - cx, - )) + .child( + self.ssh_field_row( + t(L10nKey::SettingsName), + field(Input::new(&form.name).small(), cx) + .w_full() + .into_any_element(), + vec![], + cx, + ), + ) .child( self.ssh_field_row( t(L10nKey::SettingsHost), h_flex() .w_full() .gap_2() - .child(Input::new(&form.host).small().flex_1().min_w_0()) - .child(Input::new(&form.port).small().w(px(64. * ui_scale(cx)))) + .child(field(Input::new(&form.host).small(), cx).flex_1().min_w_0()) + .child(field(Input::new(&form.port).small(), cx).w(px(64. * ui_scale(cx)))) .into_any_element(), host_error .into_iter() @@ -5949,15 +6278,19 @@ impl Tty7App { cx, ), ) - .child(self.ssh_field_row( - t(L10nKey::SettingsUser), - Input::new(&form.user).small().w_full().into_any_element(), - vec![], - cx, - )); + .child( + self.ssh_field_row( + t(L10nKey::SettingsUser), + field(Input::new(&form.user).small(), cx) + .w_full() + .into_any_element(), + vec![], + cx, + ), + ); v_flex() - .gap_4() + .gap(px(SECTION_GAP)) .child(header) // Under the buttons that produced it, on the right, where the eye // already is after pressing Test. @@ -5995,7 +6328,7 @@ impl Tty7App { div() .flex() .w_full() - .py_1p5() + .py(px(4.)) .when(stacked, |row| row.flex_col().items_start().gap_1()) .when(!stacked, |row| row.flex_row().items_start().gap_3()) .child( @@ -6007,12 +6340,14 @@ impl Tty7App { // different-sized hole after every label. l.w(px(SSH_LABEL_W * scale)) .flex_shrink_0() - .pt(px(6.)) + .flex() + .items_center() + .justify_end() + .min_h(px(FIELD_H)) .text_right() }) - .text_sm() - .font_weight(FontWeight::MEDIUM) - .text_color(cx.theme().foreground) + .text_size(gpui::rems(TEXT)) + .text_color(cx.theme().muted_foreground) .child(label.to_string()), ) .child( @@ -6055,8 +6390,7 @@ impl Tty7App { // Six methods is more than a segmented control can label // without squeezing, so a dropdown carries the choice — the // way the other long-form pickers on this page do. - Select::new(&form.auth_select) - .small() + dropdown(Select::new(&form.auth_select).small(), cx) .w_full() .into_any_element(), vec![field_note(t(L10nKey::SettingsAuthDesc), cx).into_any_element()], @@ -6067,8 +6401,7 @@ impl Tty7App { col.child( self.ssh_field_row( t(L10nKey::SettingsPassword), - Input::new(&form.password) - .small() + field(Input::new(&form.password).small(), cx) .mask_toggle() .w_full() .into_any_element(), @@ -6085,14 +6418,22 @@ impl Tty7App { .w_full() .items_start() .gap_2() - .child(Input::new(&form.identity_files).small().flex_1().min_w_0()) .child( - Button::new("ssh-form-browse-key") - .label(t(L10nKey::SettingsBrowseKey)) - .small() - .on_click(cx.listener(|this, _, window, cx| { - this.pick_ssh_identity_file(window, cx) - })), + field(Input::new(&form.identity_files).small(), cx) + .flex_1() + .min_w_0(), + ) + .child( + quiet_button( + Button::new("ssh-form-browse-key") + .label(t(L10nKey::SettingsBrowseKey)) + .small(), + true, + cx, + ) + .on_click(cx.listener( + |this, _, window, cx| this.pick_ssh_identity_file(window, cx), + )), ) .into_any_element(), vec![ @@ -6105,8 +6446,7 @@ impl Tty7App { .child( self.ssh_field_row( t(L10nKey::SettingsKeyPassphrase), - Input::new(&form.passphrase) - .small() + field(Input::new(&form.passphrase).small(), cx) .mask_toggle() .disabled(!has_key) .w_full() @@ -6139,31 +6479,45 @@ impl Tty7App { ) -> AnyElement { let muted = cx.theme().muted_foreground; let sf = cx.global::().window; - let caret = if open { "▾" } else { "▸" }; + let chevron = if open { + IconName::ChevronDown + } else { + IconName::ChevronRight + }; + // The host list's group heading, a size up: a nav-row-tall band with + // the chevron leading and the summary trailing in the muted ink. h_flex() .id(id) .items_center() .gap_2() - .py_2() + .h(px(ROW_H)) // The other collapsible header on this page lights up under the // pointer; this one only changed the cursor, so the two rows a // reader folds and unfolds answered differently to the same move. - .px_2p5() - .mx_neg_2p5() - .rounded_lg() + .px(px(ROW_PAD)) + .mx(px(-ROW_PAD)) + .rounded(rounding::ROW_RADIUS) .cursor_pointer() .hover(|s| s.bg(gpui::rgb(sf.hover))) .on_mouse_down( MouseButton::Left, cx.listener(move |this, _, _w, cx| on_toggle(this, cx)), ) - .child(div().text_color(muted).child(caret.to_string())) + .child(Icon::new(chevron).size(px(12.)).text_color(muted)) .child( div() + .text_size(gpui::rems(TEXT)) .font_weight(gpui::FontWeight::MEDIUM) .child(label.to_string()), ) - .child(div().text_xs().text_color(muted).child(summary.to_string())) + .child( + div() + .min_w_0() + .truncate() + .text_size(gpui::rems(META)) + .text_color(muted) + .child(summary.to_string()), + ) .into_any_element() } @@ -6208,7 +6562,7 @@ impl Tty7App { .gap_1() .w(px(FIELD_W)) .max_w_full() - .child(Input::new(&form.jump).small()) + .child(field(Input::new(&form.jump).small(), cx)) .when_some(error, |col, line| col.child(line)) .into_any_element(), cx, @@ -6261,6 +6615,8 @@ impl Tty7App { .label(t(L10nKey::SettingsAddRule)) .ghost() .small() + .h(px(CONTROL_H)) + .rounded(px(CONTROL_RADIUS)) .on_click( cx.listener(|this, _, window, cx| this.add_forward_rule(window, cx)), ), @@ -6311,6 +6667,20 @@ impl Tty7App { let split = self.settings_row_under(SPLIT_FORWARD_ROW_BELOW, cx); let stack_ends = self.settings_row_under(STACK_FORWARD_ENDS_BELOW, cx); let host_min = if split { 80. } else { 104. }; + // The page's filled field at the control height rather than the field + // height: a rule packs five of them onto one line. Its paint is read + // here, once, so the closures below hold colours and not `cx`. + let (fill, clear) = ( + field_fill(cx.global::().window), + cx.theme().transparent, + ); + let compact = move |input: Input| { + input + .bg(fill) + .border_color(clear) + .rounded(rounding::ROW_RADIUS) + .min_h(px(CONTROL_H)) + }; let endpoint = |host: &Entity, port: &Entity| { h_flex() .gap_1() @@ -6323,10 +6693,10 @@ impl Tty7App { div() .flex_1() .min_w(px(host_min)) - .child(Input::new(host).xsmall()), + .child(compact(Input::new(host).xsmall())), ) .child(div().text_xs().text_color(muted).child(":")) - .child(div().w(px(58.)).child(Input::new(port).xsmall())) + .child(div().w(px(58.)).child(compact(Input::new(port).xsmall()))) }; let mapping = |line: Div| { line.child( @@ -6371,7 +6741,7 @@ impl Tty7App { let description = div() .flex_1() .min_w(px(80.)) - .child(Input::new(&row.description).xsmall()); + .child(compact(Input::new(&row.description).xsmall())); let remove = crate::ui::tab_strip::hit_target( Button::new(("ssh-fwd-remove", idx)) .icon(Icon::new(IconName::Close)) @@ -6476,7 +6846,7 @@ impl Tty7App { div() .w(px(FIELD_W)) .max_w_full() - .child(Input::new(input).small()) + .child(field(Input::new(input).small(), cx)) .into_any_element(), cx, ) @@ -6503,7 +6873,7 @@ impl Tty7App { .gap_1() .w(px(FIELD_W)) .max_w_full() - .child(Input::new(input).small()) + .child(field(Input::new(input).small(), cx)) .when_some(line, |col, line| col.child(line)) .into_any_element(), cx, @@ -6840,7 +7210,7 @@ impl Tty7App { // 24px accessibility floor — which is exactly the field's inner // height, so that fill met the border top and bottom and looked // like a patch stuck over the field's right end. The field's - // own border and the tooltip carry the affordance. + // own fill and the tooltip carry the affordance. .custom(ButtonCustomVariant::new(cx).foreground(muted_fg)) .xsmall(), ) @@ -6882,7 +7252,7 @@ impl Tty7App { let program_control = div() .w(px(FIELD_W)) .max_w_full() - .child(Input::new(&program_input).small().suffix(program_picker)) + .child(field(Input::new(&program_input).small(), cx).suffix(program_picker)) .into_any_element(); // Args become argv verbatim, so a quote that never closes is a value // that cannot be saved at all — `commit_shell` refuses it, and this @@ -6898,7 +7268,7 @@ impl Tty7App { .gap_1() .w(px(FIELD_W)) .max_w_full() - .child(Input::new(&args_input).small()) + .child(field(Input::new(&args_input).small(), cx)) .when_some(args_error, |this, line| this.child(line)) .into_any_element(); @@ -6940,7 +7310,7 @@ impl Tty7App { .gap_1() .w(px(FIELD_W)) .max_w_full() - .child(Input::new(&wd_path_input).small()) + .child(field(Input::new(&wd_path_input).small(), cx)) .when_some(wd_path_error, |this, line| this.child(line)) .into_any_element() } else { @@ -7057,7 +7427,7 @@ impl Tty7App { div() .w(px(300.)) .max_w_full() - .child(Input::new(&link_file_command_input).small()) + .child(field(Input::new(&link_file_command_input).small(), cx)) .into_any_element() }); let scrollback_radio = self.segmented_valued( @@ -7262,13 +7632,17 @@ impl Tty7App { self.settings_row( t(L10nKey::SettingsSearchKeybindingsTitle), t(L10nKey::SettingsKeybindingsIntroDesc), - Button::new("open-keybindings") - .label(t(L10nKey::SettingsEditShortcuts)) - .small() - .on_click(cx.listener(|this, _, window, cx| { - this.navigate_settings(SettingsSection::Keybindings, None, window, cx) - })) - .into_any_element(), + quiet_button( + Button::new("open-keybindings") + .label(t(L10nKey::SettingsEditShortcuts)) + .small(), + true, + cx, + ) + .on_click(cx.listener(|this, _, window, cx| { + this.navigate_settings(SettingsSection::Keybindings, None, window, cx) + })) + .into_any_element(), cx, ), ) @@ -7502,28 +7876,40 @@ impl Tty7App { h_flex() .gap_2() .items_center() - .child(div().size_2().rounded_full().bg(dot_color)) + .child(div().size(px(SSH_DOT)).rounded_full().bg(dot_color)) .child(div().text_sm().text_color(foreground).child(status_text)), ) .child( h_flex() .gap_2() .child( - Button::new(("agent-hooks-install", i)) - .label(primary_label) - .small() - .on_click(cx.listener(move |this, _, _w, cx| { + quiet_button( + Button::new(("agent-hooks-install", i)) + .label(primary_label) + .small(), + true, + cx, + ) + .on_click(cx.listener( + move |this, _, _w, cx| { this.settings_install_agent_hooks(agent, cx) - })), + }, + )), ) .when(row.state != HooksState::NotInstalled, |r| { r.child( - Button::new(("agent-hooks-uninstall", i)) - .label(t(L10nKey::SettingsUninstall)) - .small() - .on_click(cx.listener(move |this, _, _w, cx| { + quiet_button( + Button::new(("agent-hooks-uninstall", i)) + .label(t(L10nKey::SettingsUninstall)) + .small(), + true, + cx, + ) + .on_click( + cx.listener(move |this, _, _w, cx| { this.settings_uninstall_agent_hooks(agent, cx) - })), + }), + ), ) }), ) @@ -7584,7 +7970,6 @@ impl Tty7App { fn agent_hooks_machine_picker(&self, selected: HostId, cx: &mut Context) -> Option
{ let sf = cx.global::().window; - let border = cx.theme().border; let muted_fg = cx.theme().muted_foreground; let machines = self.agent_hooks_machines(cx); let offline = self.agent_hooks_offline_count(cx); @@ -7595,7 +7980,7 @@ impl Tty7App { Some( v_flex() .gap_2() - .mb_4() + .mb(px(SECTION_GAP)) .child( h_flex() .flex_wrap() @@ -7603,15 +7988,17 @@ impl Tty7App { .children(machines.into_iter().map(|machine| { let active = machine.host == selected; let host = machine.host; + // Chips, not outlined boxes: the fields' faint fill + // at rest, the current machine a rung up at medium + // weight — the segmented control's cells, spaced + // apart because the list wraps. h_flex() .id(("agent-hooks-machine", host.0 as usize)) - .h(px(24.)) + .h(px(CONTROL_H)) .px_2p5() .items_center() - .rounded_lg() - .border_1() - .border_color(border) - .bg(rgb(sf.base)) + .rounded(px(CONTROL_RADIUS)) + .bg(field_fill(sf)) .text_sm() .cursor_pointer() .when(active, |s| { @@ -7950,11 +8337,11 @@ impl Tty7App { fn render_theme_card(&self, slot: ThemeSlot, cx: &mut Context) -> AnyElement { let theme = cx.theme(); - let border = theme.border; let foreground = theme.foreground; let muted_fg = theme.muted_foreground; - let hover_bg = gpui::rgb(cx.global::().window.hover); - let surface = theme.secondary.opacity(0.28); + let sf = cx.global::().window; + let hover_bg = gpui::rgb(sf.hover); + let surface = field_fill(sf); let config = cx.global::(); let (card_id, active_id) = match slot { @@ -8025,16 +8412,16 @@ impl Tty7App { .w_full() .items_center() .gap_4() - .p_3() - .rounded(rounding::TRACK_RADIUS) - .border_1() - .border_color(if open { - foreground.opacity(0.35) + .p(px(CONTENT_INSET)) + .rounded(rounding::CARD_RADIUS) + // The fields' faint fill, no outline; open, it takes the + // selected rung, the same "current" a nav row wears. + .bg(if open { + gpui::rgb(sf.selected).into() } else { - border + surface }) - .bg(surface) - .hover(|h| h.bg(hover_bg)) + .when(!open, |card| card.hover(|h| h.bg(hover_bg))) // The preview is the first thing to go: it is a picture of a // choice the two lines beside it already name, and at the // width where it stops fitting it was pushing the "change @@ -8047,11 +8434,16 @@ impl Tty7App { .flex_1() .min_w_0() .gap_0p5() - .child(div().text_xs().text_color(muted_fg).child(caption)) .child( div() - .text_sm() - .font_weight(FontWeight::SEMIBOLD) + .text_size(gpui::rems(HEADING)) + .text_color(muted_fg) + .child(caption), + ) + .child( + div() + .text_size(gpui::rems(TEXT)) + .font_weight(FontWeight::MEDIUM) .text_color(foreground) .child(name), ) @@ -8062,10 +8454,10 @@ impl Tty7App { .flex_shrink_0() .items_center() .gap_1() - .text_sm() + .text_size(gpui::rems(META)) .text_color(muted_fg) .child(t(L10nKey::SettingsChangeTheme)) - .child(Icon::new(IconName::ChevronRight).small()), + .child(Icon::new(IconName::ChevronRight).size(px(12.))), ), ) .into_any_element() @@ -8074,9 +8466,13 @@ impl Tty7App { fn render_theme_panel(&self, cx: &mut Context) -> AnyElement { let theme = cx.theme(); let border = theme.border; + let divider = theme.sidebar_border; let foreground = theme.foreground; let muted_fg = theme.muted_foreground; - let bg = theme.sidebar; + let sf = cx.global::().window; + // The page's own opaque surface, as the right panel keeps the + // content fill: the nav is the one tinted side of this window. + let bg: gpui::Hsla = rgb(sf.base).into(); let (search, query, slot) = match self.active_settings() { Some(s) => ( @@ -8105,25 +8501,20 @@ impl Tty7App { ThemeSlot::Dark => config.theme_preset_dark.clone(), }; + // The title stands in the title-bar band, level with the page title + // across from it, and the close tile at the band's end is where the + // page's own close tile is when this panel is shut. let header = h_flex() + .h(px(TITLE_BAR_HEIGHT)) + .flex_shrink_0() .items_center() .justify_between() - .px_4() - .pt_4() - .pb_1() - .child( - div() - .text_base() - .font_weight(FontWeight::SEMIBOLD) - .text_color(foreground) - .child(t(L10nKey::SettingsThemes)), - ) + .pl(px(CONTENT_INSET + ROW_PAD)) + .pr(px(CONTENT_INSET)) + .child(page_title(t(L10nKey::SettingsThemes), cx)) .child( div().occlude().child( - Button::new("theme-panel-close") - .icon(IconName::Close) - .ghost() - .small() + settings_tile(Button::new("theme-panel-close").icon(IconName::Close), cx) .tooltip(t(L10nKey::SettingsThemesCloseTooltip)) .on_click( cx.listener(|this, _, window, cx| this.close_theme_panel(window, cx)), @@ -8132,9 +8523,9 @@ impl Tty7App { ); let subtitle = div() - .px_4() - .pb_3() - .text_xs() + .px(px(CONTENT_INSET + ROW_PAD)) + .pb(px(10.)) + .text_size(gpui::rems(META)) .text_color(muted_fg) .child(match slot { ThemeSlot::Manual => t(L10nKey::SettingsThemePanelManual), @@ -8142,9 +8533,9 @@ impl Tty7App { ThemeSlot::Dark => t(L10nKey::SettingsThemePanelDark), }); - let search_box = div().px_4().pb_3().child( + let search_box = div().px(px(CONTENT_INSET)).pb(px(SECTION_GAP)).child( div().w_full().child( - Input::new(&search).small().prefix( + field(Input::new(&search).small(), cx).prefix( Icon::empty() .path("stock/icons/search.svg") .small() @@ -8159,11 +8550,11 @@ impl Tty7App { let rejected = presets::rejected(cx); let rejected_note = (!rejected.is_empty() && query.is_empty()).then(|| { let mut note = v_flex() - .mx_4() - .mb_4() - .p_3() + .mx(px(CONTENT_INSET)) + .mb(px(SECTION_GAP)) + .p(px(CONTENT_INSET)) .gap_1p5() - .rounded(rounding::TRACK_RADIUS) + .rounded(rounding::ROW_RADIUS) .border_1() .border_color(theme.danger.opacity(0.4)) .child( @@ -8184,7 +8575,10 @@ impl Tty7App { note }); - let mut list = v_flex().px_4().pb_4().gap_4(); + let mut list = v_flex() + .px(px(CONTENT_INSET)) + .pb(px(CONTENT_INSET)) + .gap(px(SECTION_GAP)); // Filtering every preset out left the panel blank under its own search // box — the one filter in the app that said nothing about it. let mut any_themes = false; @@ -8211,12 +8605,14 @@ impl Tty7App { .rounded(rounding::TRACK_RADIUS) .overflow_hidden() .border_1() + // Flat: the current theme is marked by a firmer + // outline and the check under it, not by lifting + // the card off the panel. .border_color(if is_active { foreground.opacity(0.5) } else { border }) - .when(is_active, |s| s.shadow_md()) .when(!is_active, |s| { s.hover(|h| h.border_color(foreground.opacity(0.25))) }) @@ -8230,12 +8626,8 @@ impl Tty7App { .child( div() .truncate() - .text_sm() - .font_weight(if is_active { - FontWeight::SEMIBOLD - } else { - FontWeight::MEDIUM - }) + .text_size(gpui::rems(TEXT)) + .when(is_active, |d| d.font_weight(FontWeight::MEDIUM)) .text_color(if is_active { foreground } else { muted_fg }) .child(p.name.clone()), ) @@ -8269,7 +8661,7 @@ impl Tty7App { .flex_shrink_0() .bg(bg) .border_l_1() - .border_color(border) + .border_color(divider) .child(header) .child(subtitle) .child(search_box) @@ -8295,16 +8687,21 @@ impl Tty7App { .active_settings() .map(|s| s.shortcut_search.read(cx).value().trim().to_lowercase()) .unwrap_or_default(); - let (foreground, muted, border, kbd_bg, accent) = { + let (foreground, muted, border, divider, accent) = { let t = cx.theme(); ( t.foreground, t.muted_foreground, t.border, - t.secondary.opacity(0.6), + t.sidebar_border, t.primary, ) }; + let sf = cx.global::().window; + // Keycaps are the switcher's: a filled cap, no outline. The capture + // cell around them lights a rung up under the pointer. + let kbd_bg = field_fill(sf); + let capture_hover: gpui::Hsla = rgb(sf.hover).into(); let (preset, prefix, overridden) = { let cfg = cx.global::(); @@ -8336,10 +8733,8 @@ impl Tty7App { .min_w(px(22.)) .h(px(22.)) .px_1p5() - .rounded_md() + .rounded(px(5.)) .bg(kbd_bg) - .border_1() - .border_color(border) .text_xs() .text_color(foreground) .child(tok) @@ -8378,33 +8773,31 @@ impl Tty7App { r.flex_row().items_center().justify_between().gap_8() }) }; - let preset_row = hand_rolled_row(div().py_2()) + let preset_row = hand_rolled_row(div().py(px(ROW_PAD))) .child( v_flex() .min_w_0() - .gap_0p5() + .gap(px(2.)) .child( div() - .text_sm() - .font_weight(FontWeight::MEDIUM) + .text_size(gpui::rems(TEXT)) .text_color(foreground) .child(t(L10nKey::SettingsPreset)), ) .child( div() - .text_xs() + .text_size(gpui::rems(META)) .text_color(muted) .child(t(L10nKey::SettingsPresetDesc)), ), ) .child(h_flex().flex_shrink_0().child(preset_control)); - let prefix_row = hand_rolled_row(div().py_2()) + let prefix_row = hand_rolled_row(div().py(px(ROW_PAD))) .child( div() .min_w_0() - .text_sm() - .font_weight(FontWeight::MEDIUM) + .text_size(gpui::rems(TEXT)) .text_color(foreground) .child(t(L10nKey::SettingsPrefix)), ) @@ -8558,10 +8951,10 @@ impl Tty7App { .gap_2() .px_2() .py_1() - .rounded_md() + .rounded(rounding::ROW_RADIUS) .cursor_pointer() .when(is_recording, |d| d.border_1().border_color(accent)) - .hover(|d| d.bg(kbd_bg)) + .hover(|d| d.bg(capture_hover)) .child(captured) .on_click(cx.listener(move |this, _, window, cx| { this.start_recording_key(action_for_click.clone(), window, cx) @@ -8574,30 +8967,28 @@ impl Tty7App { .child(capture) .when(is_overridden, |r| { r.child( - Button::new(SharedString::from(format!("reset-{action}"))) - .label(t(L10nKey::Reset)) - .small() - .on_click(cx.listener(move |this, _, _w, cx| { - this.reset_keybinding(action_for_reset.clone(), cx) - })), + quiet_button( + Button::new(SharedString::from(format!("reset-{action}"))) + .label(t(L10nKey::Reset)) + .small(), + true, + cx, + ) + .on_click(cx.listener(move |this, _, _w, cx| { + this.reset_keybinding(action_for_reset.clone(), cx) + })), ) }); if let Some(title) = heading_at.get(&i) { - list = list.child( - div() - .pt_5() - .pb_1p5() - .text_xs() - .font_weight(FontWeight::MEDIUM) - .text_color(muted) - .child(*title), - ); + list = list.child(heading_text(*title, cx).mt(px(SECTION_GAP))); } let last_in_group = heading_at.contains_key(&(i + 1)) || i + 1 == count; list = list.child( - hand_rolled_row(div().py_1p5()) - .when(!last_in_group, |s| s.border_b_1().border_color(border)) + hand_rolled_row(div().min_h(px(ROW_H)).py(px(4.))) + .when(!last_in_group, |s| { + s.border_b(px(0.5)).border_color(divider) + }) // An action name is a line, never a paragraph: wrapped, it // came out one or three CJK glyphs a line on Linux (#919), // spilling over the rows below it while the keycaps beside @@ -8620,8 +9011,8 @@ impl Tty7App { v_flex() .when_some(self.active_settings(), |v, s| { - v.child(Input::new(&s.shortcut_search).small()) - .child(self.section_rule(cx)) + v.child(field(Input::new(&s.shortcut_search).small(), cx)) + .child(div().h(px(SECTION_GAP))) }) .child(self.section_intro( t(L10nKey::SettingsNavKeybindings), @@ -8649,13 +9040,17 @@ impl Tty7App { }) .when(!filtering, |v| { v.child( - h_flex().justify_end().py_2().child( - Button::new("kb-restore-all") - .label(t(L10nKey::SettingsRestoreAllDefaults)) - .small() - .on_click(cx.listener(|this, _, window, cx| { - this.restore_default_keybindings(window, cx) - })), + h_flex().justify_end().py(px(ROW_PAD)).child( + quiet_button( + Button::new("kb-restore-all") + .label(t(L10nKey::SettingsRestoreAllDefaults)) + .small(), + true, + cx, + ) + .on_click(cx.listener(|this, _, window, cx| { + this.restore_default_keybindings(window, cx) + })), ), ) }) @@ -8713,7 +9108,7 @@ impl Tty7App { .gap_1() .w(px(FIELD_W)) .max_w_full() - .child(Input::new(&http_proxy_input).small()) + .child(field(Input::new(&http_proxy_input).small(), cx)) .when_some(http_proxy_error, |this, line| this.child(line)) .into_any_element(); @@ -8789,12 +9184,16 @@ impl Tty7App { ) .child( h_flex().child( - Button::new("restart-daemon") - .label(t(L10nKey::SettingsRestartServer)) - .small() - .on_click(cx.listener(|this, _, window, cx| { - this.restart_daemon(window, cx) - })), + quiet_button( + Button::new("restart-daemon") + .label(t(L10nKey::SettingsRestartServer)) + .small(), + true, + cx, + ) + .on_click( + cx.listener(|this, _, window, cx| this.restart_daemon(window, cx)), + ), ), ), ) @@ -8859,7 +9258,7 @@ impl Tty7App { .gap_0p5() .child( div() - .text_xl() + .text_base() .font_weight(FontWeight::SEMIBOLD) .text_color(foreground) .child("tty7"), @@ -8879,7 +9278,7 @@ impl Tty7App { ) .child( div() - .mt_4() + .mt(px(SECTION_GAP)) .text_sm() .text_color(muted_fg) .child(t(L10nKey::SettingsAboutDesc1)), @@ -8913,30 +9312,49 @@ impl Tty7App { h_flex() .gap_2() .child( - Button::new("update-retry") - .label(t(L10nKey::SettingsUpdateRetry)) - .small() - .disabled(update_busy) - .on_click(cx.listener(|_, _, _window, cx| { + quiet_button( + Button::new("update-retry") + .label(t(L10nKey::SettingsUpdateRetry)) + .small(), + !update_busy, + cx, + ) + .on_click( + cx.listener(|_, _, _window, cx| { crate::core::update::dismiss_failure(cx); crate::core::update::install_available(cx); - })), + }), + ), ) .child( - Button::new("update-manual") - .label(t(L10nKey::SettingsUpdateDownloadManually)) - .small() - .on_click(cx.listener(|_, _, _window, _cx| { + quiet_button( + Button::new("update-manual") + .label(t( + L10nKey::SettingsUpdateDownloadManually, + )) + .small(), + true, + cx, + ) + .on_click( + cx.listener(|_, _, _window, _cx| { crate::core::update::open_releases_page() - })), + }), + ), ) .child( - Button::new("update-dismiss") - .label(t(L10nKey::SettingsUpdateDismiss)) - .small() - .on_click(cx.listener(|_, _, _window, cx| { + quiet_button( + Button::new("update-dismiss") + .label(t(L10nKey::SettingsUpdateDismiss)) + .small(), + true, + cx, + ) + .on_click( + cx.listener(|_, _, _window, cx| { crate::core::update::dismiss_failure(cx) - })), + }), + ), ), ), ) @@ -8963,22 +9381,32 @@ impl Tty7App { h_flex() .gap_2() .child( - Button::new("install-ready") - .label(t(L10nKey::SettingsUpdateInstallNow)) - .small() - .disabled(update_busy) - .on_click(cx.listener(|_, _, _window, cx| { + primary_button( + Button::new("install-ready") + .label(t(L10nKey::SettingsUpdateInstallNow)) + .small(), + !update_busy, + cx, + ) + .on_click( + cx.listener(|_, _, _window, cx| { crate::core::update::install_available(cx) - })), + }), + ), ) .child( - Button::new("discard-ready") - .label(t(L10nKey::SettingsUpdateDiscard)) - .small() - .disabled(update_busy) - .on_click(cx.listener(|_, _, _window, cx| { + quiet_button( + Button::new("discard-ready") + .label(t(L10nKey::SettingsUpdateDiscard)) + .small(), + !update_busy, + cx, + ) + .on_click( + cx.listener(|_, _, _window, cx| { crate::core::update::discard_pending(cx) - })), + }), + ), ), ), ) @@ -9017,13 +9445,16 @@ impl Tty7App { .child(availability), ) .child( - Button::new("install-update") - .label(action) - .small() - .disabled(update_busy) - .on_click(cx.listener(|_, _, _window, cx| { + primary_button( + Button::new("install-update").label(action).small(), + !update_busy, + cx, + ) + .on_click( + cx.listener(|_, _, _window, cx| { crate::core::update::install_available(cx) - })), + }), + ), ), ) .when_some(upd.install_hint, |this, hint| { @@ -9043,34 +9474,43 @@ impl Tty7App { h_flex() .gap_2() .child( - Button::new("check-update-now") - .label( - if matches!( - update_status.phase, - crate::core::update::UpdatePhase::Checking - ) { - t(L10nKey::SettingsUpdateChecking) - } else { - t(L10nKey::SettingsUpdateCheckNow) - }, - ) - .small() - .disabled(update_busy) - .on_click(cx.listener(|_, _, _window, cx| { - crate::core::update::spawn_check_forced(cx) - })), + quiet_button( + Button::new("check-update-now") + .label( + if matches!( + update_status.phase, + crate::core::update::UpdatePhase::Checking + ) { + t(L10nKey::SettingsUpdateChecking) + } else { + t(L10nKey::SettingsUpdateCheckNow) + }, + ) + .small(), + !update_busy, + cx, + ) + .on_click(cx.listener( + |_, _, _window, cx| crate::core::update::spawn_check_forced(cx), + )), ) // Thirty megabytes on a slow link is exactly the // download someone wants to call off; without this // the only way out was to kill the app. .when(transferring, |this| { this.child( - Button::new("cancel-update-download") - .label(t(L10nKey::SettingsUpdateCancel)) - .small() - .on_click(cx.listener(|_, _, _window, cx| { + quiet_button( + Button::new("cancel-update-download") + .label(t(L10nKey::SettingsUpdateCancel)) + .small(), + true, + cx, + ) + .on_click(cx.listener( + |_, _, _window, cx| { crate::core::update::cancel_download(cx) - })), + }, + )), ) }), ),