diff --git a/src/ui/code_editor.rs b/src/ui/code_editor.rs index 02c92fc3..4396492a 100644 --- a/src/ui/code_editor.rs +++ b/src/ui/code_editor.rs @@ -1360,32 +1360,44 @@ impl Tty7App { row }; let menu_app = cx.entity().downgrade(); + // v4 chrome: the file name in body ink at medium — the one heading the + // column has — a hairline in the divider tone under the bar, and the + // rail's 26px close tile, so the header reads as part of the plane it + // sits in rather than a toolbar bolted on top of it. + let (tile, glyph) = ( + crate::ui::tab_strip::RAIL_TILE, + crate::ui::tab_strip::RAIL_TILE_GLYPH, + ); row.flex_none() .h(px(crate::ui::app::TITLE_BAR_HEIGHT)) .items_center() - .gap_1p5() + .gap(px(8.)) .pl(px(lead)) - .pr(px(crate::ui::app::tile_trailing_inset())) - .border_b_1() - .border_color(cx.theme().border) + // The glyph, not the tile, lands on `CONTENT_INSET`, the column + // the file name starts on at the other end of the bar. + .pr(px(crate::ui::app::CONTENT_INSET - (tile - glyph) / 2.)) + .border_b(crate::ui::theme::hairline(window)) + .border_color(cx.theme().sidebar_border) .child( div() .flex_1() .min_w_0() .text_ellipsis() - .text_sm() - .when(name.is_none(), |d| { - d.text_color(cx.theme().muted_foreground) + .text_size(gpui::rems(crate::ui::right_panel::TEXT)) + .map(|d| match name.is_some() { + true => d.font_weight(gpui::FontWeight::MEDIUM), + false => d.text_color(cx.theme().muted_foreground), }) .child( name.unwrap_or_else(|| SharedString::from(t(L10nKey::EditorNoFileOpen))), ), ) + // Unsaved: the sidebar's 5px status dot, in the warning ink. .when(dirty, |d| { d.child( div() .flex_none() - .size(px(6.)) + .size(px(crate::ui::tab_strip::ROW_STATUS_DOT)) .rounded_full() .bg(cx.theme().warning), ) @@ -1394,12 +1406,12 @@ impl Tty7App { div().occlude().flex_shrink_0().child( crate::ui::tab_strip::chrome_tile_sized( Button::new("editor-panel-close").icon(Icon::new(IconName::Close)), - crate::ui::app::TILE_SIZE, - crate::ui::app::TILE_GLYPH_LINE, + tile, + glyph, false, cx, ) - .rounded_lg() + .rounded(px(crate::ui::tab_strip::RAIL_TILE_RADIUS)) .tooltip(t(L10nKey::EditorBackToTerminal)) .on_click(cx.listener(|this, _, window, cx| { this.toggle_code_panel(window, cx); @@ -1411,7 +1423,7 @@ impl Tty7App { }) } - fn render_code_status_bar(&self, _window: &Window, cx: &mut Context) -> gpui::Div { + fn render_code_status_bar(&self, window: &Window, cx: &mut Context) -> gpui::Div { // The roots below belong to this window's own machine. A file read // over SFTP is on another one, where they mean nothing, so it shows // its own full path rather than borrowing the local repo's name. @@ -1455,16 +1467,18 @@ impl Tty7App { let is_markdown = active.is_some_and(|f| language_for_path(&f.path) == "markdown"); let preview = active.is_some_and(|f| f.preview); + // Metadata, not a toolbar: caption ink on the plane's own fill, set + // off by a hairline in the divider tone rather than a control border. h_flex() .flex_none() .w_full() .h(px(26.)) .items_center() .gap_3() - .px_3() - .border_t_1() - .border_color(cx.theme().border) - .text_xs() + .px(px(crate::ui::app::CONTENT_INSET)) + .border_t(crate::ui::theme::hairline(window)) + .border_color(cx.theme().sidebar_border) + .text_size(gpui::rems(crate::ui::right_panel::META)) .text_color(muted) .when_some(path_text, |this, t| { this.child(div().min_w_0().text_ellipsis().child(t)) @@ -1500,7 +1514,15 @@ impl Tty7App { ), ) }) - .when_some(cursor, |this, t| this.child(div().child(t))) + // Tabular figures, so the position does not jitter sideways as + // the caret walks from line 9 to line 10. + .when_some(cursor, |this, t| { + this.child( + div() + .font_features(crate::ui::theme::tabular_figures()) + .child(t), + ) + }) } fn render_editor_empty(&self, cx: &Context) -> gpui::Div { diff --git a/src/ui/forwards.rs b/src/ui/forwards.rs index 3257016d..c1c9fde4 100644 --- a/src/ui/forwards.rs +++ b/src/ui/forwards.rs @@ -5,7 +5,7 @@ use gpui_component::{ActiveTheme as _, Disableable as _, IconName, Sizable as _, use crate::daemon::protocol::{ForwardStatus, ManagedForward, SshForwardKind, SshForwardRule}; use crate::terminal::view::TerminalView; -use crate::ui::app::{CONTENT_INSET, Tty7App}; +use crate::ui::app::Tty7App; use crate::ui::i18n::{L10nKey, t, t_fmt}; use crate::ui::right_panel::{META, TEXT_MONO}; @@ -179,6 +179,7 @@ impl Tty7App { let theme = cx.theme(); let (danger, foreground) = (theme.danger, theme.foreground); + let theme_popover = theme.popover; let bar = crate::ui::notice::pill(danger, cx) .child( @@ -217,9 +218,12 @@ impl Tty7App { .map(|keys| div().child(format!("· {keys}"))), ) .child( + // The strip's one action, as v4's primary: inverted neutral on + // the popover fill the pill floats on. The red is the edge + // and the reason; the button does not repeat it. Button::new("ssh-reconnect") .label(crate::ui::i18n::t(crate::ui::i18n::L10nKey::Reconnect)) - .primary() + .custom(crate::ui::theme::inverted_button(theme_popover, cx)) .small() .on_click( cx.listener(|this, _, window, cx| this.restart_ssh_session(window, cx)), @@ -277,9 +281,14 @@ impl Tty7App { .group(group.clone()) .items_center() .gap(px(8.)) - .px(px(4.)) - .py(px(5.)) - .rounded(crate::ui::rounding::ROW_RADIUS) + // The port and process rows' box: 26px, padded `ROW_INSET` so the + // text lands on the panel's 20px column and the trailing tile ends + // 20px from the edge, on the panel's row corner. A described + // forward grows by its second line instead of being clipped to one. + .min_h(px(26.)) + .py(px(3.)) + .px(px(crate::ui::right_panel::ROW_INSET)) + .rounded(crate::ui::right_panel::ROW_FILL_RADIUS) .cursor_pointer() .hover(|s| s.bg(gpui::rgb(sf.hover))) .on_click(cx.listener(move |this, _, window, cx| { @@ -342,7 +351,7 @@ impl Tty7App { ) .w(px(crate::ui::tab_strip::MIN_TARGET)) .h(px(crate::ui::tab_strip::MIN_TARGET)) - .rounded(px(4.)) + .rounded(px(crate::ui::tab_strip::RAIL_TILE_RADIUS)) .tooltip(t(L10nKey::ForwardTooltipRemove)) .on_click(cx.listener( move |this, _, _window, cx| { @@ -401,8 +410,9 @@ impl Tty7App { .child(div().w(px(52.)).child(Input::new(port).xsmall())) }; + // Form text, not rows: on the panel's text column. v_flex() - .px(px(CONTENT_INSET)) + .px(px(crate::ui::right_panel::TEXT_INSET)) .pt(px(6.)) .pb(px(2.)) .gap(px(5.)) @@ -547,7 +557,12 @@ impl Tty7App { } else { t(L10nKey::ForwardAdd) }) - .primary() + // v4's primary: the panel's ink as the + // fill, its opaque surface as the label. + .custom(crate::ui::theme::inverted_button( + gpui::rgb(sf.base).into(), + cx, + )) .xsmall() .disabled(!complete) .on_click(cx.listener(move |this, _, window, cx| { diff --git a/src/ui/pane.rs b/src/ui/pane.rs index 145d08d4..4481928b 100644 --- a/src/ui/pane.rs +++ b/src/ui/pane.rs @@ -1080,8 +1080,14 @@ impl Pane { let row = *axis == Axis::Horizontal; let r = ratio.get().clamp(MIN_RATIO, MAX_RATIO); - let idle = cx.theme().border; + // At rest the split is a v4 hairline in the divider tone — the + // line between two panes that already carry their own content, + // not the stronger rule that outlines a floating surface. Hover + // and drag keep the accent the sidebar and right-panel edges + // light up with, so every resize handle answers the same way. + let idle = cx.theme().sidebar_border; let active = cx.theme().drag_border; + let hairline = crate::ui::theme::hairline(window); let container: Rc>>> = Rc::new(Cell::new(None)); @@ -1141,6 +1147,9 @@ impl Pane { .size_full(); let line_color = if dragging.get() { active } else { idle }; + // A lit handle goes back to a whole pixel: a hairline of accent + // is too faint to say "this is what you are holding". + let line = if dragging.get() { px(1.) } else { hairline }; // The gutter stays 5px so the split looks the same; the target // is the 8px the sidebar and right-panel edges already hand // you. The extra 1.5px a side reaches into the pane's own 8px @@ -1182,10 +1191,13 @@ impl Pane { .when(!row, |d| d.h(px(DIVIDER_THICKNESS)).w_full()) .child( div() - .when(row, |d| d.w(px(1.)).h_full()) - .when(!row, |d| d.h(px(1.)).w_full()) + .when(row, |d| d.w(line).h_full()) + .when(!row, |d| d.h(line).w_full()) .bg(line_color) - .group_hover("split-divider", |s| s.bg(active)), + .group_hover("split-divider", move |s| match row { + true => s.bg(active).w(px(1.)), + false => s.bg(active).h(px(1.)), + }), ) .child(grab); diff --git a/src/ui/sftp.rs b/src/ui/sftp.rs index 05e830cc..7383bb6f 100644 --- a/src/ui/sftp.rs +++ b/src/ui/sftp.rs @@ -22,7 +22,20 @@ use crate::daemon::ssh::sftp::{remote_basename, remote_join, remote_parent, safe use crate::terminal::RemoteTerminal; use crate::ui::app::{CONTENT_INSET, TILE_GLYPH_SM, TILE_SIZE_SM, Tty7App}; use crate::ui::i18n::{L10nKey, t, t_fmt}; -use crate::ui::right_panel::{META, TEXT}; +use crate::ui::right_panel::{ + HEADING, META, ROW_FILL_RADIUS, ROW_GLYPH, ROW_INSET, TEXT, TEXT_INSET, +}; + +/// A remote directory row: the local file tree's 26px row, on the panel's +/// `ROW_FILL_RADIUS`, so the Files tab keeps its rhythm whichever machine it +/// is showing. +const SFTP_ROW_H: f32 = 26.; + +/// The gap between a row's icon, name and size — the file tree's. +const SFTP_ROW_GAP: f32 = 6.; + +/// The transfer tray's disclosure chevron — the file tree's. +const TRAY_CHEVRON: f32 = 10.; #[derive(Clone, Copy)] enum SftpMenuAction { @@ -1357,7 +1370,8 @@ impl Tty7App { .flex_wrap() .items_center() .gap_0p5() - .px(px(CONTENT_INSET)) + // Text, not a row: on the panel's text column. + .px(px(TEXT_INSET)) .pb(px(4.)) .on_double_click( cx.listener(|this, _, window, cx| this.sftp_begin_edit_path(window, cx)), @@ -1395,9 +1409,10 @@ impl Tty7App { } fn render_sftp_edit_form(&self, cx: &mut Context) -> Option> { - let secondary = cx.theme().secondary; - let border = cx.theme().border; + let well = cx.theme().muted; let foreground = cx.theme().foreground; + let surface: gpui::Hsla = + gpui::rgb(cx.global::().sidebar.base).into(); let (title, input): (String, _) = match self.sftp_panel.editing.as_ref()? { SftpEdit::NewFolder(input) => (t(L10nKey::SftpEditNewFolder).to_string(), input), SftpEdit::NewFile(input) => (t(L10nKey::SftpEditNewFile).to_string(), input), @@ -1416,14 +1431,16 @@ impl Tty7App { Some( v_flex() .id("panel-sftp-edit") - .gap(px(5.)) - .mx(px(CONTENT_INSET - 4.)) + .gap(px(6.)) + // Where a row's fill would be, its text on the row's text + // column. + .mx(px(CONTENT_INSET)) .mb(px(4.)) - .p(px(6.)) - .bg(secondary) - .border_1() - .border_color(border) - .rounded_md() + .p(px(ROW_INSET)) + // A borderless faint well, the file tree's search shape, not + // an outlined card: it is part of the list, not over it. + .bg(well) + .rounded(px(7.)) // Escape backs out of the form, the way it backs out of the // path editor above it and every sheet the app puts up. .on_key_down(cx.listener(|this, ev: &gpui::KeyDownEvent, window, cx| { @@ -1433,7 +1450,7 @@ impl Tty7App { })) .child( div() - .text_xs() + .text_size(rems(HEADING)) .font_weight(FontWeight::MEDIUM) .text_color(foreground) .child(title), @@ -1456,7 +1473,7 @@ impl Tty7App { Button::new("sftp-edit-ok") .label(t(L10nKey::Ok)) .xsmall() - .primary() + .custom(crate::ui::theme::inverted_button(surface, cx)) .disabled(!can_commit) .on_click( cx.listener(|this, _, w, cx| this.sftp_commit_edit(w, cx)), @@ -1475,12 +1492,12 @@ impl Tty7App { .min_h_0() .overflow_y_scroll() .track_scroll(&self.sftp_panel.scroll) - .px(px(CONTENT_INSET - 6.)) + .px(px(CONTENT_INSET)) .pb(px(4.)); let note = |text: gpui::SharedString, color| { div() - .px(px(6.)) + .px(px(ROW_INSET)) .py(px(4.)) .text_size(rems(TEXT)) .text_color(color) @@ -1521,33 +1538,41 @@ impl Tty7App { } fn render_sftp_go_up_row(&self, cx: &mut Context) -> AnyElement { - let foreground = cx.theme().foreground; + let theme = cx.theme(); + let (name_ink, muted) = (theme.sidebar_foreground, theme.muted_foreground); let sf = cx.global::().popover; h_flex() .id("sftp-go-up") .items_center() - .gap_1() - .pl(px(6.)) - .pr_1() - .py_1() - .rounded(cx.theme().radius) + .gap(px(SFTP_ROW_GAP)) + .h(px(SFTP_ROW_H)) + .px(px(ROW_INSET)) + .rounded(ROW_FILL_RADIUS) .cursor_pointer() .hover(|s| s.bg(gpui::rgb(sf.hover))) .child( Icon::new(IconName::FolderOpen) - .xsmall() - .text_color(foreground), + .size(px(ROW_GLYPH)) + .text_color(muted), + ) + .child( + div() + .flex_1() + .min_w_0() + .text_size(rems(TEXT)) + .text_color(name_ink) + .child(".."), ) - .child(div().flex_1().min_w_0().text_sm().child("..")) .on_click(cx.listener(|this, _, _w, cx| this.sftp_up(cx))) .into_any_element() } fn render_sftp_row(&self, entry: &SftpEntry, cx: &mut Context) -> AnyElement { - let foreground = cx.theme().foreground; + // The file tree's row: every glyph in caption ink, the name in the + // rail's title ink, the size a tabular caption at the trailing end. + let name_ink = cx.theme().sidebar_foreground; let muted = cx.theme().muted_foreground; - let dir_color = foreground; - let list_hover = cx.theme().list_hover; + let list_hover = gpui::rgb(cx.global::().popover.hover); let entry = entry.clone(); let dir_like = is_dir_like(&entry); let icon = if dir_like { @@ -1575,13 +1600,12 @@ impl Tty7App { h_flex() .id(row_id) .items_center() - .gap_1() - .pl(px(6.)) - .pr_1() - .py_1() - .rounded(cx.theme().radius) + .gap(px(SFTP_ROW_GAP)) + .h(px(SFTP_ROW_H)) + .px(px(ROW_INSET)) + .rounded(ROW_FILL_RADIUS) .cursor_pointer() - .hover(|s| s.bg(list_hover)) + .hover(move |s| s.bg(list_hover)) // Single click, the same gesture the local file tree answers — // this panel used to demand a double click because its open // action was a download, and that caution outlived the download. @@ -1591,21 +1615,24 @@ impl Tty7App { this.sftp_open_entry(open_entry.clone(), window, cx) }), ) - .child( - Icon::new(icon) - .xsmall() - .text_color(if dir_like { dir_color } else { muted }), - ) + .child(Icon::new(icon).size(px(ROW_GLYPH)).text_color(muted)) .child( div() .flex_1() .min_w_0() - .text_sm() - .text_color(foreground) + .text_size(rems(TEXT)) + .text_color(name_ink) .truncate() .child(name_label), ) - .child(div().flex_none().text_xs().text_color(muted).child(size)) + .child( + div() + .flex_none() + .text_size(rems(META)) + .font_features(crate::ui::theme::tabular_figures()) + .text_color(muted) + .child(size), + ) .context_menu(move |menu, _window, cx| { let danger = cx.theme().danger; Self::sftp_row_context_menu(menu, &menu_entry, dir_like, is_symlink, danger, &app) @@ -1716,8 +1743,10 @@ impl Tty7App { let muted = cx.theme().muted_foreground; let danger = cx.theme().danger; - let accent = cx.theme().accent; - let border = cx.theme().border; + // Progress in v4 is ink on a faint track, not the accent: the bar is a + // reading, and blue is kept for the things you can press. + let (ink, track) = (cx.theme().foreground, cx.theme().muted); + let divider = cx.theme().sidebar_border; let hover = gpui::rgb(cx.global::().sidebar.hover); let expanded = self.sftp_panel.tray_expanded || history; @@ -1769,16 +1798,21 @@ impl Tty7App { .id("sftp-transfers-summary") .items_center() .gap(px(6.)) - .px(px(CONTENT_INSET)) + // The disclosure hangs in the gutter so the summary's text starts + // on the panel's text column, the way a tree row's chevron does. + .pl(px(TEXT_INSET - TRAY_CHEVRON - 6.)) + .pr(px(CONTENT_INSET)) .h(px(28.)) .cursor_pointer() .hover(move |s| s.bg(hover)) .on_click(cx.listener(|this, _, _w, cx| this.sftp_toggle_tray(cx))) .child( - div() - .text_size(rems(META)) - .text_color(muted) - .child(if expanded { "⌄" } else { "›" }), + Icon::new(match expanded { + true => IconName::ChevronDown, + false => IconName::ChevronRight, + }) + .size(px(TRAY_CHEVRON)) + .text_color(muted), ) .child( div() @@ -1786,6 +1820,7 @@ impl Tty7App { .min_w_0() .truncate() .text_size(rems(META)) + .font_features(crate::ui::theme::tabular_figures()) .text_color(summary_color) .child(summary), ) @@ -1803,17 +1838,17 @@ impl Tty7App { ) .w(px(crate::ui::tab_strip::MIN_TARGET)) .h(px(crate::ui::tab_strip::MIN_TARGET)) - .rounded(px(4.)) + .rounded(px(crate::ui::tab_strip::RAIL_TILE_RADIUS)) .tooltip(t(L10nKey::Dismiss)) .on_click(cx.listener(|this, _, _w, cx| this.sftp_dismiss_tray(cx))), ), ); - let underline = div().h(px(2.)).w_full().bg(border).child( + let underline = div().h(px(2.)).w_full().bg(track).child( div() .h_full() .w(gpui::relative((pct / 100.0) as f32)) - .bg(if failed > 0 { danger } else { accent }), + .bg(if failed > 0 { danger } else { ink }), ); let body = expanded.then(|| { @@ -1830,14 +1865,14 @@ impl Tty7App { }; v_flex().child( div() - .px(px(CONTENT_INSET)) + .px(px(TEXT_INSET)) .py(px(3.)) .text_size(rems(META)) .text_color(color) .child(text), ) } else { - let mut list = v_flex().px(px(CONTENT_INSET)).pb(px(6.)).gap(px(6.)); + let mut list = v_flex().px(px(TEXT_INSET)).pb(px(8.)).gap(px(8.)); for job in jobs { list = list.child(self.render_sftp_job(job, cx)); } @@ -1866,7 +1901,7 @@ impl Tty7App { v_flex() .flex_none() .border_t_1() - .border_color(border) + .border_color(divider) .child(head) .when(running > 0 && !expanded, |this| this.child(underline)) .children(body) @@ -1876,11 +1911,10 @@ impl Tty7App { fn render_sftp_job(&self, job: &SftpJobProgress, cx: &mut Context) -> Div { let foreground = cx.theme().foreground; - let border = cx.theme().border; + let track = cx.theme().muted; let danger = cx.theme().danger; let success = cx.theme().success; let muted = cx.theme().muted_foreground; - let accent = cx.theme().accent; let arrow = match job.kind { SftpTransferKind::Upload => "↑", SftpTransferKind::Download => "↓", @@ -1912,10 +1946,11 @@ impl Tty7App { SftpJobState::Done => success, _ => muted, }; + // The tray's own bar: body ink on the faint track, red once it fails. let bar_color = if matches!(job.state, SftpJobState::Error) { danger } else { - accent + foreground }; let job_id = job.job_id; let running = matches!(job.state, SftpJobState::Running); @@ -1934,7 +1969,7 @@ impl Tty7App { div() .flex_1() .min_w_0() - .text_xs() + .text_size(rems(META)) .text_color(foreground) .truncate() .child(format!("{arrow} {name}")), @@ -1971,7 +2006,7 @@ impl Tty7App { }), ) .child( - div().h(px(3.)).w_full().rounded_full().bg(border).child( + div().h(px(3.)).w_full().rounded_full().bg(track).child( div() .h_full() .w(gpui::relative((pct / 100.0) as f32)) @@ -1979,7 +2014,13 @@ impl Tty7App { .bg(bar_color), ), ) - .child(div().text_xs().text_color(status_color).child(status)) + .child( + div() + .text_size(rems(META)) + .font_features(crate::ui::theme::tabular_figures()) + .text_color(status_color) + .child(status), + ) } } diff --git a/src/ui/theme.rs b/src/ui/theme.rs index 03bf879a..70a88d70 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -947,6 +947,36 @@ pub(crate) fn floating_surface(element: T, cx: &App) -> T { .shadow_xl() } +/// One device pixel: the v4 hairline, 0.5px on a Retina panel and a whole +/// pixel on a 1x one. A literal `px(0.5)` would be antialiased down to half +/// its ink on a 1x display, under the contrast floor `divider` is held to. +pub(crate) fn hairline(window: &Window) -> Pixels { + px(1. / window.scale_factor().max(1.)) +} + +/// Tabular figures, so a column of counts, sizes and positions keeps its +/// digits on one grid while the numbers change under it. +pub(crate) fn tabular_figures() -> gpui::FontFeatures { + gpui::FontFeatures(std::sync::Arc::new(vec![("tnum".to_string(), 1)])) +} + +/// The v4 primary button: an inverted neutral — body ink as the fill, the +/// surface it sits on as the label — rather than the accent, which stays with +/// focus and the things that are on. `surface` is the opaque fill under the +/// button, so the label reads as cut out of it. Disabled, gpui-component +/// drops the fill to 15% of itself, the faint well the rest of v4 uses. +pub(crate) fn inverted_button( + surface: Hsla, + cx: &App, +) -> gpui_component::button::ButtonCustomVariant { + let ink = cx.theme().foreground; + gpui_component::button::ButtonCustomVariant::new(cx) + .color(ink) + .foreground(surface) + .hover(ink.blend(surface.opacity(0.14))) + .active(ink.blend(surface.opacity(0.24))) +} + /// On-state shares the accent role with sliders and primary actions. pub(crate) fn switch(id: impl Into, cx: &App) -> gpui_component::switch::Switch { let accent = cx.global::().0;