From 0277ca67dc89a851dbf93dfa5e828db86805dba1 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:59:53 +0800 Subject: [PATCH] Centre the title-bar search box; bring back auto-hiding title-bar buttons as an Appearance option (#1036) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(settings): an Appearance option to show the title-bar buttons only under the pointer 26.9.2 painted the new-tab and sidebar tiles only while the pointer was over the bar they sit in; 26.9.3 took that out so the buttons would stay discoverable. Both are fair, so it comes back as a choice: Appearance > "Show title bar buttons on hover" (`auto_hide_titlebar_buttons`), off by default, which keeps today's always-visible bar for everyone who has not asked otherwise. A config written before the key existed reads as off. With it on, the rail's two tiles follow the rail, and the collapsed rail's pair and the trailing panel toggle follow the tab strip. The window mark stays put, the tiles keep their layout slot so a reveal never shifts anything, and the title-bar search box is always drawn. The trailing toggle stays painted while the detail panel is open, as before, since the panel's own tab row beside it always is. The reveal is the hover sheet from 26.9.2 — a transparent last child over each region, because `group_hover` loses the region the moment the pointer reaches an occluding tile. Resting tiles go to zero opacity rather than `invisible()`: gpui skips a hidden element's paint pass, which is where its click and accessibility actions are registered, so a screen reader could find a hidden tile by label and then not press it. Shortcuts are actions and never depended on the tiles. Claude-Session: https://claude.ai/code/session_011mDkkQhwx4RJJBHee3yVpq * fix(titlebar): centre the search box on the window, not the bar after the traffic lights On macOS `TitleBar` leaves an 80pt lead for the traffic lights before the tab strip — whether or not the rail stands in front of them — and the Search Everywhere box was centred on the strip. So it sat 40pt right of the terminal column's middle: with the rail collapsed, 40pt right of the window's (#1033). Fullscreen added the bar's own inset on top. The band the box centres in now reaches back over that lead, so its left edge is the terminal column's on every platform (12pt elsewhere). Its right edge is unchanged: the strip's end on macOS, the terminal column's end beside a docked panel or document off it. Reaching back puts the collapsed rail's tiles inside the band, so the box now keeps an equal clearance at both ends — two springs with a minimum width either side of it. In a narrow column it shrinks instead of sliding under New Tab or the panel toggle, and stays centred while it does. The geometry is a pure `search_band`, tested for the macOS rail-collapsed, rail-open and fullscreen cases and off macOS. Claude-Session: https://claude.ai/code/session_011mDkkQhwx4RJJBHee3yVpq * fix(titlebar): clear a hover flag whose sheet is not on screen; document the key A title-bar hover flag is written only by its sheet, and only when the sheet sees the pointer cross its edge. Hide the rail from its own tile, or turn the switch off mid-hover, and the sheet leaves the tree with its flag still set: the next time it is built the tiles came back painted with nobody pointing at them, until the pointer happened to pass through and out again. Clear the flag of any sheet not built this frame. Also list auto_hide_titlebar_buttons in the configuration reference. Claude-Session: https://claude.ai/code/session_011mDkkQhwx4RJJBHee3yVpq --- crates/tty7-core/src/core/config.rs | 24 +++ docs/reference/configuration.mdx | 1 + src/ui/app.rs | 97 ++++++++++- src/ui/i18n/en.rs | 7 + src/ui/i18n/ja.rs | 7 + src/ui/i18n/mod.rs | 3 + src/ui/i18n/zh.rs | 7 + src/ui/settings.rs | 47 ++++++ src/ui/settings/pages.rs | 16 ++ src/ui/tab_sidebar.rs | 56 ++++--- src/ui/tab_strip.rs | 240 ++++++++++++++++++++++++---- 11 files changed, 450 insertions(+), 55 deletions(-) diff --git a/crates/tty7-core/src/core/config.rs b/crates/tty7-core/src/core/config.rs index 153dcfa7..e0e34c4b 100644 --- a/crates/tty7-core/src/core/config.rs +++ b/crates/tty7-core/src/core/config.rs @@ -177,6 +177,14 @@ pub struct Config { pub window_backdrop: WindowBackdrop, #[serde(default = "default_true")] pub dim_inactive_panes: bool, + /// Paint the title bar's buttons — new tab and the two panel toggles — + /// only while the pointer is over the bar they sit in. Off by default: a + /// button that is not on screen is a button nobody finds, and the switch + /// is for those who already know where it is and would rather rest their + /// eyes on a bare bar. The struct-level `serde(default)` reads a file + /// written before the key existed as off, which is how every window drew + /// then. + pub auto_hide_titlebar_buttons: bool, /// Lenient one entry at a time, for the same reason the nested keys below /// are: this is hand-edited, and it used to be all-or-nothing. A single /// value serde could not read — `"ActivateTab1": null`, a number, an object @@ -756,6 +764,7 @@ impl Default for Config { window_blur: None, window_backdrop: WindowBackdrop::default(), dim_inactive_panes: true, + auto_hide_titlebar_buttons: false, keybindings: HashMap::new(), keybinding_preset: default_preset(), prefix: default_prefix(), @@ -1851,6 +1860,21 @@ mod tests { assert!(!back.dim_inactive_panes); } + #[test] + fn auto_hide_titlebar_buttons_defaults_off_and_round_trips() { + assert!(!Config::default().auto_hide_titlebar_buttons); + + // A file from before the key existed keeps its buttons on screen. + let old: Config = serde_json::from_str(r#"{"font_size": 15.0}"#).unwrap(); + assert!(!old.auto_hide_titlebar_buttons); + + let on: Config = serde_json::from_str(r#"{"auto_hide_titlebar_buttons": true}"#).unwrap(); + assert!(on.auto_hide_titlebar_buttons); + let json = serde_json::to_string(&on).unwrap(); + let back: Config = serde_json::from_str(&json).unwrap(); + assert!(back.auto_hide_titlebar_buttons, "persisted"); + } + #[test] fn theme_follow_system_defaults_and_round_trips() { let cfg: Config = serde_json::from_str(r#"{"theme_preset":"dracula"}"#).unwrap(); diff --git a/docs/reference/configuration.mdx b/docs/reference/configuration.mdx index b48fcd95..32968305 100644 --- a/docs/reference/configuration.mdx +++ b/docs/reference/configuration.mdx @@ -77,6 +77,7 @@ sessions. | `window_blur` | bool | — | Blur behind a translucent window (macOS). Unset means "follow the theme". | | `window_backdrop` | enum | `"auto"` | Windows only: `auto`, `blur`, `mica`, `mica-alt`, `acrylic`, `off`. | | `dim_inactive_panes` | bool | `true` | Dim panes that are not focused. | +| `auto_hide_titlebar_buttons` | bool | `false` | Show the title bar's new tab and sidebar buttons only while the pointer is over the bar they sit in. | | `startup_mode` | enum | `"normal"` | `normal`, `maximized`, `fullscreen`. | | `remember_window_size` | bool | `true` | Reopen at the last size and position. | | `restore_session` | bool | `true` | Reopen the last window's tabs, splits, and directories. | diff --git a/src/ui/app.rs b/src/ui/app.rs index 8edf71a0..d4a193f8 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -316,6 +316,57 @@ fn strip_band(viewport: Size, pad: Edges) -> Bounds { pub(crate) const WINDOW_MARK_SIZE: f32 = 20.; +/// A transparent sheet that answers one question: is the pointer inside the +/// box it covers. Lay it over a region as that region's *last* child and read +/// the flag to reveal chrome only while the pointer is there. +/// +/// The obvious way to write this is `group_hover` on the region itself, and it +/// does not work. Group hover asks whether the group's *hitbox* is the one +/// under the pointer, and gpui's hit test stops at the first `occlude()`d +/// element it meets on the way down. Tab chips and the chrome tiles are all +/// occluding, so the region stopped counting as hovered the instant the +/// pointer reached the very button it was revealing, and the button vanished +/// from under the cursor. Painted last, this sheet's own hitbox sits in front +/// of all of them, and it blocks nothing — it is not opaque, so the rows, +/// chips and tiles underneath keep their clicks, cursors and tooltips. +pub(crate) fn hover_sheet(id: &'static str, flag: &Rc>) -> gpui::Stateful { + use gpui::{InteractiveElement as _, StatefulInteractiveElement as _}; + let flag = flag.clone(); + gpui::div() + .id(id) + .absolute() + .inset_0() + .on_hover(move |over, window, _cx| { + if flag.get() != *over { + flag.set(*over); + window.refresh(); + } + }) +} + +/// Whether a group of title-bar tiles paints this frame: always, unless +/// `auto_hide_titlebar_buttons` is on, and then only while the pointer is over +/// the bar the group sits in. +pub(crate) fn titlebar_chrome_shown(auto_hide: bool, pointer_over_bar: bool) -> bool { + !auto_hide || pointer_over_bar +} + +/// Take the paint off a chrome tile that [`titlebar_chrome_shown`] says is +/// resting, and nothing else. +/// +/// Opacity, not `invisible()`. Both keep the tile's slot, so revealing it never +/// shifts what is beside it. But gpui skips a hidden element's whole paint +/// pass, and that is where its click handler, its accessibility actions and +/// its tooltip are registered: a hidden tile still shows up to a screen reader, +/// by label, from prepaint, and then does nothing when pressed from there. At +/// zero opacity the tile is fully there and merely not drawn. +pub(crate) fn resting_chrome(tile: E, shown: bool) -> E { + match shown { + true => tile, + false => tile.opacity(0.), + } +} + pub(crate) fn title_bar_drag( row: gpui::Stateful, key: &'static str, @@ -911,11 +962,15 @@ pub struct Tty7App { pub(crate) editor: crate::ui::code_editor::EditorPanelState, pub(crate) sidebar_width: Rc>, pub(crate) sidebar_dragging: Rc>, - /// Whether the pointer is over the sidebar and over the tab strip. The - /// chrome tiles in each — new tab, the panel toggles, the app menu — are - /// drawn only while its own flag is set, so a window nobody is pointing at - /// carries no buttons at all. The right panel's own title bar is the - /// exception: its tiles are always painted while the panel is open. + /// Whether the pointer is over the sidebar and over the tab strip. With + /// `auto_hide_titlebar_buttons` on, the chrome tiles in each — new tab and + /// the panel toggles — are painted only while its own flag is set, so a + /// window nobody is pointing at carries no buttons at all. The right + /// panel's own title bar is the exception: its tiles are always painted + /// while the panel is open. Written by [`hover_sheet`]; nobody reads them + /// while the setting is off. + pub(crate) sidebar_chrome_hover: Rc>, + pub(crate) strip_chrome_hover: Rc>, /// How much width a settings row will actually get, measured once per /// render. `settings_row` is called from page builders that never see the /// window, and the answer differs per page — the SSH page spends a host @@ -1556,6 +1611,8 @@ impl Tty7App { editor, sidebar_width: Rc::new(Cell::new(sidebar_width)), sidebar_dragging: Rc::new(Cell::new(false)), + sidebar_chrome_hover: Rc::new(Cell::new(false)), + strip_chrome_hover: Rc::new(Cell::new(false)), settings_row_width: Cell::new(f32::MAX), settings_viewport_w: Cell::new(f32::MAX), settings_hit_anchored: Cell::new(false), @@ -3565,6 +3622,10 @@ impl Tty7App { self.update_config(cx, |cfg| cfg.dim_inactive_panes = on); } + pub(crate) fn set_auto_hide_titlebar_buttons(&mut self, on: bool, cx: &mut Context) { + self.update_config(cx, |cfg| cfg.auto_hide_titlebar_buttons = on); + } + pub(crate) fn set_cursor_blink(&mut self, on: bool, cx: &mut Context) { self.update_config(cx, |cfg| cfg.cursor_blink = on); if !on { @@ -6677,6 +6738,9 @@ impl Tty7App { L10nKey::SettingsDimInactivePanes => { self.set_dim_inactive_panes(defaults.dim_inactive_panes, cx) } + L10nKey::SettingsAutoHideTitlebarButtons => { + self.set_auto_hide_titlebar_buttons(defaults.auto_hide_titlebar_buttons, cx) + } L10nKey::SettingsCursorBlink => self.set_cursor_blink(defaults.cursor_blink, cx), L10nKey::SettingsCursorShape => self.set_cursor_style(defaults.cursor_style, cx), L10nKey::SettingsPromptCursorShape => { @@ -9001,6 +9065,19 @@ impl Render for Tty7App { // two spellings of "is the rail up" is one more than the layout can // afford to have disagree. let rail = self.sidebar_open(cx); + // A hover flag is only written by its sheet, and only on a change of + // state it saw. A sheet that is not in the tree sees none: the rail + // hidden by its own tile with the pointer on it, or the switch turned + // off mid-hover, would leave its flag set, and the tiles would come + // back painted, with nobody pointing at them, the next time the sheet + // does. Clear the flag of every sheet not built this frame. + let auto_hide_chrome = cx.global::().auto_hide_titlebar_buttons; + if !auto_hide_chrome || !rail { + self.sidebar_chrome_hover.set(false); + } + if !auto_hide_chrome { + self.strip_chrome_hover.set(false); + } // Both read before the strip and the sidebar are built: a tab held out // over the layout suspends the reorder, which is what those two ask // what to draw, and a pane held over *them* is measured against where @@ -11102,6 +11179,16 @@ mod tests { }; use gpui::{Edges, point, px, size}; + #[test] + fn title_bar_buttons_rest_out_of_sight_only_when_asked_to_and_nobody_points() { + // Off: painted whether or not the pointer is anywhere near. + assert!(super::titlebar_chrome_shown(false, false)); + assert!(super::titlebar_chrome_shown(false, true)); + // On: painted exactly while the pointer is over the bar. + assert!(!super::titlebar_chrome_shown(true, false)); + assert!(super::titlebar_chrome_shown(true, true)); + } + #[test] fn a_rename_box_left_alone_is_not_a_rename() { // The box opens holding the label already on screen, and `Blur` diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 83f0d27b..d6f0dda8 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -334,6 +334,10 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsDimInactivePanesDesc => { "Fade unfocused panes so the active one stands out." } + L10nKey::SettingsAutoHideTitlebarButtons => "Show title bar buttons on hover", + L10nKey::SettingsAutoHideTitlebarButtonsDesc => { + "Keep the new tab and sidebar buttons out of sight until the pointer is over the title bar." + } L10nKey::SettingsOpenThemesFolder => "Open themes folder", L10nKey::SettingsChangeThemeImage => "Change…", L10nKey::SettingsChooseThemeImage => "Choose…", @@ -951,6 +955,9 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsSearchDimInactivePanesKeywords => { "fade unfocused inactive split pane focus opacity highlight active dimming" } + L10nKey::SettingsSearchAutoHideTitlebarButtonsKeywords => { + "auto hide autohide title bar titlebar buttons chrome hover pointer minimal clean new tab sidebar toggle" + } L10nKey::SettingsSearchFocusFollowsMouseKeywords => "pane hover activate", L10nKey::SettingsSearchFontFamilyKeywords => "typeface monospace typography", L10nKey::SettingsSearchFontLigaturesKeywords => "typography glyph fira", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 939d0a86..4263baa7 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -353,6 +353,10 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::FollowTheme => "テーマに従う", L10nKey::SettingsDimInactivePanes => "非アクティブなペインを暗くする", L10nKey::SettingsDimInactivePanesDesc => "フォーカス外のペインを暗くします", + L10nKey::SettingsAutoHideTitlebarButtons => "タイトルバーのボタンをホバー時に表示", + L10nKey::SettingsAutoHideTitlebarButtonsDesc => { + "新規タブやサイドバーのボタンを、ポインタがタイトルバーに乗ったときだけ表示します" + } L10nKey::SettingsOpenThemesFolder => "テーマフォルダを開く", L10nKey::SettingsChangeThemeImage => "変更…", L10nKey::SettingsChooseThemeImage => "選択…", @@ -972,6 +976,9 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsSearchDimInactivePanesKeywords => { "非アクティブ ペイン 暗く フォーカス 分割 fade unfocused inactive split pane focus opacity highlight active dimming" } + L10nKey::SettingsSearchAutoHideTitlebarButtonsKeywords => { + "自動 隠す タイトルバー ボタン ホバー ポインタ すっきり 新規タブ サイドバー auto hide title bar buttons hover" + } L10nKey::SettingsSearchFocusFollowsMouseKeywords => { "ペイン ホバー アクティブ focus follows mouse pane hover activate" } diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index fd37c7f9..a83b3755 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -350,6 +350,8 @@ l10n_keys! { FollowTheme, SettingsDimInactivePanes, SettingsDimInactivePanesDesc, + SettingsAutoHideTitlebarButtons, + SettingsAutoHideTitlebarButtonsDesc, SettingsOpenThemesFolder, SettingsChangeThemeImage, SettingsChooseThemeImage, @@ -778,6 +780,7 @@ l10n_keys! { SettingsSearchCustomThemesKeywords, SettingsSearchDetectUrlsKeywords, SettingsSearchDimInactivePanesKeywords, + SettingsSearchAutoHideTitlebarButtonsKeywords, SettingsSearchDroidKeywords, SettingsSearchFocusFollowsMouseKeywords, SettingsSearchFontFamilyKeywords, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index d6ba5b3e..3d54f90b 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -307,6 +307,10 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::FollowTheme => "跟随主题", L10nKey::SettingsDimInactivePanes => "调暗非活动窗格", L10nKey::SettingsDimInactivePanesDesc => "在分屏中淡化未聚焦的窗格,让活动窗格更突出。", + L10nKey::SettingsAutoHideTitlebarButtons => "悬停时显示标题栏按钮", + L10nKey::SettingsAutoHideTitlebarButtonsDesc => { + "新标签页、侧栏开关等按钮平时隐藏,鼠标移到标题栏上才出现。" + } L10nKey::SettingsOpenThemesFolder => "打开主题文件夹", L10nKey::SettingsChangeThemeImage => "更改…", L10nKey::SettingsChooseThemeImage => "选择…", @@ -846,6 +850,9 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsSearchDimInactivePanesKeywords => { "调暗 非活动窗格 淡化 未聚焦 分屏 高亮 active dimming pane focus" } + L10nKey::SettingsSearchAutoHideTitlebarButtonsKeywords => { + "自动隐藏 标题栏 按钮 悬停 鼠标 简洁 新标签页 侧栏 auto hide title bar buttons hover" + } L10nKey::SettingsSearchFocusFollowsMouseKeywords => { "焦点跟随鼠标 悬停 激活 窗格 focus follows mouse hover activate pane" } diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 7d886e06..3d4a9617 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -361,6 +361,11 @@ fn settings_search_entries() -> &'static [SearchEntry] { title: SettingsDimInactivePanes, keywords: SettingsSearchDimInactivePanesKeywords, }, + SearchEntry { + section: Appearance, + title: SettingsAutoHideTitlebarButtons, + keywords: SettingsSearchAutoHideTitlebarButtonsKeywords, + }, SearchEntry { section: Appearance, title: SettingsFontSize, @@ -792,6 +797,7 @@ impl SearchEntry { fn config_key(&self) -> &'static str { match self.title { L10nKey::SettingsDimInactivePanes => "dim_inactive_panes", + L10nKey::SettingsAutoHideTitlebarButtons => "auto_hide_titlebar_buttons", L10nKey::SettingsCursorBlink => "cursor_blink", L10nKey::SettingsCursorShape => "cursor_style", L10nKey::SettingsPromptCursorShape => "prompt_cursor_style", @@ -875,6 +881,9 @@ impl SearchEntry { L10nKey::SettingsBlur => t(L10nKey::SettingsBlurDesc), L10nKey::SettingsBackdrop => t(L10nKey::SettingsBackdropDesc), L10nKey::SettingsDimInactivePanes => t(L10nKey::SettingsDimInactivePanesDesc), + L10nKey::SettingsAutoHideTitlebarButtons => { + t(L10nKey::SettingsAutoHideTitlebarButtonsDesc) + } L10nKey::SettingsFontSize => t(L10nKey::SettingsFontSizeDesc), L10nKey::SettingsUiFontFamily => t(L10nKey::SettingsUiFontFamilyDesc), L10nKey::SettingsLineHeight => t(L10nKey::SettingsLineHeightDesc), @@ -951,6 +960,9 @@ impl SearchEntry { L10nKey::SettingsDimInactivePanes => { cfg.dim_inactive_panes != defaults.dim_inactive_panes } + L10nKey::SettingsAutoHideTitlebarButtons => { + cfg.auto_hide_titlebar_buttons != defaults.auto_hide_titlebar_buttons + } L10nKey::SettingsCursorBlink => cfg.cursor_blink != defaults.cursor_blink, L10nKey::SettingsCursorShape => cfg.cursor_style != defaults.cursor_style, L10nKey::SettingsPromptCursorShape => { @@ -2922,6 +2934,40 @@ mod tests { assert_eq!(changed, vec![L10nKey::SettingsNotifyThreshold]); } + /// The title-bar switch is found under Appearance by its config key and by + /// its own name in every language, and flipping it marks that row — and + /// only that row — as changed, so its reset link resets the right thing. + #[test] + fn the_titlebar_buttons_switch_is_found_by_key_and_name_and_marks_only_itself() { + let entry = settings_search_entries() + .iter() + .find(|e| e.title == L10nKey::SettingsAutoHideTitlebarButtons) + .unwrap(); + assert!(entry.section == SettingsSection::Appearance); + for locale in ["en", "zh-CN", "ja-JP"] { + crate::ui::i18n::set_locale(locale); + let name = t(L10nKey::SettingsAutoHideTitlebarButtons); + for query in ["auto_hide_titlebar_buttons", name] { + assert!(entry_matches(entry, query), "{locale}: {query}"); + assert_eq!( + best_matching_section(query).unwrap().profile_label(), + SettingsSection::Appearance.profile_label(), + "{locale}: {query}" + ); + } + } + crate::ui::i18n::set_locale("en"); + + let mut cfg = Config::default(); + cfg.auto_hide_titlebar_buttons = true; + let changed = settings_search_entries() + .iter() + .filter(|e| e.modified(&cfg)) + .map(|e| e.title) + .collect::>(); + assert_eq!(changed, vec![L10nKey::SettingsAutoHideTitlebarButtons]); + } + /// A shortcut is the first thing someone searching a settings window for a /// feature by name is after, and the Keybindings page was the one page the /// search could not see into: searching "split" found the settings that @@ -3360,6 +3406,7 @@ mod tests { "Tab completion", "Command history search", "Dim inactive panes", + "Show title bar buttons on hover", "Option (⌥) acts as Meta", "Install the tty7 command on PATH", ] { diff --git a/src/ui/settings/pages.rs b/src/ui/settings/pages.rs index daea548d..83dd6a5b 100644 --- a/src/ui/settings/pages.rs +++ b/src/ui/settings/pages.rs @@ -556,6 +556,7 @@ impl Tty7App { let config = cx.global::(); let overridden = window_overrides_active(config, cfg!(target_os = "windows")); let dim_inactive_panes = config.dim_inactive_panes; + let auto_hide_titlebar_buttons = config.auto_hide_titlebar_buttons; let opacity = Tty7App::effective_window_opacity(cx); const MIN: f32 = 0.2; @@ -638,6 +639,12 @@ impl Tty7App { cx, |this, on, _, cx| this.set_dim_inactive_panes(on, cx), ); + let auto_hide_chrome = self.settings_switch( + "auto-hide-titlebar-buttons", + auto_hide_titlebar_buttons, + cx, + |this, on, _, cx| this.set_auto_hide_titlebar_buttons(on, cx), + ); let follow_theme = overridden.then(|| { h_flex() .pt(px(4.)) @@ -690,6 +697,15 @@ impl Tty7App { ) .into_any_element(), ); + rows.push( + self.settings_row( + t(L10nKey::SettingsAutoHideTitlebarButtons), + t(L10nKey::SettingsAutoHideTitlebarButtonsDesc), + auto_hide_chrome, + cx, + ) + .into_any_element(), + ); self.settings_group(Some(t(L10nKey::SettingsTransparency)), None, rows, cx) } diff --git a/src/ui/tab_sidebar.rs b/src/ui/tab_sidebar.rs index 1088f2b8..a809233a 100644 --- a/src/ui/tab_sidebar.rs +++ b/src/ui/tab_sidebar.rs @@ -1696,7 +1696,14 @@ impl Tty7App { ); } - // Keep navigation discoverable without requiring a hover over the rail. + // On screen at rest unless the Appearance switch says otherwise; then + // these two tiles wait for the pointer to reach the rail. The mark + // stays either way — it names the window rather than doing anything. + let auto_hide_chrome = cx.global::().auto_hide_titlebar_buttons; + let chrome_shown = crate::ui::app::titlebar_chrome_shown( + auto_hide_chrome, + self.sidebar_chrome_hover.get(), + ); let controls = h_flex() .flex_shrink_0() .h(px(TITLE_BAR_HEIGHT)) @@ -1716,9 +1723,7 @@ impl Tty7App { .child(div().flex_1().min_w(px(GRAB_HANDLE_W))) }) .child( - div() - .occlude() - .flex_shrink_0() + crate::ui::app::resting_chrome(div().occlude().flex_shrink_0(), chrome_shown) .child(self.new_tab_button_sized( "sidebar-add", crate::ui::tab_strip::RAIL_TILE, @@ -1726,24 +1731,25 @@ impl Tty7App { )), ) .child( - div().occlude().flex_shrink_0().child( - crate::ui::tab_strip::chrome_tile_sized( - Button::new("sidebar-collapse") - .icon(Icon::empty().path("icons/panel-left.svg")), - crate::ui::tab_strip::RAIL_TILE, - crate::ui::tab_strip::RAIL_TILE_GLYPH, - false, - cx, - ) - .rounded(px(crate::ui::tab_strip::RAIL_TILE_RADIUS)) - .accessible_label(t(L10nKey::TabTooltipHideSidebar)) - .tooltip_element(crate::ui::tab_strip::chord_tooltip( - t(L10nKey::TabTooltipHideSidebar), - "ToggleLeftPanel", - cx, - )) - .on_click(cx.listener(|this, _, _window, cx| this.toggle_left_panel(cx))), - ), + crate::ui::app::resting_chrome(div().occlude().flex_shrink_0(), chrome_shown) + .child( + crate::ui::tab_strip::chrome_tile_sized( + Button::new("sidebar-collapse") + .icon(Icon::empty().path("icons/panel-left.svg")), + crate::ui::tab_strip::RAIL_TILE, + crate::ui::tab_strip::RAIL_TILE_GLYPH, + false, + cx, + ) + .rounded(px(crate::ui::tab_strip::RAIL_TILE_RADIUS)) + .accessible_label(t(L10nKey::TabTooltipHideSidebar)) + .tooltip_element(crate::ui::tab_strip::chord_tooltip( + t(L10nKey::TabTooltipHideSidebar), + "ToggleLeftPanel", + cx, + )) + .on_click(cx.listener(|this, _, _window, cx| this.toggle_left_panel(cx))), + ), ); // The tile inside asks for `w_full`, and a percentage is only a width // while some box above it has a real one. This row used to have none of @@ -1912,6 +1918,12 @@ impl Tty7App { )), ) .child(handle) + .when(auto_hide_chrome, |rail| { + rail.child(crate::ui::app::hover_sheet( + "sidebar-chrome-hover", + &self.sidebar_chrome_hover, + )) + }) } /// The boundary between the kept groups and the derived ones, recording diff --git a/src/ui/tab_strip.rs b/src/ui/tab_strip.rs index 4ffaaba8..a86f696e 100644 --- a/src/ui/tab_strip.rs +++ b/src/ui/tab_strip.rs @@ -764,6 +764,91 @@ const MENU_AGENTS: usize = 3; const TITLEBAR_SEARCH_W: f32 = 360.; const TITLEBAR_SEARCH_H: f32 = 28.; +/// Air between the search box and the nearest chrome tile when the bar is too +/// narrow for the box to sit at its full width. +const TITLEBAR_SEARCH_CLEAR_GAP: f32 = 8.; + +/// Where the title-bar search box is centred, as offsets from the tab strip it +/// is drawn inside. +/// +/// The strip does not start at the terminal column's left edge. The title bar +/// leaves a lead before it — 80pt on macOS for the traffic lights, whether or +/// not the rail is standing in front of them, plus the bar's own inset in +/// fullscreen; 12 elsewhere — and a box centred on the strip was centred that +/// much too far right by half: 40pt on macOS, which is plainly visible with +/// the rail collapsed and the box standing alone in an empty bar (#1033). +/// So the band reaches back over the lead, and its left edge is the column's. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct SearchBand { + /// `left` for the band, against the strip's left edge: the lead, negated. + pub(crate) left: f32, + /// `right` for the band, against the strip's right edge. + pub(crate) right: f32, + /// How much of *each* end of the band the box keeps clear of, so that in + /// a narrow bar it shrinks rather than slides under the chrome tiles. The + /// same at both ends, because a box clear of only one is no longer + /// centred. + pub(crate) clear: f32, +} + +/// [`SearchBand`] for a strip that starts `lead` in from the terminal column's +/// left edge. +/// +/// `docked_right` is what is docked to the right of the terminal — the detail +/// panel and a document column. Off macOS the bar spans those too, so the band +/// stops at the terminal column: the strip already stops short of the window +/// controls, so they come off the columns' share. On macOS the bar is inside +/// the terminal column already and the strip runs to its end. +/// +/// `leading_w` is the collapsed rail's group at the strip's left end, measured +/// from the strip's left edge; `trailing_w` the chrome tiles at the band's +/// right end, when they are there rather than off over a docked column. +pub(crate) fn search_band( + macos: bool, + lead: f32, + docked_right: f32, + controls_w: f32, + leading_w: Option, + trailing_w: Option, +) -> SearchBand { + let right = match !macos && docked_right > 0. { + true => (docked_right - controls_w).max(0.), + false => 0., + }; + let leading = leading_w.map_or(0., |w| lead + w + TITLEBAR_SEARCH_CLEAR_GAP); + let trailing = trailing_w.map_or(0., |w| w + TITLEBAR_SEARCH_CLEAR_GAP); + SearchBand { + left: -lead, + right, + clear: leading.max(trailing), + } +} + +/// How far the title bar's lead reaches before the tab strip starts: the room +/// `TitleBar` leaves for the traffic lights on macOS, and the bar's own inset +/// that gpui-component adds to it in fullscreen, where the lights are gone but +/// the room is not; `TITLE_BAR_LEAD` elsewhere, in both of the rows the strip +/// can sit in. +fn title_bar_strip_lead(fullscreen: bool, rem: f32) -> f32 { + match cfg!(target_os = "macos") && fullscreen { + // `pl_3` on the bar inside `TitleBar`. + true => crate::ui::app::TITLE_BAR_LEAD + 0.75 * rem, + false => crate::ui::app::TITLE_BAR_LEAD, + } +} + +/// How wide the collapsed rail's group at the start of the strip is: the +/// window mark where there is one, then New Tab and Show Sidebar. Mirrors the +/// `left_group` built in `tab_strip`, `ml` included. +fn collapsed_rail_group_w() -> f32 { + use crate::ui::app::{CONTENT_INSET, TILE_SIZE, WINDOW_MARK_SIZE, tile_trailing_inset}; + let mark = match cfg!(target_os = "macos") { + true => 0., + false => (CONTENT_INSET - tile_trailing_inset()) + WINDOW_MARK_SIZE + 4. + 2., + }; + crate::ui::app::title_bar_hug_offset() + mark + TILE_SIZE + 2. + TILE_SIZE +} + /// How wide the New Tab menu is allowed to get. const MENU_W: Pixels = px(360.); @@ -2524,6 +2609,13 @@ impl Tty7App { .flex_shrink_0() .child(self.new_tab_button("tab-add", cx)); + // Same bargain the rail's own tiles keep when the Appearance switch is + // on: present in the layout, painted only while the pointer is on the + // bar. The New Tab button that trails the chips is left alone — it is + // part of the row of tabs, not of the window's chrome. + let auto_hide_chrome = cx.global::().auto_hide_titlebar_buttons; + let strip_chrome_shown = + crate::ui::app::titlebar_chrome_shown(auto_hide_chrome, self.strip_chrome_hover.get()); let rail_collapsed = !show_chips && !self.left_panel_open(cx); let left_group = rail_collapsed.then(|| { h_flex() @@ -2545,13 +2637,18 @@ impl Tty7App { // control, and a window that loses its identity when nobody is // pointing at it reads as a different window. .child( - div() - .occlude() - .flex_shrink_0() - .child(self.new_tab_button("titlebar-add-collapsed", cx)), + crate::ui::app::resting_chrome( + div().occlude().flex_shrink_0(), + strip_chrome_shown, + ) + .child(self.new_tab_button("titlebar-add-collapsed", cx)), ) .child( - div().occlude().flex_shrink_0().child( + crate::ui::app::resting_chrome( + div().occlude().flex_shrink_0(), + strip_chrome_shown, + ) + .child( chrome_tile( Button::new("titlebar-expand-sidebar") .icon(Icon::empty().path("icons/panel-left.svg")), @@ -2572,7 +2669,16 @@ impl Tty7App { // macOS places these controls in the open panel's own title bar, and // drops them while a docked document holds the right edge. - let right_chrome = strip_chrome.then(|| self.window_chrome(cx)); + // + // Elsewhere, with the panel open, they stand in the band above it, + // beside the panel's own tab row — which is painted whenever the panel + // is, so a band that grew a button on hover read as a glitch next to + // it. Once the panel is open they belong to its chrome, and stay. + let right_chrome_shown = strip_chrome_shown || self.right_panel_open(cx); + let right_chrome = strip_chrome.then(|| { + crate::ui::app::resting_chrome(div().flex_shrink_0(), right_chrome_shown) + .child(self.window_chrome(cx)) + }); // With the tabs in the rail, the middle of the bar over the terminal // is the way into Search Everywhere: a field-shaped button, centred, @@ -2580,44 +2686,46 @@ impl Tty7App { // names every tab, so the bar has no title of its own to repeat. The // box alone takes the pointer; the rest of the bar still drags. // - // Centred over the terminal column, not the bar. Off macOS the bar - // spans the workspace while a document or the detail panel is docked, - // and centred on all of it the box landed under the document's - // hoisted header, which has no fill to hide it. The strip already - // stops short of the window controls, so they come off the columns' - // share. - let search_right = match cfg!(target_os = "macos") { - true => 0., - false => { - let panel = match self.right_panel_open(cx) { - true => self.right_panel_px(window, cx), - false => 0., - }; - match panel + document_w > 0. { - true => (panel + document_w - controls_w).max(0.), - false => 0., - } - } - }; + // Centred over the terminal column, not the bar: see `search_band`. + // Off macOS the bar spans the workspace while a document or the detail + // panel is docked, and centred on all of it the box landed under the + // document's hoisted header, which has no fill to hide it. + let docked_right = document_w + + match self.right_panel_open(cx) { + true => self.right_panel_px(window, cx), + false => 0., + }; + let band = search_band( + cfg!(target_os = "macos"), + title_bar_strip_lead(window.is_fullscreen(), window.rem_size().as_f32()), + docked_right, + controls_w, + rail_collapsed.then(collapsed_rail_group_w), + (strip_chrome && docked_right <= 0.).then(trailing_chrome_tiles_w), + ); let centre_search = (!show_chips).then(|| { // The chord as text, not caps: a cap's fill is this box's own // grey, so on it a cap is only a gap between two letters. let chord = crate::ui::keymap::effective_key("TogglePalette", cx) .map(|spec| crate::ui::keymap::key_tokens(&spec).join("")); + // Two equal springs either side keep the box centred; each stops + // at the band's `clear`, and past that it is the box that gives. + let spring = || div().flex_1().min_w(px(band.clear)); div() .absolute() .top_0() .bottom_0() - .left_0() - .right(px(search_right)) + .left(px(band.left)) + .right(px(band.right)) .flex() .items_center() - .justify_center() + .child(spring()) .child( h_flex() .id("titlebar-search") .occlude() .w(px(TITLEBAR_SEARCH_W)) + .min_w_0() // Enough of the bar that the label and its chord still // fit with a document docked beside the terminal; at a // half it read `Search Everywhe…` in exactly the @@ -2658,6 +2766,7 @@ impl Tty7App { }), ), ) + .child(spring()) }); h_flex() @@ -2685,6 +2794,12 @@ impl Tty7App { ), None => this.child(chrome), }) + .when(auto_hide_chrome, |this| { + this.child(crate::ui::app::hover_sheet( + "strip-chrome-hover", + &self.strip_chrome_hover, + )) + }) } } @@ -2980,6 +3095,75 @@ mod tests { use std::path::Path; use unicode_segmentation::UnicodeSegmentation; + /// Where a band lands in the terminal column, for a strip that starts + /// `lead` in and ends `strip_end` from the column's left edge. + fn band_in_column(band: SearchBand, lead: f32, strip_end: f32) -> (f32, f32) { + (lead + band.left, strip_end - band.right) + } + + /// #1033, with the reporter's window: 1946pt wide, the rail collapsed. + /// The strip starts 80pt in, after the traffic lights, and centred on it + /// the box sat at 1013 — 40pt right of the window's middle at 973. + #[test] + fn with_the_rail_collapsed_on_macos_the_search_box_centres_on_the_window() { + let (w, lead) = (1946., 80.); + let band = search_band(true, lead, 0., 0., Some(58.), Some(49.)); + let (l, r) = band_in_column(band, lead, w); + assert_eq!((l, r), (0., w)); + assert_eq!((l + r) / 2., 973.); + // Clear of the traffic lights and the two tiles after them, at both + // ends so that clearing them does not pull the box off centre. + assert_eq!(band.clear, lead + 58. + TITLEBAR_SEARCH_CLEAR_GAP); + } + + /// With the rail open, the title bar sits in the terminal column but + /// still leaves the traffic lights' 80pt before the strip, over nothing + /// — the lights are over the rail. The band reaches back over that too, + /// and centres on the column. + #[test] + fn with_the_rail_open_on_macos_the_search_box_centres_on_the_terminal_column() { + let (column, lead) = (1200., 80.); + let band = search_band(true, lead, 0., 0., None, Some(49.)); + assert_eq!(band_in_column(band, lead, column), (0., column)); + assert_eq!(band.clear, 49. + TITLEBAR_SEARCH_CLEAR_GAP); + + // A docked panel on macOS is beside the column, not under its bar: + // nothing to subtract from the strip's end. + let docked = search_band(true, lead, 360., 0., None, None); + assert_eq!(band_in_column(docked, lead, column), (0., column)); + assert_eq!(docked.clear, 0.); + } + + /// Fullscreen keeps `TitleBar`'s 80pt and adds the bar's own inset. + #[test] + fn fullscreen_on_macos_reaches_back_over_the_bars_extra_inset_as_well() { + let lead = 80. + 12.; + let band = search_band(true, lead, 0., 0., Some(58.), Some(49.)); + assert_eq!(band_in_column(band, lead, 1440.), (0., 1440.)); + } + + /// Off macOS the bar spans the docked columns and stops short of the + /// window buttons; the band ends where the terminal column does, as it + /// always has, and now starts where it does too. + #[test] + fn off_macos_the_band_is_the_terminal_column_beside_a_docked_panel() { + let (w, lead, controls, panel) = (1600., 12., 102., 360.); + let strip_end = w - controls; + let band = search_band(false, lead, panel, controls, None, None); + assert_eq!(band.right, panel - controls); + assert_eq!(band_in_column(band, lead, strip_end), (0., w - panel)); + + // Nothing docked: the band runs to the end of the strip, short of + // the window buttons, and keeps clear of the tiles standing there. + let bare = search_band(false, lead, 0., controls, None, Some(38.)); + assert_eq!(band_in_column(bare, lead, strip_end), (0., strip_end)); + assert_eq!(bare.clear, 38. + TITLEBAR_SEARCH_CLEAR_GAP); + + // A panel narrower than the buttons leaves no room to take off. + let thin = search_band(false, lead, 80., controls, None, None); + assert_eq!(thin.right, 0.); + } + /// Most of these tests are about where a title is *cut*, not about what /// `~` means: the paths they pass either already start with `~` or are /// nowhere near anybody's home. Naming no home keeps the assertions off