diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 542abf69..a21ba7bb 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -2167,6 +2167,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::AppMenuWindow => "Window", L10nKey::AppMenuHelp => "Help", L10nKey::AppMenuNewTab => "New Tab", + L10nKey::NewTabMoreOptions => "More New Tab Options", L10nKey::AppMenuNewWorkspace => "New Workspace…", L10nKey::AppMenuNewWorktreeTab => "New Worktree Tab…", L10nKey::AppMenuSplitRight => "Split Right", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index c282f87f..cec38b8f 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -2234,6 +2234,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::AppMenuWindow => "ウィンドウ", L10nKey::AppMenuHelp => "ヘルプ", L10nKey::AppMenuNewTab => "新規タブ", + L10nKey::NewTabMoreOptions => "その他の新規タブオプション", L10nKey::AppMenuNewWorkspace => "新規ワークスペース…", L10nKey::AppMenuNewWorktreeTab => "新規ワークツリータブ…", L10nKey::AppMenuSplitRight => "右に分割", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index e14dc8a9..aacf4ee2 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -1264,6 +1264,7 @@ l10n_keys! { AppMenuWindow, AppMenuHelp, AppMenuNewTab, + NewTabMoreOptions, AppMenuNewWorkspace, AppMenuNewWorktreeTab, AppMenuSplitRight, diff --git a/src/ui/i18n/ru.rs b/src/ui/i18n/ru.rs index dad9171d..858c8af7 100644 --- a/src/ui/i18n/ru.rs +++ b/src/ui/i18n/ru.rs @@ -1714,6 +1714,7 @@ pub fn translate_ru(key: L10nKey) -> Option<&'static str> { L10nKey::AppMenuWindow => "Окно", L10nKey::AppMenuHelp => "Справка", L10nKey::AppMenuNewTab => "Новая вкладка", + L10nKey::NewTabMoreOptions => "Дополнительные параметры новой вкладки", L10nKey::AppMenuNewWorkspace => "Новая рабочая область…", L10nKey::AppMenuNewWorktreeTab => "Новая вкладка worktree…", L10nKey::AppMenuSplitRight => "Разделить вправо", diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 78051ec3..90788c6e 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -2016,6 +2016,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::AppMenuWindow => "窗口", L10nKey::AppMenuHelp => "帮助", L10nKey::AppMenuNewTab => "新标签页", + L10nKey::NewTabMoreOptions => "更多新标签页选项", L10nKey::AppMenuNewWorkspace => "新工作区…", L10nKey::AppMenuNewWorktreeTab => "新 worktree 标签页…", L10nKey::AppMenuSplitRight => "向右分屏", diff --git a/src/ui/tab_strip.rs b/src/ui/tab_strip.rs index 92e5c237..9434fd1d 100644 --- a/src/ui/tab_strip.rs +++ b/src/ui/tab_strip.rs @@ -1,16 +1,14 @@ use gpui::{ - Animation, AnimationExt as _, AnyElement, App, Axis, Bounds, Context, Edges, FontWeight, - MouseButton, Pixels, SharedString, Window, canvas, deferred, div, ease_out_quint, - linear_color_stop, linear_gradient, prelude::*, px, + Animation, AnimationExt as _, AnyElement, App, Axis, Bounds, Context, Div, Edges, FontWeight, + Interactivity, MouseButton, Pixels, SharedString, Stateful, StyleRefinement, Window, canvas, + deferred, div, ease_out_quint, linear_color_stop, linear_gradient, prelude::*, px, }; use gpui_component::button::{Button, ButtonCustomVariant, ButtonVariants as _}; use gpui_component::input::Input; use gpui_component::kbd::Kbd; -use gpui_component::menu::{ContextMenuExt as _, DropdownMenu as _, PopupMenu, PopupMenuItem}; +use gpui_component::menu::{ContextMenuExt as _, DropdownMenu, PopupMenu, PopupMenuItem}; use gpui_component::tooltip::Tooltip; -use gpui_component::{ - ActiveTheme as _, Icon, IconName, Selectable as _, Side, Sizable as _, h_flex, -}; +use gpui_component::{ActiveTheme as _, Icon, IconName, Selectable, Side, Sizable as _, h_flex}; use unicode_segmentation::UnicodeSegmentation as _; use crate::core::actions::{ @@ -27,6 +25,7 @@ use crate::ui::app::{SpawnWhere, TILE_GLYPH, TILE_SIZE, Tab, Tty7App, tile_trail use crate::ui::hints::tab_badge_label; use crate::ui::i18n::{L10nKey, t, t_fmt}; use crate::ui::reorder::{self, Reorder, Surface}; +use crate::ui::rounding::{RoundedCorners as _, segment_corners}; use crate::ui::search::SearchTab; /// One duration and one curve for every transition the app runs, so a fade and @@ -864,7 +863,7 @@ fn collapsed_rail_group_w() -> f32 { 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 + crate::ui::app::title_bar_hug_offset() + mark + NEW_TAB_W + 2. + TILE_SIZE } /// How wide the New Tab menu is allowed to get. @@ -1874,19 +1873,22 @@ impl Tty7App { (!raw.trim().is_empty()).then_some(raw) } - /// The New Tab control: one `+` that drops the list of everything it could - /// open — the installed shells, and the saved SSH hosts. + /// The New Tab control, in two halves: `+` opens a tab with the default + /// shell outright, and the chevron beside it drops the list of everything + /// else it could open — the installed shells, the agents, the saved SSH + /// hosts. /// - /// It was briefly split in two, a `+` that opened a tab outright next to a - /// caret for the list, the way Windows Terminal and VS Code split theirs. - /// That buys back the click the menu costs on the most common action, and - /// it costs a second mark in a row of single ones. This row is four icons - /// wide and reads as four icons; a fifth that was half of the fourth had - /// to earn its place by looking like a pair, and a pair is exactly what a - /// chrome row of plain icons has no vocabulary for. + /// It used to be one `+` that only ever dropped the list, with ⌘T standing + /// in for the click that cost. But a plain tab is what the button is + /// pressed for most, and routing every press through a menu charged that + /// click to everyone who reaches for the pointer rather than the chord. So + /// the pair is drawn as one tile split down the middle — each half hovers + /// on its own, square where they meet, rounded only at the outer edges — + /// and still reads as one control in a row of single ones. /// - /// So: one tile, drawn and hovered like every other tile beside it, and - /// the click it costs is answered by ⌘T rather than by a second button. + /// Every New Tab in the chrome comes through here — the strip, the + /// collapsed rail, the sidebar header — so `+` does the same thing + /// wherever it is, and the rail keeps the same control when it opens. pub(crate) fn new_tab_button( &self, id: &'static str, @@ -1904,37 +1906,135 @@ impl Tty7App { cx: &Context, ) -> impl IntoElement + use<> { let app = cx.entity().downgrade(); - let glyph = match tile < crate::ui::app::TILE_SIZE { + NewTabSplit::new(id, tile, app.clone()) + // Built when the menu opens, not when the strip draws: this + // closure runs once per press, and again after each dismissal. + .dropdown_menu(move |menu, window, cx| { + let Some(this) = app.upgrade() else { + return menu; + }; + this.read(cx) + .new_tab_menu_rows(app.clone(), cx) + .build(menu, window) + }) + } +} + +/// How wide [`Tty7App::new_tab_button`] is: the `+` tile and the chevron. +/// Anything that budgets the title bar row around it counts it at this. +pub(crate) const NEW_TAB_W: f32 = crate::ui::app::TILE_SIZE + MIN_TARGET; + +/// The two halves of [`Tty7App::new_tab_button`], as one trigger for the menu. +/// +/// The menu wants a trigger it can mark selected while it is open, and that +/// has to be the pair rather than the chevron alone: anchored to the chevron +/// the list would open a tile's width to the right of the `+` it belongs to. +/// The popover toggles on a press anywhere in the pair, so the `+` stops its +/// own press from reaching it. +#[derive(IntoElement)] +struct NewTabSplit { + id: &'static str, + /// The pair's own box. The trigger traits hand out its interactivity, so + /// it is what renders — not a fresh `div` that would drop whatever was + /// hung on it. + base: Stateful
, + style: StyleRefinement, + selected: bool, + tile: f32, + app: gpui::WeakEntity, +} + +impl NewTabSplit { + fn new(id: &'static str, tile: f32, app: gpui::WeakEntity) -> Self { + Self { + id, + base: div().id(SharedString::from(format!("{id}-split"))), + style: StyleRefinement::default(), + selected: false, + tile, + app, + } + } +} + +impl Selectable for NewTabSplit { + fn selected(mut self, selected: bool) -> Self { + self.selected = selected; + self + } + + fn is_selected(&self) -> bool { + self.selected + } +} + +impl Styled for NewTabSplit { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl InteractiveElement for NewTabSplit { + fn interactivity(&mut self) -> &mut Interactivity { + self.base.interactivity() + } +} + +impl DropdownMenu for NewTabSplit {} + +impl RenderOnce for NewTabSplit { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let glyph = match self.tile < crate::ui::app::TILE_SIZE { true => RAIL_TILE_GLYPH, false => TILE_GLYPH, }; - chrome_tile_sized( - Button::new(id).icon(Icon::new(IconName::Plus)), - tile, - glyph, - false, - cx, - ) - .rounded(px(RAIL_TILE_RADIUS)) - // Every other tile in this row names itself on hover — Switch - // Workspace, More, Hide Sidebar. The three New Tab buttons that - // come through here were the ones left silent. The chord is worth - // more here than anywhere else in the row: it is the way back to - // opening a tab without reading a menu first. - .accessible_label(t(L10nKey::AppMenuNewTab)) - .tooltip_element(chord_tooltip(t(L10nKey::AppMenuNewTab), "NewTab", cx)) - // Built when the menu opens, not when the strip draws: this - // closure runs once per press, and again after each dismissal. - .dropdown_menu(move |menu, window, cx| { - let Some(this) = app.upgrade() else { - return menu; - }; - this.read(cx) - .new_tab_menu_rows(app.clone(), cx) - .build(menu, window) - }) + let app = self.app; + let half = |i| segment_corners(i, 2, px(RAIL_TILE_RADIUS), px(0.)); + self.base + .flex() + .flex_none() + .items_center() + .h(px(self.tile)) + .child( + chrome_tile_sized( + Button::new(self.id).icon(Icon::new(IconName::Plus)), + self.tile, + glyph, + self.selected, + cx, + ) + .rounded_corners(half(0)) + // Every other tile in this row names itself on hover. The + // chord is worth more here than anywhere else in the row: it + // is the way to a tab without reaching for the pointer at all. + .accessible_label(t(L10nKey::AppMenuNewTab)) + .when(!self.selected, |b| { + b.tooltip_element(chord_tooltip(t(L10nKey::AppMenuNewTab), "NewTab", cx)) + }) + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .on_click(move |_, window, cx| { + let _ = app.update(cx, |this, cx| this.new_tab(window, cx)); + }), + ) + .child( + chrome_tile_sized( + Button::new(SharedString::from(format!("{}-menu", self.id))) + .icon(Icon::new(IconName::ChevronDown)), + self.tile, + glyph, + self.selected, + cx, + ) + .w(px(MIN_TARGET)) + .rounded_corners(half(1)) + .accessible_label(t(L10nKey::NewTabMoreOptions)) + // Not while the list it names is already open under it. + .when(!self.selected, |b| b.tooltip(t(L10nKey::NewTabMoreOptions))), + ) } +} +impl Tty7App { /// What the menu offers, read off the app as the menu opens — the builder /// runs on the popup's own entity, so the rows carry a weak handle back. fn new_tab_menu_rows(&self, app: gpui::WeakEntity, cx: &App) -> NewTabMenu { @@ -2372,7 +2472,7 @@ impl Tty7App { } else { document_w }; - let fixed_w = 3. * CHIP_GAP + crate::ui::app::TILE_SIZE + corner_w; + let fixed_w = 3. * CHIP_GAP + NEW_TAB_W + corner_w; let chips_avail = (strip_w - px(fixed_w + GRAB_HANDLE_W)).max(px(80.)); let mut chips = h_flex() .items_center()