diff --git a/src/ui/app.rs b/src/ui/app.rs index 42aab1bc..5a3f3563 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -166,6 +166,12 @@ pub(crate) fn tile_trailing_inset_sm() -> f32 { /// here, not at the window edge. pub(crate) const TITLE_BAR_LEAD: f32 = if cfg!(target_os = "macos") { 80. } else { 12. }; +/// What the bar reserves at its *trailing* edge for the window controls: three +/// 34px tiles (─ ▢ ✕) off macOS, nothing on macOS (the traffic lights are on the +/// left, and `TITLE_BAR_LEAD` covers them). Anything in the bar that has to line +/// up with a column below it measures from the window edge minus this. +pub(crate) const WINDOW_CONTROLS_W: f32 = if cfg!(target_os = "macos") { 0. } else { 102. }; + /// Left offset for the tile group that sits beside the window controls. /// /// On macOS the thing that can collide with the traffic lights is the tile's @@ -5165,68 +5171,103 @@ impl Render for Tty7App { pair.into_iter().filter_map(|(_, el)| el).collect() }; - // The two layouts. Horizontal: a column of [title bar / body]. - // Vertical (default): the rail is a full-height *left column* that reaches the very - // top of the window — the traffic lights sit on its surface — with the - // title strip and terminal stacked in the right column. That way the rail - // surface has no seam with the title bar and reads as one continuous - // panel. - // The right detail panel is a full-height column, not a box under the title - // bar: it carries its own title-bar-height top zone (tab row + the window's - // corner chrome) exactly like the rail does on the left, so its surface - // runs unbroken from the very top of the window. Anything less leaves a - // horizontal seam where the panel's grey starts under the terminal's bar. + // The layout. The rail (vertical mode) is a full-height *left column* that + // reaches the very top of the window — the traffic lights sit on its + // surface — with the title strip and terminal stacked in the right column. + // That way the rail surface has no seam with the title bar and reads as one + // continuous panel. + // + // The right detail panel does the same on macOS: a full-height column + // carrying its own title-bar-height top zone (tab row + the window's corner + // chrome), so its surface runs unbroken from the very top of the window. + // + // Off macOS it can't. The window controls (─ ▢ ✕) are laid out by the title + // bar itself, at *its* right end, so a full-height panel beside the bar + // strands them mid-window with the panel's grey to their right. There the + // bar spans the panel too — reaching the real top-right corner, where + // Windows and Linux users expect the controls — and the panel hangs below + // it, VS Code style. The panel's tab row then sits on the panel (no longer + // in the caption row), and the corner chrome stays in the strip. let right_panel = self.render_right_panel(window, cx); - let main_layout = match sidebar { - Some(sidebar) => div() - .flex_1() - .min_h_0() - .w_full() - .flex() - .flex_row() - .child(sidebar) - .child( - div() - .flex_1() - .min_w_0() - .flex() - .flex_col() - // Anchor for the code overlay: it fills this column - // (title strip + body) — and, since the panel is a sibling - // rather than a child, stops short of the panel for free. - .relative() - .child(title_bar) - .child(body_area) - .children(overlays), - ) - .when_some(right_panel, |this, panel| this.child(panel)) - .into_any_element(), - // Horizontal-tabs mode has no rail, but the panel is still a column - // beside the stacked [title bar / body], for the same reason: it has to - // own its own top zone to read as one surface. - None => div() - .flex_1() - .min_h_0() - .w_full() - .flex() - .flex_row() - .child( - div() - .flex_1() - .min_w_0() - .flex() - .flex_col() - .relative() - .child(title_bar) - .child(body_area) - // Both overlays cover the whole window face here (their - // content pads down past the traffic lights — see - // `render_code_overlay` and `diff_header`). - .children(overlays), - ) - .when_some(right_panel, |this, panel| this.child(panel)) - .into_any_element(), + let panel_below_title_bar = right_panel.is_some() && !cfg!(target_os = "macos"); + // Which host the bar goes to: the terminal column's first child, or the + // spanning row above [terminal | panel]. + let (column_title_bar, spanning_title_bar) = if panel_below_title_bar { + (None, Some(title_bar)) + } else { + (Some(title_bar), None) }; + // 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, + // which keeps the native controls clickable while an overlay is open and + // lines the overlay's own header row up with the panel's tab row. + let terminal_column = div() + .flex_1() + .min_w_0() + .flex() + .flex_col() + .relative() + .when_some(column_title_bar, |this, bar| this.child(bar)) + .child(body_area) + .children(overlays); + let panel_row = div() + .flex_1() + .min_h_0() + .min_w_0() + .flex() + .flex_row() + .child(terminal_column) + .when_some(right_panel, |this, panel| this.child(panel)); + let main_layout = div() + .flex_1() + .min_h_0() + .w_full() + .flex() + .flex_row() + .when_some(sidebar, |this, sidebar| this.child(sidebar)) + .child(match spanning_title_bar { + Some(bar) => div() + .flex_1() + .min_w_0() + .flex() + .flex_col() + .child( + // The bar's own band over the panel, painted in the panel's + // surface so the column still reads as one continuous + // sidebar from the very top of the window — the rail's + // trick, kept now that the tab row moved off the caption + // line. Without it the panel's grey started 40px down and + // the corner tore into two colours. + // + // A sibling *under* the transparent bar rather than padding + // inside it: the ─ ▢ ✕ group is the bar's own last child, so + // nothing laid out in the bar can get behind the controls, + // and only a layer below can carry a surface under them. + // Same width and left border as the panel, both read from + // `right_panel_px`, so the edge stays in register through a + // resize drag. + div() + .relative() + .flex_none() + .child( + div() + .absolute() + .top_0() + .bottom_0() + .right_0() + .w(px(self.right_panel_px(window, cx))) + .bg(cx.theme().sidebar) + .border_l_1() + .border_color(cx.theme().sidebar_border), + ) + .child(bar), + ) + .child(panel_row) + .into_any_element(), + None => panel_row.into_any_element(), + }) + .into_any_element(); // The real window background paint: gradient-aware and opacity-carrying // (see `theme::window_background`), plus the theme's optional background diff --git a/src/ui/file_tree.rs b/src/ui/file_tree.rs index ae4dfa7f..770500a3 100644 --- a/src/ui/file_tree.rs +++ b/src/ui/file_tree.rs @@ -1247,9 +1247,18 @@ impl Tty7App { let app = cx.entity().downgrade(); let path = path.clone(); let is_root = row.is_root; + let show_hidden = self.file_tree.show_hidden; move |menu, _window, cx| { let danger = cx.theme().danger; - Self::tree_row_context_menu(menu, &path, is_dir, is_root, danger, &app) + Self::tree_row_context_menu( + menu, + &path, + is_dir, + is_root, + show_hidden, + danger, + &app, + ) } }); @@ -1279,12 +1288,16 @@ impl Tty7App { out } - /// The per-row right-click menu, mirroring Warp's Project Explorer set. + /// The per-row right-click menu, mirroring Warp's Project Explorer set, plus + /// the tree's one view option (dotfiles) — which lives here rather than as a + /// header button: it is set once and then forgotten, and a tile in the header + /// spends the panel's scarcest row on it forever. fn tree_row_context_menu( menu: PopupMenu, path: &Path, is_dir: bool, is_root: bool, + show_hidden: bool, danger: gpui::Hsla, app: &gpui::WeakEntity, ) -> PopupMenu { @@ -1379,6 +1392,8 @@ impl Tty7App { } })); + menu = menu.separator().item(dotfiles_menu_item(show_hidden, app)); + if !is_root { menu = menu.separator().item( PopupMenuItem::element(move |_window, _cx| { @@ -1397,6 +1412,27 @@ impl Tty7App { } } +/// The tree's dotfile switch as a row of the tree's existing right-click menu. +/// +/// The label states what the click will do rather than checking off the current +/// state: a single checked item makes `PopupMenu` reserve a left icon gutter on +/// *every* row in the menu (see `tab_strip::window_chrome`), and the menu has a +/// dozen rows with nothing to put in one. +fn dotfiles_menu_item(show_hidden: bool, app: &gpui::WeakEntity) -> PopupMenuItem { + let app = app.clone(); + PopupMenuItem::new(if show_hidden { + "Hide Dotfiles" + } else { + "Show Dotfiles" + }) + .on_click(move |_, _window, cx| { + let _ = app.update(cx, |this, cx| { + this.file_tree.show_hidden = !this.file_tree.show_hidden; + cx.notify(); + }); + }) +} + /// The little drag ghost shown while a row is dragged toward a terminal. struct DragGhost { name: String, diff --git a/src/ui/right_panel.rs b/src/ui/right_panel.rs index df97ac7c..bf38b2af 100644 --- a/src/ui/right_panel.rs +++ b/src/ui/right_panel.rs @@ -2,12 +2,14 @@ //! rather than what it's printing — session facts, its working-tree diff, and //! its file tree. //! -//! It splits across two hosts on purpose. The **tab row lives in the title bar** -//! (built in [`tab_strip`](crate::ui::tab_strip)), so the panel's controls sit on -//! the same line as the window's own chrome instead of stacking a second 40px bar -//! under it; the **body** is this module's column inside `body_area`. The two are -//! kept in register by both measuring from `Config::right_panel_width`, so the -//! tabs sit exactly over the content they switch. +//! Its tab row has two homes. On macOS it is the panel's own title-bar-height top +//! zone, level with the window's chrome, so the column runs unbroken from the top +//! of the window. Off macOS the title bar has to span the panel (the window +//! controls live at its right end), so the row drops to the panel's second line — +//! Cursor-style — while the caption row above is painted in the panel's surface +//! so the column still reads as one colour. +//! Either way the tiles themselves are built in +//! [`tab_strip`](crate::ui::tab_strip), beside the rest of the window's tiles. //! //! No new source of truth: Info reads the same `TerminalView`/`Tab` accessors the //! sidebar row does, Changes probes the same `git_diff` the diff overlay does, and @@ -182,14 +184,15 @@ impl Tty7App { // bolted under the title bar: its surface runs the full height of // the window, and the tab row sits *on* it rather than on the // terminal's bar above a seam. - .child({ - // The top zone sits level with the real `TitleBar`, but the - // bar only spans the terminal column — so, exactly like the - // rail's top strip (`tab_sidebar`), make this one act like the - // title bar it aligns with: drag to move, double-click to zoom. - // A press arms a flag and the first *move* starts the window - // move, so a plain click on a tab — and a double-click — still - // lands intact; the tabs and corner chrome take their own. + // + // macOS only. Off macOS the bar spans the panel — it has to, or the + // window controls end up stranded mid-window (see `app::render`) — + // and a row of tiles under that caption row was one chrome row too + // many: the panel opened with three stacked headers (caption chrome, + // tab tiles, section title) before any content. So there the tiles + // move into the section header instead (`panel_title`), which is a + // row the panel was drawing anyway. + .children(cfg!(target_os = "macos").then(|| { let should_move = Rc::new(Cell::new(false)); h_flex() .id("right-panel-titlebar-drag") @@ -204,6 +207,14 @@ impl Tty7App { // down a physical pixel the moment the panel opens. .border_b_1() .border_color(cx.theme().transparent) + // The top zone sits level with the real `TitleBar`, but the + // bar only spans the terminal column — so, exactly like the + // rail's top strip (`tab_sidebar`), make this one act like + // the title bar it aligns with: drag to move, double-click + // to zoom. A press arms a flag and the first *move* starts + // the window move, so a plain click on a tab — and a + // double-click — still lands intact; the tabs and corner + // chrome take their own. .window_control_area(WindowControlArea::Drag) .on_mouse_down(MouseButton::Left, { let should_move = should_move.clone(); @@ -227,7 +238,7 @@ impl Tty7App { // The panel is what reaches the window's right edge while // it's open, so it carries the corner chrome. .child(self.window_chrome(window, cx)) - }) + })) .child(body) // The transfers footer is a sibling of the body, not part of any // tab: an SFTP transfer belongs to the pane, so reading Info or @@ -333,14 +344,20 @@ impl Tty7App { (backing, handle) } - /// A section label inside the panel body — the small caps line that names - /// what the icon-only tab row can't. `trailing` carries a tab's own controls - /// where it has any, so they sit on the label's line rather than earning a - /// second header row. /// A tab's header: the name in a weightier small-caps than the old faint /// label, plus an optional live count trailing it (files, commands, changed /// files) so the header states scale at a glance, and an optional control on /// the right. The count is the quiet mono tally the sidebar group headers use. + /// `trailing` carries a tab's own controls where it has any, so they sit on + /// the label's line rather than earning a second header row. + /// + /// Off macOS this row is also the panel's tab switcher: the four tiles ride + /// at its trailing edge, and the row takes the full title-bar height with a + /// hairline under it. The panel there hangs below a caption row that already + /// carries chrome (see `render_right_panel`), and a tile row of its own on top + /// of this one meant three stacked headers before a single line of content — + /// so the two that were saying "this is a header" merge into one that also + /// says which tab you are on. pub(crate) fn panel_title( &self, text: &str, @@ -348,23 +365,37 @@ impl Tty7App { trailing: Option, cx: &mut Context, ) -> AnyElement { + let tabs = (!cfg!(target_os = "macos")).then(|| self.right_panel_tabs(cx)); + let has_trailing = trailing.is_some(); h_flex() .flex_none() - .h(px(32.)) + // Tall enough to seat the chrome-scale tiles when it carries them; + // otherwise the compact label line it has always been. + .h(px(if tabs.is_some() { + crate::ui::app::TITLE_BAR_HEIGHT + } else { + 32. + })) .items_center() - .justify_between() .pl(px(CONTENT_INSET)) // Trailing tiles align on the glyph like every other control in the - // window; a label-only header just takes the plain inset. `_SM` - // because what hangs here is a body-scale tile, whose glyph sits a - // different distance inside its box than the chrome's does. - .pr(px(if trailing.is_some() { - tile_trailing_inset_sm() - } else { - CONTENT_INSET + // window; a label-only header just takes the plain inset. `_SM` for a + // tab's own control, whose glyph sits a different distance inside its + // box than the chrome-scale tab tiles do. + .pr(px(match (&tabs, has_trailing) { + (Some(_), _) => tile_trailing_inset(), + (None, true) => tile_trailing_inset_sm(), + (None, false) => CONTENT_INSET, })) + // The line that separates the header from the tab's content. Only + // where the header is the switcher: a label alone doesn't need ruling + // off from the band it introduces. + .when(tabs.is_some(), |this| { + this.border_b_1().border_color(cx.theme().sidebar_border) + }) .child( h_flex() + .flex_shrink_0() .items_baseline() .gap(px(7.)) .child( @@ -384,35 +415,23 @@ impl Tty7App { ) }), ) + .child(div().flex_1().min_w_0()) .when_some(trailing, |this, t| this.child(t)) + .when_some(tabs, |this, tiles| { + this.child( + h_flex() + .flex_shrink_0() + .items_center() + .gap(px(2.)) + // Clear of a tab's own control where there is one; flush + // against the label's spring where there isn't. + .when(has_trailing, |this| this.ml(px(6.))) + .children(tiles), + ) + }) .into_any_element() } - /// The Files header's one control. No refresh button: the tree runs a - /// recursive filesystem watcher over its roots and invalidates its own caches, - /// so a manual refresh is a button that does what already happened. - fn files_controls(&self, cx: &mut Context) -> AnyElement { - let show_hidden = self.file_tree.show_hidden; - crate::ui::tab_strip::chrome_tile_sized( - Button::new("panel-tree-hidden").icon(Icon::new(IconName::Eye)), - TILE_SIZE_SM, - TILE_GLYPH_SM, - show_hidden, - cx, - ) - .rounded_md() - .tooltip(if show_hidden { - "Hide dotfiles" - } else { - "Show dotfiles" - }) - .on_click(cx.listener(|this, _, _w, cx| { - this.file_tree.show_hidden = !this.file_tree.show_hidden; - cx.notify(); - })) - .into_any_element() - } - /// A tab's filter box — the same borderless magnifier + input the tab rail /// uses, so everything in the window searches the same way. Sits under the /// header rather than in it: it's a full-width control, not a trailing tile. @@ -1290,8 +1309,10 @@ impl Tty7App { return self.render_panel_sftp(host.unwrap_or_default(), window, cx); } - let controls = self.files_controls(cx); - let title = self.panel_title("Files", None, Some(controls), cx); + // No header control: the tree's one view option (dotfiles) is a + // right-click away in the tree itself (`file_tree::dotfiles_menu_item`), + // which is where you are when you want it. + let title = self.panel_title("Files", None, None, cx); let search = self.panel_search(&self.file_search.clone(), cx); let rows = self.render_file_tree_rows(window, cx); v_flex() diff --git a/src/ui/tab_strip.rs b/src/ui/tab_strip.rs index c5e20dd6..11c4c84d 100644 --- a/src/ui/tab_strip.rs +++ b/src/ui/tab_strip.rs @@ -631,14 +631,23 @@ impl Tty7App { .when(!cfg!(target_os = "macos"), |this| this.pr_1()) .child( div().occlude().flex_shrink_0().child( + // Never drawn selected, exactly like the rail's own toggle + // (`tab_sidebar`): the panel being open is already on screen — + // it *is* the panel — so a lit capsule only restates it, and + // the two panel toggles would disagree about what a chrome tile + // means. The state lives in the tooltip's verb instead. chrome_tile( Button::new("titlebar-right-panel") .icon(Icon::empty().path("icons/panel-right.svg")), - panel_open, + false, cx, ) .rounded_lg() - .tooltip("Detail Panel") + .tooltip(if panel_open { + "Hide Detail Panel" + } else { + "Show Detail Panel" + }) .on_click(cx.listener(|this, _, _window, cx| { this.toggle_right_panel(cx); })), @@ -1107,12 +1116,21 @@ impl Tty7App { } else { (window.viewport_size().width - px(114.)).max(px(140.)) }; + // Off macOS the bar spans the detail panel (see `app::render`). The corner + // chrome then sits *over the panel's surface*, so it stops hugging the + // window controls and aligns with the column it is painted on: a block as + // wide as the slice of the panel the bar can reach — panel width less the + // control group, less the panel's own left border — with the tiles packed + // at its leading edge, on the same inset as everything else in the panel. + let chrome_band_w = (!cfg!(target_os = "macos") && self.right_panel_open(cx)).then(|| { + (self.right_panel_px(window, cx) - crate::ui::app::WINDOW_CONTROLS_W - 1.).max(0.) + }); // The "+" and the right-edge overflow "⋯" (30px each), their surrounding // gaps, and the strip's own left/right padding all live *outside* the // clipped chip row — reserve that whole footprint here so the fixed chrome // never overflows the strip box (which would eat the "⋯"'s right inset and // shove it into the window corner) and cap the chip row at the remainder. - let chips_avail = (strip_w - px(100.)).max(px(80.)); + let chips_avail = (strip_w - px(100.) - px(chrome_band_w.unwrap_or(0.))).max(px(80.)); // Only the chip row clips; a crowded row shrinks its chips (down to their // `min_w`) and truncates their labels rather than pushing the "+" away. let mut chips = h_flex() @@ -1532,11 +1550,14 @@ impl Tty7App { }); let panel_open = self.right_panel_open(cx); - // The window's right-corner chrome. When the panel is open it lives on the - // *panel's* top zone (the panel is what reaches the window's right edge - // then) exactly like the rail's controls live on the rail; the strip only - // carries it while the panel is closed. - let right_chrome = (!panel_open).then(|| self.window_chrome(window, cx)); + // The window's right-corner chrome. On macOS, when the panel is open it + // lives on the *panel's* top zone (the panel is what reaches the window's + // right edge then) exactly like the rail's controls live on the rail; the + // strip only carries it while the panel is closed. Off macOS the bar spans + // the panel (the window controls are at its right end — see `app::render`), + // so the strip always reaches the right edge and always carries the chrome. + let right_chrome = + (!panel_open || !cfg!(target_os = "macos")).then(|| self.window_chrome(window, cx)); // Outer strip: the clipping chip row and the always-visible "+" anchored // left, the overflow "⋯" pushed to the right edge by a flexible spacer. @@ -1567,7 +1588,22 @@ impl Tty7App { // leaving just the "⋯" overflow menu on a thin strip. .when(show_chips, move |this| this.child(add_button)) .child(div().flex_1()) - .when_some(right_chrome, |this, chrome| this.child(chrome)) + .when_some(right_chrome, |this, chrome| match chrome_band_w { + // Over the panel: left-aligned on the panel's content edge, and + // wide enough that its own right edge lands where the window + // controls start. + Some(w) => this.child( + h_flex() + .flex_none() + .w(px(w)) + .items_center() + .pl(px(tile_trailing_inset())) + .child(chrome), + ), + // Over the terminal: pinned to the strip's trailing edge, which is + // the window's right edge (or the controls' left edge off macOS). + None => this.child(chrome), + }) } }