diff --git a/CHANGELOG.md b/CHANGELOG.md index 7210b8d0..3adc8124 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **The window's leading corner carries the app's mark off macOS** — macOS fills + the top-left with the traffic lights; on Windows and Linux that corner was + empty, with everything the caption row holds (the rail's "+" and collapse, the + corner chrome, the window controls) pushed to a right edge. The "duo" mark now + heads the tab rail on its content inset, the line the search box and every row + label below it start on, and follows the rail's controls into the title strip + when the sidebar is collapsed — so the corner never falls back to nothing. + Drawn, never clicked: it takes no hover capsule and no hit box, leaving the + strip grabbable through it. + ### Fixed +- **The editor and diff overlays keep their header on the caption line when the + detail panel is open** — off macOS the title bar is hoisted above + `[terminal | panel]` so the ─ ▢ ✕ group can reach the window's corner, which + left both overlays — anchored to the terminal column — starting 40px down. + Their headers are drawn to *be* the title bar while they're up (its height, + its insets, a full chrome tile for their one control), and instead landed a + row low, level with the panel's tab row. They now hang on the row that owns + the bar, inset by the panel's width, so the corner chrome keeps its surface + and its clicks. +- **Those headers became real title bars** — dragging one now moves the window + and double-clicking zooms it. Both covered the caption row and neither did + either, with the panel open or closed, so opening a file turned the top of the + window into a 40px strip that looked exactly like a title bar and answered + nothing. Their controls (the ✕, the diff's back-to-all-files chip) are + `occlude()`d to keep taking clicks: a drag region on Windows is HTCAPTION, and + the OS claims the press before the app hit-tests. +- **The rail's top zone lines up with the title bar to the pixel** — the bar + reserves a hairline inside its own height that the rail's stand-in row didn't, + so everything in that row sat half a pixel low. Invisible on the line-art + tiles; not on the mark, which visibly hopped as collapsing the rail handed it + over to the bar. - **CJK and emoji stop falling through to the OS on Windows and Linux** — the default `font_fallbacks` named only faces that ship with macOS (Menlo, Apple Color Emoji), so off macOS the entire chain matched nothing and every diff --git a/src/ui/app.rs b/src/ui/app.rs index 6772af2c..6014c80e 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -9,7 +9,9 @@ use gpui_component::color_picker::{ColorPickerEvent, ColorPickerState}; use gpui_component::input::{InputEvent, InputState}; use gpui_component::select::{SearchableVec, SelectEvent, SelectState}; use gpui_component::slider::{SliderEvent, SliderState}; -use gpui_component::{ActiveTheme as _, IndexPath, TitleBar, WindowExt as _}; +use gpui_component::{ + ActiveTheme as _, IndexPath, InteractiveElementExt as _, TitleBar, WindowExt as _, +}; use std::cell::{Cell, RefCell}; use std::collections::HashSet; use std::rc::Rc; @@ -190,6 +192,79 @@ pub(crate) fn title_bar_hug_offset() -> f32 { } } +/// Edge of the brand mark that anchors the window's leading corner off macOS +/// (see [`window_mark`]). Between a chrome tile's 32px hit box and its 13px +/// glyph: the mark paints no hover capsule, so what has to sit level with the +/// tiles beside it is its *ink* — and solid art reads heavier than line work at +/// equal size, hence short of the tile box rather than matching it. +pub(crate) const WINDOW_MARK_SIZE: f32 = 20.; + +/// The "duo" mark — the same art the app icon and the About page carry — drawn +/// at the leading edge of the title-bar row, or `None` on macOS. +/// +/// macOS owns that corner: the traffic lights sit there, and [`TITLE_BAR_LEAD`] +/// reserves them 80px. Everywhere else it is empty. The row's contents are the +/// rail's controls at its *right* end and the window chrome at the far side, so +/// the window's leading corner — the slot Windows reads as the app's identity, +/// filled by Explorer, VS Code and Zed alike — held nothing at all, which comes +/// across as unfinished rather than restrained. +/// +/// Drawn, never clicked. It is not a menu button, so it stays out of the tile +/// rhythm (no hover capsule) and deliberately takes no `occlude()`: the row it +/// lives in is a `WindowControlArea::Drag`, and letting the mark fall through to +/// that keeps the strip grabbable instead of punching a dead 20px hole in it. +/// Make a row that stands in for the title bar behave like one: drag it to move +/// the window, double-click it to zoom. +/// +/// Three rows do this. The rail's top zone sits level with the real bar but +/// outside it (the bar only spans the column beside the rail), and the code and +/// diff overlays each cover the bar with a header of their own drawn to its line. +/// Without this they are all dead strips: 40px across the top of the window that +/// look exactly like the caption and do nothing when you grab them. +/// +/// Driven the way gpui-component's own `TitleBar` drives it — a press arms a +/// flag and the first *move* starts the window move — so a plain click, and a +/// double-click, still land intact. Note that on Windows the drag area maps to +/// HTCAPTION and the OS claims the press before gpui hit-tests, so every button +/// inside one of these rows needs an `occlude()` wrapper to get its clicks back. +pub(crate) fn title_bar_drag(row: gpui::Stateful) -> gpui::Stateful { + let should_move = Rc::new(Cell::new(false)); + row.window_control_area(gpui::WindowControlArea::Drag) + .on_mouse_down(gpui::MouseButton::Left, { + let should_move = should_move.clone(); + move |_, _, _| should_move.set(true) + }) + .on_mouse_up(gpui::MouseButton::Left, { + let should_move = should_move.clone(); + move |_, _, _| should_move.set(false) + }) + .on_mouse_move(move |_, window, _| { + if should_move.replace(false) { + window.start_window_move(); + } + }) + .on_double_click(|_, window, _| window.titlebar_double_click()) +} + +pub(crate) fn window_mark() -> Option { + if cfg!(target_os = "macos") { + return None; + } + // Decoded once and shared: the title bar re-renders on every cursor blink, + // and building a fresh `Image` per frame would re-copy the PNG and miss + // gpui's image cache, which is keyed on the image's identity. + static LOGO: std::sync::OnceLock> = std::sync::OnceLock::new(); + let logo = LOGO + .get_or_init(|| { + Arc::new(gpui::Image::from_bytes( + gpui::ImageFormat::Png, + include_bytes!("../../assets/logo@256.png").to_vec(), + )) + }) + .clone(); + Some(img(logo).size(px(WINDOW_MARK_SIZE)).flex_shrink_0()) +} + /// One tab: a split-pane tree plus an optional user-assigned name. Settings is /// no longer a tab — it's a full-window overlay (`Tty7App::settings`), so every /// tab is a real terminal tab. @@ -5198,6 +5273,26 @@ impl Render for Tty7App { } else { (Some(title_bar), None) }; + // And where the overlays hang. Normally on the terminal column, which they + // fill: the bar is that column's first child, so an `inset_0` overlay + // covers it and the overlay's own header row lands *on* the caption line — + // which is what both headers are drawn for (title-bar height, the bar's + // insets, a full-size chrome tile for their one control). + // + // With the bar hoisted, a column-anchored overlay starts 40px down and its + // header sits one row too low: level with the panel's tab row instead of + // with the caption. So it hangs on the row that owns the bar instead, + // inset from the right by the panel's width — covering the bar's band over + // the terminal column (which carries nothing there but the drag region, or + // the rail's controls while it's collapsed: exactly what an overlay covers + // with the panel closed) and stopping short of the panel, so the ─ ▢ ✕ + // group and the corner chrome keep their own surface and their clicks. + let (column_overlays, hoisted_overlays) = if panel_below_title_bar { + (Vec::new(), overlays) + } else { + (overlays, Vec::new()) + }; + let panel_px = self.right_panel_px(window, cx); // The terminal column, and the anchor for both overlays: they fill it — // and, since the panel is a sibling rather than a child, stop short of the // panel for free. With the bar spanning above, they stop short of it too, @@ -5211,7 +5306,7 @@ impl Render for Tty7App { .relative() .when_some(column_title_bar, |this, bar| this.child(bar)) .child(body_area) - .children(overlays); + .children(column_overlays); let panel_row = div() .flex_1() .min_h_0() @@ -5233,6 +5328,8 @@ impl Render for Tty7App { .min_w_0() .flex() .flex_col() + // The containing block for the hoisted overlays below. + .relative() .child( // The bar's own band over the panel, painted in the panel's // surface so the column still reads as one continuous @@ -5265,6 +5362,19 @@ impl Render for Tty7App { .child(bar), ) .child(panel_row) + // Last child, so they paint over both the bar and the column. + // Each overlay is `absolute().inset_0()` against this wrapper, + // which is the only thing that has to know where the panel + // starts. + .children(hoisted_overlays.into_iter().map(|overlay| { + div() + .absolute() + .top_0() + .left_0() + .bottom_0() + .right(px(panel_px)) + .child(overlay) + })) .into_any_element(), None => panel_row.into_any_element(), }) diff --git a/src/ui/code_editor.rs b/src/ui/code_editor.rs index 645cf793..1c229799 100644 --- a/src/ui/code_editor.rs +++ b/src/ui/code_editor.rs @@ -801,7 +801,7 @@ impl Tty7App { /// every buffer that was ever opened. Sits on the title bar's line and matches /// its height, so the editor's top edge lines up with the panel's tab row and /// the rail's controls across the window. - fn render_editor_header(&self, cx: &mut Context) -> gpui::Div { + fn render_editor_header(&self, cx: &mut Context) -> gpui::Stateful { let active = self.tab_code().and_then(|c| c.active_file()); let name = active.map(|f| f.label()); let dirty = active.is_some_and(|f| f.dirty); @@ -816,7 +816,10 @@ impl Tty7App { } else { crate::ui::app::TITLE_BAR_LEAD }; - h_flex() + // The overlay covers the real title bar, so this row inherits its drag and + // zoom gestures — otherwise opening a file turns the top of the window into + // a strip that looks like the caption and can't move it. + crate::ui::app::title_bar_drag(h_flex().id("editor-header")) .flex_none() .h(px(crate::ui::app::TITLE_BAR_HEIGHT)) .items_center() @@ -847,22 +850,27 @@ impl Tty7App { ) }) .child( - crate::ui::tab_strip::chrome_tile_sized( - // This header is the title bar's own height and sits flush - // with it, so its one control is a full chrome tile — not the - // half-size one it used to be, which read as a different - // class of button on the same line. - Button::new("editor-panel-close").icon(Icon::new(IconName::Close)), - crate::ui::app::TILE_SIZE, - crate::ui::app::TILE_GLYPH_LINE, - false, - cx, - ) - .rounded_lg() - .tooltip("Back to Terminal (Esc)") - .on_click(cx.listener(|this, _, window, cx| { - this.toggle_code_panel(window, cx); - })), + // `occlude()` for the same reason the title bar's own tiles carry + // it: this row is a `WindowControlArea::Drag`, which on Windows is + // HTCAPTION, and the OS takes the press before gpui hit-tests. + div().occlude().flex_shrink_0().child( + crate::ui::tab_strip::chrome_tile_sized( + // This header is the title bar's own height and sits flush + // with it, so its one control is a full chrome tile — not the + // half-size one it used to be, which read as a different + // class of button on the same line. + Button::new("editor-panel-close").icon(Icon::new(IconName::Close)), + crate::ui::app::TILE_SIZE, + crate::ui::app::TILE_GLYPH_LINE, + false, + cx, + ) + .rounded_lg() + .tooltip("Back to Terminal (Esc)") + .on_click(cx.listener(|this, _, window, cx| { + this.toggle_code_panel(window, cx); + })), + ), ) } diff --git a/src/ui/diff_overlay.rs b/src/ui/diff_overlay.rs index 340acd7d..9126c843 100644 --- a/src/ui/diff_overlay.rs +++ b/src/ui/diff_overlay.rs @@ -327,7 +327,10 @@ impl Tty7App { } else { crate::ui::app::TITLE_BAR_LEAD }; - h_flex() + // Standing in for the title bar means carrying its gestures too: the + // overlay covers the real bar, so without this the whole top of the window + // stops moving it while a diff is up. + crate::ui::app::title_bar_drag(h_flex().id("diff-overlay-header")) .flex_shrink_0() .h(px(crate::ui::app::TITLE_BAR_HEIGHT)) .pl(px(lead)) @@ -355,38 +358,42 @@ impl Tty7App { // click target back to the whole tree — otherwise the only way out // of a focused view would be to close and re-open the overlay. .when_some(focused_name(overlay), |bar, name| { + // Wrapped like every other control on a drag row — see the header's + // own note: HTCAPTION would otherwise swallow the click on Windows. bar.child( - h_flex() - .id("diff-overlay-unfocus") - .items_center() - .gap_1() - .px_1p5() - .py_0p5() - .rounded_md() - .cursor_pointer() - .hover(|s| s.bg(cx.theme().list_hover)) - .on_click(cx.listener(|this, _, _window, cx| { - let active = this.active; - if let Some(overlay) = this - .tabs - .get_mut(active) - .and_then(|t| t.diff_overlay.as_mut()) - { - overlay.focus = None; - cx.notify(); - } - })) - .child( - Icon::new(IconName::ChevronLeft) - .small() - .text_color(cx.theme().muted_foreground), - ) - .child( - div() - .text_xs() - .font_family(self.font_family.clone()) - .child(name), - ), + div().occlude().flex_shrink_0().child( + h_flex() + .id("diff-overlay-unfocus") + .items_center() + .gap_1() + .px_1p5() + .py_0p5() + .rounded_md() + .cursor_pointer() + .hover(|s| s.bg(cx.theme().list_hover)) + .on_click(cx.listener(|this, _, _window, cx| { + let active = this.active; + if let Some(overlay) = this + .tabs + .get_mut(active) + .and_then(|t| t.diff_overlay.as_mut()) + { + overlay.focus = None; + cx.notify(); + } + })) + .child( + Icon::new(IconName::ChevronLeft) + .small() + .text_color(cx.theme().muted_foreground), + ) + .child( + div() + .text_xs() + .font_family(self.font_family.clone()) + .child(name), + ), + ), ) }) .when( @@ -438,21 +445,23 @@ impl Tty7App { ) .child(div().flex_1()) .child( - crate::ui::tab_strip::chrome_tile_sized( - // Explicit tile, not `.small()`: this bar stands in for the - // title bar while the overlay is up, so its close control is - // the same tile the title bar's controls are. - Button::new("diff-overlay-close").icon(Icon::new(IconName::Close)), - crate::ui::app::TILE_SIZE, - crate::ui::app::TILE_GLYPH_LINE, - false, - cx, - ) - .rounded_lg() - .tooltip("Close Diff (Esc)") - .on_click(cx.listener(|this, _, window, cx| { - this.close_diff_overlay(window, cx); - })), + div().occlude().flex_shrink_0().child( + crate::ui::tab_strip::chrome_tile_sized( + // Explicit tile, not `.small()`: this bar stands in for the + // title bar while the overlay is up, so its close control is + // the same tile the title bar's controls are. + Button::new("diff-overlay-close").icon(Icon::new(IconName::Close)), + crate::ui::app::TILE_SIZE, + crate::ui::app::TILE_GLYPH_LINE, + false, + cx, + ) + .rounded_lg() + .tooltip("Close Diff (Esc)") + .on_click(cx.listener(|this, _, window, cx| { + this.close_diff_overlay(window, cx); + })), + ), ) } diff --git a/src/ui/tab_sidebar.rs b/src/ui/tab_sidebar.rs index bc4e7768..66ad5db1 100644 --- a/src/ui/tab_sidebar.rs +++ b/src/ui/tab_sidebar.rs @@ -13,16 +13,13 @@ use gpui::{ Animation, AnimationExt as _, AnyElement, Axis, Bounds, Context, Div, FontWeight, MouseButton, - MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, SharedString, Stateful, Window, - WindowControlArea, canvas, deferred, div, ease_out_quint, linear_color_stop, linear_gradient, - prelude::*, px, + MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, SharedString, Stateful, Window, canvas, + deferred, div, ease_out_quint, linear_color_stop, linear_gradient, prelude::*, px, }; use gpui_component::button::{Button, ButtonVariants as _}; use gpui_component::input::Input; use gpui_component::menu::{ContextMenu, ContextMenuExt as _}; -use gpui_component::{ - ActiveTheme as _, Icon, IconName, InteractiveElementExt as _, Sizable as _, h_flex, v_flex, -}; +use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex, v_flex}; use std::cell::{Cell, RefCell}; use std::rc::Rc; @@ -751,11 +748,35 @@ impl Tty7App { let controls = h_flex() .flex_shrink_0() .h(px(TITLE_BAR_HEIGHT)) + // Same box as the real title bar this row stands in for, hairline + // included: gpui-component's `TitleBar` draws a `border_b_1` inside its + // own `TITLE_BAR_HEIGHT` (tty7 paints it transparent, but it still takes + // its pixel), so the bar centres its contents on 19.5 while an + // unbordered 40px row centres them on 20. Half a pixel is invisible on + // the line-art tiles, and *not* on the solid brand mark: collapsing the + // rail hands the mark from this row to the bar, and it visibly hopped up + // as it went. Reserve the same pixel here and the handover is still. + .border_b_1() + .border_color(cx.theme().transparent) .items_center() .justify_end() .gap(px(2.)) // Glyph's ink, not hit box, on the content edge — see `TILE_PAD`. .pr(px(crate::ui::app::tile_trailing_inset())) + // The brand mark leads the row, on the rail's own content inset — the + // line the search magnifier and every row label below it start on, so + // it reads as the head of this column rather than a floating badge. + // The spacer is what keeps the controls pinned right once the row has + // a leading child (`justify_end` alone no longer does it). + .when_some(crate::ui::app::window_mark(), |row, mark| { + row.child( + div() + .flex_shrink_0() + .pl(px(crate::ui::app::CONTENT_INSET)) + .child(mark), + ) + .child(div().flex_1()) + }) // Both tiles are wrapped in an `occlude()` div, exactly like the // title-strip chrome. This row is a `WindowControlArea::Drag` (set // below), which on Windows maps to HTCAPTION — the OS claims the click @@ -934,34 +955,13 @@ impl Tty7App { // // The real `TitleBar` — which carries the window's drag region // — only spans the *right* column in this layout, so this strip - // would be dead space you can't grab the window by. Make the - // controls' own row act like the title bar it sits level with: - // drag to move, double-click to zoom. Driven exactly like - // `TitleBar` does it (and the settings overlay's stand-in - // strip): a press arms a flag and the first *move* starts the - // window move, so a plain click — and a double-click — still - // lands intact, while the buttons on the right keep taking - // their own clicks. - .child({ - let should_move = Rc::new(Cell::new(false)); - controls - .id("sidebar-titlebar-drag") - .window_control_area(WindowControlArea::Drag) - .on_mouse_down(MouseButton::Left, { - let should_move = should_move.clone(); - move |_, _, _| should_move.set(true) - }) - .on_mouse_up(MouseButton::Left, { - let should_move = should_move.clone(); - move |_, _, _| should_move.set(false) - }) - .on_mouse_move(move |_, window, _| { - if should_move.replace(false) { - window.start_window_move(); - } - }) - .on_double_click(|_, window, _| window.titlebar_double_click()) - }) + // would be dead space you can't grab the window by. `title_bar_drag` + // makes the controls' row act like the bar it sits level with: + // drag to move, double-click to zoom, while the buttons on the + // right keep taking their own clicks (they're `occlude()`d). + .child(crate::ui::app::title_bar_drag( + controls.id("sidebar-titlebar-drag"), + )) .child(top_bar) .child(crate::ui::scrollbar::with_vertical_scrollbar( "tab-sidebar-scrollbar", diff --git a/src/ui/tab_strip.rs b/src/ui/tab_strip.rs index 11c4c84d..d9994a99 100644 --- a/src/ui/tab_strip.rs +++ b/src/ui/tab_strip.rs @@ -1518,6 +1518,25 @@ impl Tty7App { // Negative off macOS only: the bar already inset us past the window // controls, and there the reserve *is* the clearance. .ml(px(crate::ui::app::title_bar_hug_offset())) + // The brand mark follows the rail's controls into the strip, so + // collapsing the sidebar doesn't strip the window's leading corner + // back to nothing (see `app::window_mark`). The group is anchored by + // its tiles' *hit boxes*, which start `tile_trailing_inset()` from + // the window edge; the mark has no box, so it adds the difference + // back to land its own ink on `CONTENT_INSET` like the rail's did. + .when_some(crate::ui::app::window_mark(), |group, mark| { + group.child( + div() + .flex_shrink_0() + .pl(px(crate::ui::app::CONTENT_INSET + - crate::ui::app::tile_trailing_inset())) + // The mark is solid where the tiles are line work, so it + // needs more air than the 2px that separates two tiles + // before the "+" beside it stops reading as part of it. + .pr(px(4.)) + .child(mark), + ) + }) .child( div().occlude().flex_shrink_0().child( self.attach_new_tab_menu(