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