From 05d431880b998d6670f1b5ff158b009fbc9e914e Mon Sep 17 00:00:00 2001 From: ventupx Date: Wed, 21 Oct 2026 14:50:46 +0800 Subject: [PATCH] feat(tabs): add content-sized top tabs with Chrome, Minimal and Tag styles --- .../2026-10-20-content-sized-top-tabs.md | 52 +++ .../2026-10-20-interactive-tab-titles.md | 48 +++ .../2026-10-20-static-tab-overflow.md | 60 ++++ nebula_app/i18n/en-US.json | 7 + nebula_app/i18n/zh-CN.json | 7 + nebula_app/src/gpui_shell/config.rs | 2 + nebula_app/src/gpui_shell/settings_pane.rs | 1 + .../settings_pane/appearance_advanced.rs | 6 + .../settings_pane/initialization.rs | 22 ++ .../gpui_shell/settings_pane/localization.rs | 5 + .../settings_pane/tab_style_settings_tests.rs | 45 +++ .../settings_pane/theme_studio_tests.rs | 2 + nebula_app/src/gpui_shell/terminal/view.rs | 7 +- .../gpui_shell/terminal/view/pointer/tests.rs | 14 + .../terminal/view/reading/output_hover.rs | 4 +- nebula_app/src/gpui_shell/workspace/render.rs | 10 + .../workspace/settings_navigation/ui_tests.rs | 224 ++++++++++++- .../src/gpui_shell/workspace/sidebar.rs | 1 + .../src/gpui_shell/workspace/tab_drag.rs | 38 ++- .../src/gpui_shell/workspace/top_tabs.rs | 311 ++++++++++-------- .../gpui_shell/workspace/top_tabs/label.rs | 116 +++++++ .../gpui_shell/workspace/top_tabs/layout.rs | 180 ++++++++++ .../gpui_shell/workspace/top_tabs/style.rs | 104 ++++++ .../gpui_shell/workspace/window_titlebar.rs | 12 +- nebula_settings/src/lib.rs | 4 + nebula_settings/src/reset.rs | 9 + nebula_settings/src/tab_style.rs | 47 +++ 27 files changed, 1174 insertions(+), 164 deletions(-) create mode 100644 architecture/notes/nebula_app/gpui_shell/workspace/2026-10-20-content-sized-top-tabs.md create mode 100644 architecture/notes/nebula_app/gpui_shell/workspace/2026-10-20-interactive-tab-titles.md create mode 100644 architecture/notes/nebula_app/gpui_shell/workspace/2026-10-20-static-tab-overflow.md create mode 100644 nebula_app/src/gpui_shell/settings_pane/tab_style_settings_tests.rs create mode 100644 nebula_app/src/gpui_shell/workspace/top_tabs/label.rs create mode 100644 nebula_app/src/gpui_shell/workspace/top_tabs/layout.rs create mode 100644 nebula_app/src/gpui_shell/workspace/top_tabs/style.rs create mode 100644 nebula_settings/src/tab_style.rs diff --git a/architecture/notes/nebula_app/gpui_shell/workspace/2026-10-20-content-sized-top-tabs.md b/architecture/notes/nebula_app/gpui_shell/workspace/2026-10-20-content-sized-top-tabs.md new file mode 100644 index 000000000..8ece0b840 --- /dev/null +++ b/architecture/notes/nebula_app/gpui_shell/workspace/2026-10-20-content-sized-top-tabs.md @@ -0,0 +1,52 @@ +# Content-sized top tabs + +## Status +Implemented. + +Overflow behavior superseded by [interactive tab titles](2026-10-20-interactive-tab-titles.md). + +## Context +Equal-width tabs consumed a full 220px for short names. Three horizontal tab +treatments also needed to share activation, closing and ordering behavior. + +## Evidence +[The top tab renderer](../../../../../nebula_app/src/gpui_shell/workspace/top_tabs.rs) previously divided the viewport by the tab count and +used that same fixed pitch for dragging. Applying variable widths only in CSS +would leave overflow buttons and drag destinations using the wrong positions. +Occluding tab hitboxes also suppress ancestor hover listeners. Top tab rows +therefore receive movement directly; an unhovered-root release handler covers +fast drops before the full-window drag overlay has painted. + +## Decision +Measure names with GPUI's text shaper and reserve the existing identity, status, +administrator and split-count slots. Cap each tab at 220 logical pixels and use +an ellipsis plus the full-title tooltip. `top_tabs/layout.rs` owns the prefix +positions used by layout, arrow navigation and drag ordering. Each horizontal +gesture freezes that layout and captures the starting scroll offset; auto-scroll +is included in pointer displacement. The pinned settings tab stays outside the +reorderable range. Sidebar gestures retain their row pitch. + +`nebula_settings::TabStyle` owns the saved `tab_style` values: `chrome` (default), +`minimal`, and `tag`. Appearance settings persist through the existing settings +boundary; open windows use the cached global snapshot. Styling has no tab state. + +## Rejected alternatives +Intrinsic widths without measured positions would leave ordering and overflow +navigation inconsistent. Text marquees add perpetual movement; ellipses meet the +requested overflow behavior while preserving a stable titlebar. + +## Consequences +Long titles no longer force short titles to stretch. The existing left sidebar +layout and session metadata keep their semantics. + +## Validation +[Core preference round trips/reset](../../../../../nebula_settings/src/tab_style.rs); mixed-width navigation and ordering rules; +rendered tests for all three styles, CJK/long titles, hover close and bidirectional +dragging; [existing settings navigation and density coverage](../../../../../nebula_app/src/gpui_shell/workspace/settings_navigation/ui_tests.rs). + +## Supersedes +None. + +## Revisit when +The maximum tab width becomes user-configurable or window chrome changes its +reserved toolbar geometry. diff --git a/architecture/notes/nebula_app/gpui_shell/workspace/2026-10-20-interactive-tab-titles.md b/architecture/notes/nebula_app/gpui_shell/workspace/2026-10-20-interactive-tab-titles.md new file mode 100644 index 000000000..093ab4d9a --- /dev/null +++ b/architecture/notes/nebula_app/gpui_shell/workspace/2026-10-20-interactive-tab-titles.md @@ -0,0 +1,48 @@ +# Interactive long tab titles + +## Status +Implemented. + +Superseded by [static tab overflow](2026-10-20-static-tab-overflow.md). + +## Context +Long names must be readable while selected or hovered, while inactive tabs retain +an ellipsis. A scrolling title must not move the identity, shell prefix or close button. + +## Evidence +[The top tab renderer](../../../../../nebula_app/src/gpui_shell/workspace/top_tabs.rs) +already measures titles and reserves fixed control slots. GPUI's keyed element +state owns hover only while the element is rendered; its animation wrapper owns +the clock and honors the application reduced-motion preference. + +## Decision +Retain one hover flag per rendered tab using GPUI keyed state. Only overflowing, +visible, selected/hovered titles in the active window receive a repeating +animation. [The label](../../../../../nebula_app/src/gpui_shell/workspace/top_tabs/label.rs) +clips its own text, pauses at either end and scrolls back at 32 logical pixels per +second. Removing the animation restores ellipsis and discards its clock. No +workspace timer or persistent settings are added. The selected terminal's muted +shell prefix stays outside the moving name and replaces the former trailing +shell indicator. The trailing close/status slot retains its size. + +## Rejected alternatives +Animating entire tabs would move controls and hitboxes. Animating every long +name would schedule frames for inactive and offscreen tabs. A second task-based +timer would duplicate GPUI's element lifetime and reduced-motion handling. + +## Consequences +At most the selected and hovered visible long titles move. Short titles and +inactive windows remain static. Selecting a terminal adds its shell prefix to +the measured width, still capped at 220 logical pixels. + +## Validation +[Rendered interaction coverage](../../../../../nebula_app/src/gpui_shell/workspace/settings_navigation/ui_tests.rs) +checks hover over tab padding, selection after pointer exit, idle ellipsis, +short names, fixed tab width and separation from the close button. + +## Supersedes +The ellipsis-only overflow choice in +[content-sized top tabs](2026-10-20-content-sized-top-tabs.md). + +## Revisit when +Text needs a different scrolling speed, or tab controls change their width. diff --git a/architecture/notes/nebula_app/gpui_shell/workspace/2026-10-20-static-tab-overflow.md b/architecture/notes/nebula_app/gpui_shell/workspace/2026-10-20-static-tab-overflow.md new file mode 100644 index 000000000..4e1a826d4 --- /dev/null +++ b/architecture/notes/nebula_app/gpui_shell/workspace/2026-10-20-static-tab-overflow.md @@ -0,0 +1,60 @@ +# Static tab overflow and overlapping Chrome feet + +## Status +Implemented. + +## Context +Long names must stay still when selected or hovered and fade out at the trailing +edge. Chrome hover and selected surfaces should be close despite the 12px flared +corners. Tabs use spacing alone rather than separator lines. + +## Evidence +[The label painter](../../../../../nebula_app/src/gpui_shell/workspace/top_tabs/label.rs) +uses GPUI content masks and opacity on a containing Div. Canvas style opacity is +not applied by GPUI's Canvas painter; Div applies it to both monochrome glyphs +and color emoji. Overpainting a gradient with a theme background would +hide wallpaper or show a rectangle with translucent/custom themes. + +## Decision +Remove title animations and their hover state. A frame-local shared shaped line +paints a solid area and 24 clipped opacity bands over the final 48 logical +pixels. It retains the normal text height and full accessible name. No animation +frames, timers, or persistent caches are requested by labels. + +Chrome tabs overlap by 10px, leaving about 2px between a neighboring hover surface +and the selected tab's inset body. The shared strip geometry stores this gap for +layout, navigation and frozen drag snapshots. Selected tabs paint after inactive +neighbors; a dragged tab paints above both. No separator lines are drawn between +tabs. Other tab styles retain their existing gap. + +The shell indicator uses the trailing status slot; it is visible on +the selected tab and yields to the hover close button. + +A live preview exposed an out-of-bounds read when Runtime API tab creation +changed the tab count during a pending drag. Reconcile the frozen snapshot before +rendering or updating the pointer position, canceling it when the count changes +or the source disappears. The renderer also rejects mismatched snapshot counts. + +## Rejected alternatives +Opaque fade overlays would change the underlying surface. Per-glyph colors would +not fade color emoji. Moving only the background without changing geometry would +leave hit testing and drag positions inconsistent with the displayed tabs. + +## Consequences +Overflow remains static in every interaction state. Glyph shaping is shared +within each label's paint; the trailing fade uses a fixed number of clip bands. +Chrome content has 20px horizontal padding: 12px for the flared feet plus 8px +inside the visible body. Overlap stays outside its controls. + +## Validation +[Rendered interaction tests](../../../../../nebula_app/src/gpui_shell/workspace/settings_navigation/ui_tests.rs) +cover static fading, hover close hitboxes, absence of separators, overlapping +tab bounds, content padding, count changes during pending gestures +and bidirectional dragging. Shared geometry tests cover Chrome's +negative gap alongside the existing positive-gap navigation cases. + +## Supersedes +The scrolling behavior in [interactive tab titles](2026-10-20-interactive-tab-titles.md). + +## Revisit when +Tab padding/corner sizes change or GPUI supplies a native gradient content mask. diff --git a/nebula_app/i18n/en-US.json b/nebula_app/i18n/en-US.json index d1493326b..4064cbad3 100644 --- a/nebula_app/i18n/en-US.json +++ b/nebula_app/i18n/en-US.json @@ -160,6 +160,13 @@ "refresh": "Refresh system environment for new terminals", "refresh_description": "Read current Windows environment settings when opening a terminal. Turn off to inherit the environment used to launch Pebrel, including temporary PATH entries. Open terminals are unchanged." }, + "tabs": { + "title": "Top tab style", + "description": "Chrome, a thin top line, or outlined tags. Tab widths follow their titles; long names fade out at the end.", + "chrome": "Chrome", + "minimal": "Minimal", + "tag": "Tag" + }, "cursor_motion": { "title": "Smooth cursor", "description": "Animate terminal cursor movement; large jumps remain instant.", diff --git a/nebula_app/i18n/zh-CN.json b/nebula_app/i18n/zh-CN.json index 7736ea709..b5a73febc 100644 --- a/nebula_app/i18n/zh-CN.json +++ b/nebula_app/i18n/zh-CN.json @@ -160,6 +160,13 @@ "refresh": "新建终端时刷新系统环境变量", "refresh_description": "打开终端时读取 Windows 当前环境设置。关闭后继承启动 Pebrel 时的环境,包括临时添加的 PATH。已打开的终端不受影响。" }, + "tabs": { + "title": "顶部标签栏样式", + "description": "谷歌、顶部细线或标签外观。标签宽度随名称自动调整,超长名称在末尾渐隐。", + "chrome": "谷歌样式", + "minimal": "简约样式", + "tag": "标签样式" + }, "cursor_motion": { "title": "平滑光标", "description": "让终端光标平滑移动,大范围跳转仍立即定位。", diff --git a/nebula_app/src/gpui_shell/config.rs b/nebula_app/src/gpui_shell/config.rs index c7859e911..250c08155 100644 --- a/nebula_app/src/gpui_shell/config.rs +++ b/nebula_app/src/gpui_shell/config.rs @@ -92,6 +92,7 @@ pub struct Settings { /// 标签关闭按钮与标签插入动画都在渲染热路径读取,必须随全局设置驻留内存。 pub tab_close_visible: bool, pub tab_reveal: nebula_settings::TabRevealName, + pub tab_style: nebula_settings::TabStyle, /// 命令补全设置(settings.txt 的 `ghost`/`completion_style`), /// 类型直接用旧壳 display 的语义枚举:接受键判定与样式分支两壳同源。 pub ghost: bool, @@ -306,6 +307,7 @@ impl Settings { notification_duration: runtime.notification_duration, tab_close_visible: runtime.tab_close_visible, tab_reveal: runtime.tab_reveal, + tab_style: runtime.tab_style, ghost: runtime.ghost, completion_style: runtime.completion_style, cell_width_mode: runtime.cell_width_mode, diff --git a/nebula_app/src/gpui_shell/settings_pane.rs b/nebula_app/src/gpui_shell/settings_pane.rs index cb4c19f04..28d43d3c4 100644 --- a/nebula_app/src/gpui_shell/settings_pane.rs +++ b/nebula_app/src/gpui_shell/settings_pane.rs @@ -798,6 +798,7 @@ impl SettingsPane { "accept" => pick!(accept), "completion_style" => pick!(completion_style), "tabs_position" => pick!(tabs_position), + "tab_style" => pick!(tab_style), "tab_reveal" => pick!(tab_reveal), "density" => pick!(density), "new_tab_position" => pick!(new_tab_position), diff --git a/nebula_app/src/gpui_shell/settings_pane/appearance_advanced.rs b/nebula_app/src/gpui_shell/settings_pane/appearance_advanced.rs index 4b4d820d6..f88fdb411 100644 --- a/nebula_app/src/gpui_shell/settings_pane/appearance_advanced.rs +++ b/nebula_app/src/gpui_shell/settings_pane/appearance_advanced.rs @@ -84,6 +84,12 @@ impl SettingsPane { )); let interface = self .group(language.pick("界面", "Interface"), cx) + .child(self.select_row( + "tab_style", + language.text(crate::i18n::Message::SettingsTabsTitle), + language.text(crate::i18n::Message::SettingsTabsDescription), + cx, + )) .child(self.font_size_row(true, cx)) .child(self.switch_row( "dim_inactive_panes", diff --git a/nebula_app/src/gpui_shell/settings_pane/initialization.rs b/nebula_app/src/gpui_shell/settings_pane/initialization.rs index 2c8feb2de..d676eb631 100644 --- a/nebula_app/src/gpui_shell/settings_pane/initialization.rs +++ b/nebula_app/src/gpui_shell/settings_pane/initialization.rs @@ -44,6 +44,21 @@ impl SettingsPane { this.set_cursor_motion(value, window, cx); return; } + if key == "tab_style" { + if let Err(error) = + this.try_persist(&[(key, (*value).to_owned())], cx) + { + let current = this.runtime.tab_style.settings_value(); + this.sync_select(key, current, window, cx); + super::super::toast::toast( + window, + cx, + crate::display::ToastKind::Warning, + error.to_string(), + ); + } + return; + } if key == "notification_duration" { this.set_notification_duration(value, window, cx); return; @@ -73,6 +88,13 @@ impl SettingsPane { ); let cursor_current = runtime.cursor_shape.map(|shape| shape.settings_value()).unwrap_or("beam"); + add_select( + "tab_style", + nebula_settings::TabStyle::VALUES, + runtime.tab_style.settings_value(), + window, + cx, + ); add_select( "cursor_motion", nebula_settings::CursorMotion::VALUES, diff --git a/nebula_app/src/gpui_shell/settings_pane/localization.rs b/nebula_app/src/gpui_shell/settings_pane/localization.rs index 75f235e31..05a388224 100644 --- a/nebula_app/src/gpui_shell/settings_pane/localization.rs +++ b/nebula_app/src/gpui_shell/settings_pane/localization.rs @@ -72,6 +72,11 @@ pub(super) fn localized_select_labels( language.text(crate::i18n::Message::SettingsCursorMotionOff), language.text(crate::i18n::Message::SettingsCursorMotionSmooth), ], + "tab_style" => vec![ + language.text(crate::i18n::Message::SettingsTabsChrome), + language.text(crate::i18n::Message::SettingsTabsMinimal), + language.text(crate::i18n::Message::SettingsTabsTag), + ], "tabs_position" => { vec![language.pick("左侧边栏", "Left sidebar"), language.pick("顶部", "Top")] }, diff --git a/nebula_app/src/gpui_shell/settings_pane/tab_style_settings_tests.rs b/nebula_app/src/gpui_shell/settings_pane/tab_style_settings_tests.rs new file mode 100644 index 000000000..a8460aba6 --- /dev/null +++ b/nebula_app/src/gpui_shell/settings_pane/tab_style_settings_tests.rs @@ -0,0 +1,45 @@ +use super::*; + +#[gpui::test] +fn tab_style_persists_keyboard_choices_reopens_and_cancels(cx: &mut TestAppContext) { + use crate::gpui_shell::config::Settings; + let _lock = lock_theme_studio(); + let _bytes = SettingsBytesGuard::capture(); + std::fs::create_dir_all(nebula_settings::settings_dir()).unwrap(); + std::fs::write(nebula_settings::settings_path(), TEST_SETTINGS).unwrap(); + let (pane, mut window) = open_settings(cx); + pane.update(&mut window, |pane, cx| { + pane.active_section = 1; + cx.notify(); + }); + window.simulate_resize(size(px(1200.0), px(2400.0))); + let values = nebula_settings::TabStyle::VALUES; + for (row, &value) in values.iter().enumerate().skip(1) { + draw(&mut window); + click("settings-select-tab_style", &mut window); + press("down", &mut window); + press("enter", &mut window); + assert_eq!(RuntimeSettings::load().tab_style.settings_value(), value); + window.read(|cx| { + let settings = cx.global::(); + assert_eq!(settings.tab_style.settings_value(), value); + }); + let reopened = window.update(|window, cx| cx.new(|cx| SettingsPane::new(window, cx))); + reopened.read_with(&mut window, |pane, cx| { + assert_eq!( + pane.select_of("tab_style").unwrap().read(cx).selected_index(cx).unwrap().row, + row + ); + }); + } + click("settings-select-tab_style", &mut window); + press("up", &mut window); + press("escape", &mut window); + pane.read_with(&mut window, |pane, _| { + assert_eq!( + pane.runtime.tab_style.settings_value(), + *values.last().unwrap(), + "Escape cancels" + ); + }); +} diff --git a/nebula_app/src/gpui_shell/settings_pane/theme_studio_tests.rs b/nebula_app/src/gpui_shell/settings_pane/theme_studio_tests.rs index 5a5f01a2e..037bef7e9 100644 --- a/nebula_app/src/gpui_shell/settings_pane/theme_studio_tests.rs +++ b/nebula_app/src/gpui_shell/settings_pane/theme_studio_tests.rs @@ -17,6 +17,8 @@ use nebula_settings::{RawSettings, RuntimeSettings, ThemeDefinition, ThemeName}; mod background_tests; #[path = "theme_package_tests.rs"] mod package_tests; +#[path = "tab_style_settings_tests.rs"] +mod tab_style_settings_tests; #[cfg(feature = "shader-background")] #[path = "terminal_effect_tests.rs"] mod terminal_effect_tests; diff --git a/nebula_app/src/gpui_shell/terminal/view.rs b/nebula_app/src/gpui_shell/terminal/view.rs index 30ee9100f..f4e276211 100644 --- a/nebula_app/src/gpui_shell/terminal/view.rs +++ b/nebula_app/src/gpui_shell/terminal/view.rs @@ -503,6 +503,7 @@ impl TerminalView { const RESIZE_SETTLE_DELAY: std::time::Duration = std::time::Duration::from_millis(150); /// 与 legacy `config::cursor::Cursor::default().blink_interval` 保持一致。 const CURSOR_BLINK_INTERVAL: std::time::Duration = std::time::Duration::from_millis(750); + const CONTENT_PADDING_TOP: f32 = 4.0; /// 首帧前的当前单元格度量(与 element prepaint 同一公式:shape "M" 的 /// advance / ascent / descent + 配置 offset)。让 spawn 网格与首帧布局一致,避免启动 @@ -1460,10 +1461,10 @@ impl Render for TerminalView { // 不画自己的背景:卡容器统一负责"卡底色(带窗口透明度)→ // 壁纸"两层,这里再铺一层不透明 bg 会把它们全部盖死。圆角也 // 只属于整个终端卡外壳;分屏 pane 自己带圆角会露出四个独立卡片。 - // 卡内呼吸边距,对齐旧壳网格 reserve 的换算值:上下 8(chrome - // 64/底 16 各减 8px 卡缝),左右 12(CONTENT_PAD_X 20 − 卡缝 8)。 + // 卡内边距:顶部 4px 收紧 chrome 下方留白,底部 8px,左右 12px。 // 网格因此不贴圆角;padding 区点击由 grid_point 的钳制兜底。 - .py(px(8.0)) + .pt(px(Self::CONTENT_PADDING_TOP)) + .pb(px(8.0)) .px(px(12.0)) .track_focus(&self.focus_handle) .on_action(cx.listener(Self::on_terminal_tab)) diff --git a/nebula_app/src/gpui_shell/terminal/view/pointer/tests.rs b/nebula_app/src/gpui_shell/terminal/view/pointer/tests.rs index e9d64c42a..ddd42c76d 100644 --- a/nebula_app/src/gpui_shell/terminal/view/pointer/tests.rs +++ b/nebula_app/src/gpui_shell/terminal/view/pointer/tests.rs @@ -202,6 +202,20 @@ fn clipboard(cx: &mut VisualTestContext) -> Option { cx.update(|_, cx| cx.read_from_clipboard().and_then(|item| item.text())) } +#[gpui::test] +fn terminal_grid_keeps_inset_coordinates_after_tightening_top_padding(cx: &mut TestAppContext) { + let (terminal, mut cx, _receiver) = link_fixture(cx, b"prompt> "); + let bounds = cx.debug_bounds("mouse-terminal").unwrap(); + terminal.read_with(&cx, |view, _| { + assert_eq!(view.origin, bounds.origin + point(px(12.0), px(4.0))); + }); + let first_row = cell(&terminal, &cx, 2); + terminal.read_with(&cx, |view, _| { + let (grid, _) = view.grid_point(first_row); + assert_eq!(grid, TermPoint::new(Line(0), Column(2))); + }); +} + #[gpui::test] fn right_click_menu_does_not_paste_and_quick_mode_remains_explicit(cx: &mut TestAppContext) { let (terminal, mut cx, receiver) = link_fixture(cx, b"prompt> "); diff --git a/nebula_app/src/gpui_shell/terminal/view/reading/output_hover.rs b/nebula_app/src/gpui_shell/terminal/view/reading/output_hover.rs index b19ed6399..a6c57649c 100644 --- a/nebula_app/src/gpui_shell/terminal/view/reading/output_hover.rs +++ b/nebula_app/src/gpui_shell/terminal/view/reading/output_hover.rs @@ -77,8 +77,8 @@ impl TerminalView { ), gpui::size((self.cell_width * self.cols as f32).min(px(180.0)), px(32.0)), ), - // 此偏移相对终端 root 的8px内边距,不参与网格或PTY尺寸计算。 - top: px(8.0) + offset, + // 相对终端 root 的顶部内边距,不参与网格或PTY尺寸计算。 + top: px(Self::CONTENT_PADDING_TOP) + offset, }) } pub(in crate::gpui_shell::terminal) fn clear_output_hover(&mut self, cx: &mut Context) { diff --git a/nebula_app/src/gpui_shell/workspace/render.rs b/nebula_app/src/gpui_shell/workspace/render.rs index dc0805f4b..1bda5600e 100644 --- a/nebula_app/src/gpui_shell/workspace/render.rs +++ b/nebula_app/src/gpui_shell/workspace/render.rs @@ -4,6 +4,7 @@ use super::*; impl Render for NebulaWorkspace { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { self.spinner_visible.set(false); + self.reconcile_tab_drag(cx); let theme_transition = self.theme_transition.render(window, cx); crate::gpui_shell::theme::sync_component_focus_ring(window, cx); if window.is_window_active() { @@ -140,6 +141,15 @@ impl Render for NebulaWorkspace { cx.stop_propagation(); } })) + .on_mouse_up_out(MouseButton::Left, cx.listener(|this, event: &gpui::MouseUpEvent, window, cx| { + // Occluding tabs hide the root hitbox, including during a fast release + // before the drag overlay has painted its first frame. + if this.tab_drag.as_ref().is_some_and(|drag| drag.axis == TabDragAxis::Horizontal) + && this.release_tab_drag_at(event.position, window, cx) + { + cx.stop_propagation(); + } + })) .on_action(cx.listener(|this, _: &NewTerminal, window, cx| { this.add_terminal(window, cx); })) diff --git a/nebula_app/src/gpui_shell/workspace/settings_navigation/ui_tests.rs b/nebula_app/src/gpui_shell/workspace/settings_navigation/ui_tests.rs index a58994d58..334f1ded9 100644 --- a/nebula_app/src/gpui_shell/workspace/settings_navigation/ui_tests.rs +++ b/nebula_app/src/gpui_shell/workspace/settings_navigation/ui_tests.rs @@ -205,12 +205,13 @@ fn density_changes_real_tab_bounds_and_sidebar_drag_pitch(cx: &mut TestAppContex use nebula_settings::{DensityName, TabsPositionName}; let (_directory, workspace, mut cx) = open_workspace(8, cx); - click_tab("top-tab-0", &mut cx); + // Settings starts at the end of the overflowing strip; tab 0 is offscreen. + cx.simulate_keystrokes(numbered_tab_shortcuts()[0]); for position in [TabsPositionName::Sidebar, TabsPositionName::Top] { for (density, height, gap, title_height) in [ - (DensityName::Standard, 34.0, 8.0, 48.0), - (DensityName::Compact, 32.0, 4.0, 48.0), - (DensityName::Standard, 34.0, 8.0, 48.0), + (DensityName::Standard, 34.0, 8.0, 40.0), + (DensityName::Compact, 32.0, 4.0, 40.0), + (DensityName::Standard, 34.0, 8.0, 40.0), ] { workspace.update(&mut cx, |workspace, cx| { workspace.tabs_position = position; @@ -249,3 +250,218 @@ fn density_changes_real_tab_bounds_and_sidebar_drag_pitch(cx: &mut TestAppContex } } } + +#[gpui::test] +fn top_tab_styles_size_titles_individually_and_preserve_hover_close(cx: &mut TestAppContext) { + use nebula_settings::TabStyle; + let (_directory, workspace, mut cx) = open_workspace(3, cx); + cx.simulate_keystrokes(numbered_tab_shortcuts()[0]); + workspace.update(&mut cx, |workspace, cx| { + for (meta, name) in workspace.tab_meta.iter_mut().zip([ + "sh", + "项目控制台", + "a very long terminal title that exceeds the maximum tab width", + ]) { + meta.custom_name = Some(name.into()); + } + cx.notify(); + }); + for style in [TabStyle::Chrome, TabStyle::Minimal, TabStyle::Tag] { + cx.update(|_, cx| cx.global_mut::().tab_style = style); + let first = tab_bounds("top-tab-0", &mut cx); + let second = tab_bounds("top-tab-1", &mut cx); + let long = tab_bounds("top-tab-2", &mut cx); + assert!( + first.size.width < second.size.width && second.size.width < long.size.width, + "{style:?}: {first:?} {second:?} {long:?}" + ); + assert_eq!(long.size.width, px(220.0)); + if style == TabStyle::Chrome { + assert_eq!(first.right() - second.left(), px(10.0), "Chrome feet overlap"); + let label = cx.debug_bounds("top-tab-label-0").unwrap(); + assert!( + (label.left() - first.left() - px(20.0 + TAB_LABEL_ICON_W + 8.0)).abs() <= px(1.0), + "selected title and leading icon clear the flared body" + ); + } else { + assert!(first.right() < second.left()); + } + cx.simulate_mouse_move(long.center(), None, Modifiers::default()); + tab_bounds("top-tab-2", &mut cx); + let close = cx.debug_bounds("top-close-tab-2").expect("hover close button"); + assert!(close.left() >= long.left() && close.right() <= long.right()); + if style == TabStyle::Chrome { + assert_eq!(long.right() - close.right(), px(20.0), "close clears the flared body"); + } + } + let close = tab_bounds("top-close-tab-2", &mut cx); + cx.simulate_click(close.center(), Modifiers::default()); + assert_eq!(workspace.read_with(&cx, |workspace, _| workspace.tabs.len()), 2); +} + +#[gpui::test] +fn long_top_titles_keep_static_fading_when_selected_or_hovered(cx: &mut TestAppContext) { + let (_directory, workspace, mut cx) = open_workspace(2, cx); + workspace.update(&mut cx, |workspace, cx| { + workspace.tab_meta[0].custom_name = Some("sh".into()); + workspace.tab_meta[1].custom_name = Some( + "a very long terminal title that exceeds the maximum tab width and must scroll".into(), + ); + cx.notify(); + }); + cx.update(|window, _| window.activate_window()); + click_tab("top-tab-0", &mut cx); + let long = tab_bounds("top-tab-1", &mut cx); + let fade = cx.debug_bounds("top-tab-title-fade-1").expect("inactive titles fade"); + assert!(cx.debug_bounds("top-tab-title-moving-1").is_none()); + + let hover = gpui::point(long.left() + px(14.0), long.center().y); + cx.simulate_mouse_move(hover, None, Modifiers::default()); + tab_bounds("top-tab-1", &mut cx); + let label = cx.debug_bounds("top-tab-label-1").unwrap(); + let text = cx.debug_bounds("top-tab-title-text-1").expect("full title retains its origin"); + assert_eq!(cx.debug_bounds("top-tab-title-fade-1"), Some(fade)); + assert!(cx.debug_bounds("top-tab-title-moving-1").is_none()); + let close = cx.debug_bounds("top-close-tab-1").unwrap(); + assert!(text.size.width > label.size.width); + assert!(label.right() <= close.left(), "fading text must end before the close slot"); + assert_eq!(tab_bounds("top-tab-1", &mut cx), long); + + let short = tab_bounds("top-tab-0", &mut cx); + cx.simulate_mouse_move(short.center(), None, Modifiers::default()); + tab_bounds("top-tab-1", &mut cx); + assert_eq!(cx.debug_bounds("top-tab-title-fade-1"), Some(fade)); + assert!(cx.debug_bounds("top-tab-title-moving-1").is_none()); + assert!(cx.debug_bounds("top-tab-title-moving-0").is_none(), "short titles stay still"); + + cx.simulate_click(hover, Modifiers::default()); + cx.simulate_mouse_move(short.center(), None, Modifiers::default()); + tab_bounds("top-tab-1", &mut cx); + assert_eq!(cx.debug_bounds("top-tab-title-fade-1"), Some(fade)); + assert!(cx.debug_bounds("top-tab-title-moving-1").is_none(), "selection must stay static"); + assert_eq!(cx.debug_bounds("top-tab-title-text-1"), Some(text)); + click_tab("top-tab-0", &mut cx); + assert!(cx.debug_bounds("top-tab-title-moving-1").is_none()); +} + +#[gpui::test] +fn chrome_tabs_omit_separators_when_selected_or_hovered(cx: &mut TestAppContext) { + let (_directory, workspace, mut cx) = open_workspace(4, cx); + workspace.update(&mut cx, |workspace, cx| { + for meta in &mut workspace.tab_meta { + meta.custom_name = Some("sh".into()); + } + cx.notify(); + }); + cx.update(|_, cx| { + cx.global_mut::().tab_style = + nebula_settings::TabStyle::Chrome; + }); + for selected in ["top-tab-2", "top-tab-0", "top-tab-4"] { + click_tab(selected, &mut cx); + let neighbor = tab_bounds("top-tab-1", &mut cx); + cx.simulate_mouse_move(neighbor.center(), None, Modifiers::default()); + tab_bounds(selected, &mut cx); + for separator in [ + "top-tab-separator-0", + "top-tab-separator-1", + "top-tab-separator-2", + "top-tab-separator-3", + "top-tab-separator-4", + ] { + assert!(cx.debug_bounds(separator).is_none()); + } + } +} + +#[gpui::test] +fn changing_tab_count_cancels_a_pending_horizontal_drag_snapshot(cx: &mut TestAppContext) { + let (directory, workspace, mut cx) = open_workspace(2, cx); + click_tab("top-tab-0", &mut cx); + let path = directory.path().join("added-during-gesture.txt"); + std::fs::write(&path, "fixture\n").unwrap(); + for add in [true, false] { + let bounds = tab_bounds("top-tab-0", &mut cx); + let press = gpui::point(bounds.left() + px(14.0), bounds.center().y); + cx.simulate_mouse_down(press, gpui::MouseButton::Left, Modifiers::default()); + workspace.read_with(&cx, |workspace, _| assert!(workspace.tab_drag.is_some())); + workspace.update_in(&mut cx, |workspace, window, cx| { + if add { + let view = cx.new(|cx| { + crate::gpui_shell::code_tab::CodeTabView::new(path.clone(), window, cx) + }); + let subscription = cx.subscribe(&view, |_, _, _, _| {}); + workspace.insert_tab_at( + workspace.tabs.len(), + WorkspaceTab::Code { view, _subscription: subscription }, + TabMeta::default(), + ); + } else { + workspace.close_top_tab(2, window, cx); + } + cx.notify(); + }); + tab_bounds(if add { "top-tab-3" } else { "top-tab-2" }, &mut cx); + workspace.read_with(&cx, |workspace, _| assert!(workspace.tab_drag.is_none())); + cx.simulate_mouse_up(press, gpui::MouseButton::Left, Modifiers::default()); + } +} + +#[gpui::test] +fn chrome_overlap_keeps_the_previous_tabs_close_button_clickable(cx: &mut TestAppContext) { + let (_directory, workspace, mut cx) = open_workspace(3, cx); + cx.update(|_, cx| { + cx.global_mut::().tab_style = + nebula_settings::TabStyle::Chrome; + }); + click_tab("top-tab-2", &mut cx); + let previous = tab_bounds("top-tab-1", &mut cx); + cx.simulate_mouse_move(previous.center(), None, Modifiers::default()); + tab_bounds("top-tab-1", &mut cx); + let close = cx.debug_bounds("top-close-tab-1").unwrap(); + let selected = cx.debug_bounds("top-tab-2").unwrap(); + assert_eq!(selected.left() - close.right(), px(10.0)); + cx.simulate_click(close.center(), Modifiers::default()); + cx.run_until_parked(); + workspace.read_with(&cx, |workspace, _| { + assert_eq!(workspace.tabs.len(), 2); + assert_eq!(workspace.active, 1, "closing the neighbor must retain the selected tab"); + }); +} + +#[gpui::test] +fn top_tab_drag_reorders_mixed_width_titles_in_both_directions(cx: &mut TestAppContext) { + let (_directory, workspace, mut cx) = open_workspace(2, cx); + cx.simulate_keystrokes(numbered_tab_shortcuts()[0]); + workspace.update(&mut cx, |workspace, cx| { + workspace.tab_meta[0].custom_name = Some("sh".into()); + workspace.tab_meta[1].custom_name = Some("a much longer terminal title".into()); + cx.notify(); + }); + for (source, target, expected) in [ + ("top-tab-0", "top-tab-1", "a much longer terminal title"), + ("top-tab-1", "top-tab-0", "sh"), + ] { + let source_bounds = tab_bounds(source, &mut cx); + // A short tab's center can be its close button; drag its leading padding. + let source = gpui::point(source_bounds.left() + px(14.0), source_bounds.center().y); + let target = tab_bounds(target, &mut cx).center(); + cx.simulate_mouse_down(source, gpui::MouseButton::Left, Modifiers::default()); + workspace.read_with(&cx, |workspace, _| { + assert!(workspace.tab_drag.is_some(), "tab padding must arm dragging") + }); + cx.simulate_mouse_move(target, Some(gpui::MouseButton::Left), Modifiers::default()); + workspace.read_with(&cx, |workspace, _| { + assert!( + workspace.tab_drag.as_ref().is_some_and(|drag| drag.active), + "moving beyond the threshold must activate dragging" + ) + }); + cx.simulate_mouse_up(target, gpui::MouseButton::Left, Modifiers::default()); + cx.run_until_parked(); + workspace.read_with(&cx, |workspace, _| { + assert_eq!(workspace.tabs.len(), 2); + assert_eq!(workspace.tab_meta[0].custom_name.as_deref(), Some(expected)); + }); + } +} diff --git a/nebula_app/src/gpui_shell/workspace/sidebar.rs b/nebula_app/src/gpui_shell/workspace/sidebar.rs index c594ea7fa..e54ede759 100644 --- a/nebula_app/src/gpui_shell/workspace/sidebar.rs +++ b/nebula_app/src/gpui_shell/workspace/sidebar.rs @@ -383,6 +383,7 @@ impl NebulaWorkspace { MouseButton::Left, cx.listener(move |this, event: &MouseDownEvent, _, cx| { this.tab_drag = Some(TabDrag { + horizontal: None, source: ix, cross_window: this.cross_window_drag_payload(ix, cx), cross_window_target: None, diff --git a/nebula_app/src/gpui_shell/workspace/tab_drag.rs b/nebula_app/src/gpui_shell/workspace/tab_drag.rs index 2fa0a1a65..feba4f7b0 100644 --- a/nebula_app/src/gpui_shell/workspace/tab_drag.rs +++ b/nebula_app/src/gpui_shell/workspace/tab_drag.rs @@ -29,12 +29,29 @@ pub(super) struct TabDrag { pub(super) press_y: f32, pub(super) axis: TabDragAxis, pub(super) pitch: f32, + pub(super) horizontal: Option<(super::top_tabs::layout::TabStripLayout, f32)>, pub(super) offset: f32, pub(super) active: bool, pub(super) dock: Option, } impl NebulaWorkspace { + /// Tab creation/closing can race a pending pointer gesture or Runtime API request. + /// Discard a snapshot whose tab count no longer matches before rendering any rows. + pub(super) fn reconcile_tab_drag(&mut self, cx: &mut Context<'_, Self>) { + let invalid = self.tab_drag.as_ref().is_some_and(|drag| { + drag.source >= self.tabs.len() + || drag + .horizontal + .as_ref() + .is_some_and(|(layout, _)| layout.tab_count() != self.top_tab_count()) + }); + if invalid { + let drag = self.tab_drag.take().expect("invalid gesture checked above"); + windowing::clear_cross_window_drag_target(drag.cross_window_target, cx); + } + } + /// 根节点只负责把移出 tab hitbox 的待命拖拽继续交给状态机。 /// GPUI 的元素监听离开 hover 后不再派发 move,因此这里不能只挂在 tab 行。 pub(super) fn continue_pending_tab_drag( @@ -113,6 +130,9 @@ impl NebulaWorkspace { /// 源下标加单轴位移换算出的整槽数;越过半格即换位。 pub(super) fn drag_slot(drag: &TabDrag, len: usize) -> usize { + if let Some((layout, _)) = &drag.horizontal { + return layout.drag_target(drag.source, drag.offset, len); + } let slots = (drag.offset / drag.pitch.max(1.0)).round() as isize; (drag.source as isize + slots).clamp(0, len.saturating_sub(1) as isize) as usize } @@ -160,6 +180,7 @@ impl NebulaWorkspace { } fn update_tab_drag_position(&mut self, x: f32, y: f32, cx: &mut Context) { + self.reconcile_tab_drag(cx); let len = self.tabs.len(); // dock 必须在可变借用 drag 前计算;只有两个 Terminal tab 之间允许。 let source = self.tab_drag.as_ref().map(|drag| drag.source); @@ -174,10 +195,21 @@ impl NebulaWorkspace { if drag.active { let delta = match drag.axis { TabDragAxis::Vertical => dy, - TabDragAxis::Horizontal => dx, + TabDragAxis::Horizontal => { + dx + drag.horizontal.as_ref().map_or(0.0, |(_, start)| { + start - f32::from(self.top_tabs_scroll.offset().x) + }) + }, }; - let before = -(drag.source as f32) * drag.pitch; - let after = (len.saturating_sub(1) as f32 - drag.source as f32) * drag.pitch; + let (before, after) = drag.horizontal.as_ref().map_or_else( + || { + ( + -(drag.source as f32) * drag.pitch, + (len.saturating_sub(1) as f32 - drag.source as f32) * drag.pitch, + ) + }, + |(layout, _)| layout.drag_limits(drag.source, len), + ); drag.offset = delta.clamp(before, after.max(before)); drag.dock = dock; cx.notify(); diff --git a/nebula_app/src/gpui_shell/workspace/top_tabs.rs b/nebula_app/src/gpui_shell/workspace/top_tabs.rs index c1a955d5d..5bd9b8da1 100644 --- a/nebula_app/src/gpui_shell/workspace/top_tabs.rs +++ b/nebula_app/src/gpui_shell/workspace/top_tabs.rs @@ -3,14 +3,19 @@ //! 这里只负责同一组 workspace tab 的第二种呈现;激活、关闭、重命名、排序 //! 与 dock 都调用 `NebulaWorkspace` 既有动作,不维护平行状态。 +mod label; +pub(super) mod layout; +mod style; + +use layout::{MAX_WIDTH as TOP_TAB_MAX_W, MIN_WIDTH as TOP_TAB_MIN_W, TabStripLayout}; use std::time::Duration; use gpui::prelude::FluentBuilder as _; use gpui::{ Animation, AnimationExt as _, App, ClickEvent, Context, FontWeight, InteractiveElement as _, IntoElement as _, KeyDownEvent, MouseButton, MouseDownEvent, ObjectFit, ParentElement as _, - ScrollWheelEvent, SharedString, StatefulInteractiveElement as _, Styled as _, StyledImage as _, - Window, div, ease_out_quint, img, px, + SharedString, StatefulInteractiveElement as _, Styled as _, StyledImage as _, Window, div, + ease_out_quint, img, px, }; use gpui_component::menu::PopupMenuItem; @@ -26,38 +31,13 @@ use super::{ /// 与顶部模式正文卡片的 `px_2` 左边距同源,让首个 tab 和终端左缘对齐。 pub(super) const TOP_TAB_LEFT_INSET: f32 = 8.0; -/// 单个 tab 的最小宽。WT 的 TabView 同一条取舍:标签**不压到读不出来**, -/// 宁可溢出让用户翻页。120 曾经是下限,但分屏 tab 的行首 2×2 图标 + 尾部 -/// 数量胶囊各要 20 多像素,120 里标题只剩两三个字符(实测挤成 `te`)。 -/// 翻页按钮到位之后,早一点溢出比早一点读不出来划算。 -const TOP_TAB_MIN_W: f32 = 160.0; -const TOP_TAB_MAX_W: f32 = 220.0; -const TOP_TAB_GAP: f32 = 4.0; const TOP_TAB_STATUS_W: f32 = 28.0; /// 标题栏中不属于 tab 视口的固定预算:左内边距、五枚 32px 操作按钮、 /// 三枚 34px 窗口按钮,以及至少 72px 的可拖拽空白。 const TOP_TAB_RESERVED_W: f32 = TOP_TAB_LEFT_INSET + 32.0 * 5.0 + 34.0 * 3.0 + 72.0; -/// 溢出翻页按钮(WT 的 TabView 在 tab 溢出时于两端给 `‹ ›`)单枚占的宽。 -const TOP_TAB_NUDGE_W: f32 = 22.0; /// 拖拽越界自动滚的触发带宽:指针进到视口左/右这么近就开始滚。 const TOP_TAB_EDGE_BAND: f32 = 24.0; -fn tab_width(viewport_w: f32, count: usize) -> f32 { - if count == 0 || viewport_w <= 1.0 { - return TOP_TAB_MAX_W; - } - (viewport_w / count as f32).clamp(TOP_TAB_MIN_W, TOP_TAB_MAX_W) -} - -fn tab_strip_width(tab_w: f32, count: usize) -> f32 { - tab_w * count as f32 + TOP_TAB_GAP * count.saturating_sub(1) as f32 -} - -/// tab 条是否装不下。装不下才画翻页按钮,也才允许自动滚。 -fn strip_overflows(strip_w: f32, capacity_w: f32) -> bool { - strip_w > capacity_w + 0.5 -} - /// GPUI 横向滚动偏移是 ≤ 0(内容向左移)。 /// 可滚范围因此是 `[-(strip - viewport), 0]`。 fn clamp_strip_offset(x: f32, strip_w: f32, viewport_w: f32) -> f32 { @@ -130,10 +110,20 @@ impl NebulaWorkspace { let row_height = super::tab_scroll::tab_row_height(self.density); let language = crate::gpui_shell::config::ui_language(cx); let port_forward_button = self.render_port_forward_button(cx); - let theme = cx.theme(); + let theme = cx.theme().clone(); let muted = theme.muted_foreground; let active_bg = theme.sidebar_accent; - let active_fg = theme.sidebar_accent_foreground; + let tab_style = cx + .try_global::() + .map(|s| s.tab_style) + .unwrap_or_default(); + // Chrome's 12px flared feet need another 8px inside the visible body. + let tab_padding = if tab_style == nebula_settings::TabStyle::Chrome { 20.0 } else { 12.0 }; + let active_fg = if tab_style == nebula_settings::TabStyle::Chrome { + theme.sidebar_accent_foreground + } else { + theme.foreground + }; let hover_bg = theme.list_hover; let dark = theme.is_dark(); let settings = cx.try_global::(); @@ -149,26 +139,89 @@ impl NebulaWorkspace { let tab_capacity_w = (f32::from(window.viewport_size().width) - TOP_TAB_RESERVED_W - port_forward_w) .max(TOP_TAB_MIN_W); - let tab_w = tab_width(tab_capacity_w, self.top_tab_count()); - let strip_w = tab_strip_width(tab_w, self.top_tab_count()); - // 溢出时两端各让出一枚翻页按钮。这个反馈是单调的:`tab_w` 已被 - // `TOP_TAB_MIN_W` 钳死,扣掉按钮宽只会让溢出更成立,不会在"画了按钮 - // → 不溢出了 → 撤掉按钮"之间抖。 - let overflow = strip_overflows(strip_w, tab_capacity_w); - let tab_viewport_w = if overflow { - (tab_capacity_w - TOP_TAB_NUDGE_W * 2.0).max(TOP_TAB_MIN_W) - } else { - strip_w.min(tab_capacity_w) - }; + let presentations: Vec<_> = + (0..self.top_tab_count()).map(|ix| self.top_tab_presentation(ix, cx, dark)).collect(); + let top_tab_count = presentations.len(); + let status_widths: Vec<_> = presentations + .iter() + .map(|tab| { + Self::shell_status_width( + window, + tab.shell_tag.as_ref(), + &metadata_family, + label_px * 0.8, + TOP_TAB_STATUS_W, + TOP_TAB_MAX_W * 0.4, + ) + }) + .collect(); + let text_widths: Vec<_> = presentations + .iter() + .map(|tab| { + layout::text_width(tab.title.clone(), chrome_family.clone(), label_px, window) + }) + .collect(); + let widths: Vec<_> = presentations + .iter() + .enumerate() + .map(|(ix, tab)| { + if tab.renaming.is_some() { + return TOP_TAB_MAX_W; + } + let icon = if tab.is_settings + || tab.logo_image.is_some() + || tab.program_glyph.is_some() + || tab.pane_count > 1 + { + TAB_LABEL_ICON_SIZE.max(TAB_LABEL_ICON_W) + 8.0 + } else { + 0.0 + }; + let admin = if tab.local_administrator { TAB_LABEL_ICON_SIZE + 8.0 } else { 0.0 }; + let badge = if tab.pane_count > 1 { + pane_header::split_badge_slot_w(label_px) + 8.0 + } else { + 0.0 + }; + text_widths[ix] + + if tab_style == nebula_settings::TabStyle::Tag { 2.0 } else { 0.0 } + + tab_padding * 2.0 + + icon + + admin + + badge + + 8.0 + + status_widths[ix] + }) + .collect(); + let inset = crate::platform::window_chrome::layout(window) + .map_or(TOP_TAB_LEFT_INSET, |(_, inset)| inset); + let layout = self + .tab_drag + .as_ref() + .and_then(|drag| drag.horizontal.as_ref()) + .filter(|(layout, _)| layout.tab_count() == top_tab_count) + .map(|(layout, _)| layout.clone()) + .unwrap_or_else(|| { + TabStripLayout::new( + widths.iter().copied(), + tab_capacity_w, + inset, + layout::gap(tab_style), + ) + }); + let strip_w = layout.width; + let overflow = layout.overflow; + let tab_viewport_w = layout.viewport; let scroll_x = f32::from(self.top_tabs_scroll.offset().x); - let pitch = tab_w + TOP_TAB_GAP; let drag = self .tab_drag .as_ref() .filter(|drag| drag.active && drag.axis == TabDragAxis::Horizontal) .map(|drag| (drag.source, Self::drag_slot(drag, self.tabs.len()), drag.offset)); - let items = (0..self.top_tab_count()) - .map(|ix| { + let items = presentations.into_iter().enumerate() + .map(|(ix, presentation)| { + let tab_w = layout.tab_width(ix); + let drag_layout = layout.clone(); let settings_navigation = self.settings_tab_open && ix == self.tabs.len(); let active = if settings_navigation { self.settings_open @@ -188,18 +241,11 @@ impl NebulaWorkspace { renaming, pane_count, local_administrator, - } = self.top_tab_presentation(ix, cx, dark); + } = presentation; + let tooltip = Some(tooltip.unwrap_or_else(|| title.clone())); let hover_group: SharedString = format!("top-tab-hover-{ix}").into(); let cross_window_drag = self.cross_window_drag_payload(ix, cx); - let status_color = if active { active_fg } else { muted }; - let status_width = Self::shell_status_width( - window, - shell_tag.as_ref(), - &metadata_family, - label_px * 0.8, - TOP_TAB_STATUS_W, - tab_w * 0.4, - ); + let status_width = status_widths[ix]; let resting_status: Option = match activity { SidebarActivity::Running => { let (track, head) = @@ -242,13 +288,8 @@ impl NebulaWorkspace { .into_any_element(), ), SidebarActivity::Idle => shell_tag.map(|tag| { - Self::shell_status_label( - tag, - metadata_family.clone(), - label_px * 0.8, - status_color, - ) - .into_any_element() + Self::shell_status_label(tag, metadata_family.clone(), label_px * 0.8, muted) + .into_any_element() }), }; let strip = color.map(|color| gpui::Rgba { @@ -260,10 +301,10 @@ impl NebulaWorkspace { let (dragged, shift) = match drag { Some((source, _, _)) if ix == source => (true, 0.0), Some((source, target, _)) if source < target && ix > source && ix <= target => { - (false, -pitch) + (false, layout.shift(ix, source, target)) }, Some((source, target, _)) if source > target && ix >= target && ix < source => { - (false, pitch) + (false, layout.shift(ix, source, target)) }, _ => (false, 0.0), }; @@ -283,22 +324,30 @@ impl NebulaWorkspace { .overflow_hidden() .items_center() .gap_2() - .px_2() + .px(px(tab_padding)) .font_family(chrome_family.clone()) - // 顶部 tab 的底边直接接正文,只保留上侧圆角;四角全圆 - // 会把它重新画成悬浮在标题栏里的药丸。 - .rounded_tl(px(crate::display::UI_CORNER_RADIUS_LOGICAL)) - .rounded_tr(px(crate::display::UI_CORNER_RADIUS_LOGICAL)) + .map(|row| style::apply(row, tab_style, active, dragged, hover_group.clone(), cx)) .font_weight(FontWeight::NORMAL) .cursor_pointer() // TitleBar 的父层是 WindowControlArea::Drag;tab 必须自己 // 占住普通鼠标命中区,滚轮仍交给外层标签滚动容器。 .block_mouse_except_scroll() - .when(active, |item| item.bg(active_bg).text_color(active_fg)) + .when(active, |item| item.text_color(active_fg)) .when(!active && !dragged, |item| { - item.text_color(muted).hover(|style| style.bg(hover_bg)) + item.text_color(muted).when(tab_style != nebula_settings::TabStyle::Tag, |item| { + item.hover(|style| style.bg(hover_bg)) + }) + }) + .when(!active && dragged, |item| { + item.text_color(muted).when(tab_style != nebula_settings::TabStyle::Tag, |item| item.bg(hover_bg)) }) - .when(!active && dragged, |item| item.text_color(muted).bg(hover_bg)) + .on_mouse_move(cx.listener(|this, event, window, cx| { + // Occluding tabs own hover; their ancestors may not receive this move. + if this.tab_drag.is_some() { + this.update_tab_drag(event, window, cx); + cx.stop_propagation(); + } + })) .on_click(cx.listener(move |this, _, window, cx| { cx.stop_propagation(); this.activate_top_tab(ix, window, cx); @@ -317,7 +366,8 @@ impl NebulaWorkspace { press_x: f32::from(event.position.x), press_y: f32::from(event.position.y), axis: TabDragAxis::Horizontal, - pitch, + pitch: tab_w + drag_layout.gap, + horizontal: Some((drag_layout.clone(), scroll_x)), offset: 0.0, active: false, dock: None, @@ -443,15 +493,14 @@ impl NebulaWorkspace { .font_family(chrome_family.clone()), ) .into_any_element(), - None => div() - .flex_1() - .min_w_0() - .truncate() - .font_family(chrome_family.clone()) - .text_size(px(label_px)) - .font_weight(FontWeight::NORMAL) - .child(title) - .into_any_element(), + None => label::render( + ix, + title, + chrome_family.clone(), + label_px, + text_widths[ix], + (widths[ix] - tab_w).max(0.0), + ), }) .when(pane_count > 1, |row| { row.child( @@ -473,9 +522,10 @@ impl NebulaWorkspace { }) .child( Self::tab_status_slot(status_width) - .when_some(resting_status, |slot, status| { + .when_some(resting_status.filter(|_| active), |slot, status| { slot.child( h_flex() + .debug_selector(move || format!("top-tab-resting-status-{ix}")) .absolute() .inset_0() .justify_end() @@ -495,6 +545,7 @@ impl NebulaWorkspace { .when(tab_close_visible || settings_navigation, |slot| { slot.child( Button::new(("top-close-tab", ix)) + .debug_selector(move || format!("top-close-tab-{ix}")) .icon(IconName::Close) .ghost() .xsmall() @@ -514,10 +565,11 @@ impl NebulaWorkspace { }) }); - if dragged { + let row = if dragged { gpui::deferred( row.left(px(drag.map(|(_, _, offset)| offset).unwrap_or(0.0))).shadow_md(), ) + .with_priority(1) .into_any_element() } else if shift != 0.0 { if tab_reveal == nebula_settings::TabRevealName::Instant { @@ -533,6 +585,11 @@ impl NebulaWorkspace { } } else { row.into_any_element() + }; + if active && !dragged { + gpui::deferred(row).into_any_element() + } else { + row } }) .collect::>(); @@ -551,11 +608,13 @@ impl NebulaWorkspace { .flex_shrink(1.0) .min_w_0() .items_end() + .when(tab_style == nebula_settings::TabStyle::Tag, |bar| bar.items_center()) // 溢出翻页:只在装不下时出现。滚轮已经能滚,但滚轮是个 // 看不见的入口——tab 一多,用户根本不知道右边还有东西。 .when(overflow, |bar| { + let nudge_layout = layout.clone(); bar.child( - div().h(px(row_height)).flex().items_center().child( + div().w(px(layout::NUDGE_WIDTH)).h(px(row_height)).flex_shrink_0().flex().items_center().justify_center().child( title_bar_panel_controls().h_auto().child( Button::new("top-tabs-prev") .icon(IconName::ChevronLeft) @@ -564,13 +623,7 @@ impl NebulaWorkspace { .disabled(at_strip_start(scroll_x)) .tooltip(language.text(Message::ChromeScrollTabsLeft)) .on_click(cx.listener(move |this, _, _, cx| { - this.nudge_top_tabs( - -1.0, - pitch, - strip_w, - tab_viewport_w, - cx, - ); + this.nudge_top_tabs(-1.0, &nudge_layout, cx); })), ), ), @@ -579,6 +632,7 @@ impl NebulaWorkspace { .child( div() .id("top-tabs-viewport") + .debug_selector(|| "top-tabs-viewport".to_owned()) .relative() .w(px(tab_viewport_w)) .flex_shrink(1.0) @@ -598,15 +652,16 @@ impl NebulaWorkspace { .id("top-tabs-scroll") .size_full() .items_center() - .gap(px(TOP_TAB_GAP)) + .gap(px(layout.gap)) .overflow_x_scroll() .track_scroll(&self.top_tabs_scroll) .children(items), ), ) .when(overflow, |bar| { + let nudge_layout = layout.clone(); bar.child( - div().h(px(row_height)).flex().items_center().child( + div().w(px(layout::NUDGE_WIDTH)).h(px(row_height)).flex_shrink_0().flex().items_center().justify_center().child( title_bar_panel_controls().h_auto().child( Button::new("top-tabs-next") .icon(IconName::ChevronRight) @@ -615,13 +670,7 @@ impl NebulaWorkspace { .disabled(at_strip_end(scroll_x, strip_w, tab_viewport_w)) .tooltip(language.text(Message::ChromeScrollTabsRight)) .on_click(cx.listener(move |this, _, _, cx| { - this.nudge_top_tabs( - 1.0, - pitch, - strip_w, - tab_viewport_w, - cx, - ); + this.nudge_top_tabs(1.0, &nudge_layout, cx); })), ), ), @@ -714,59 +763,38 @@ impl NebulaWorkspace { .into_any_element() } - /// 翻页按钮的落点:一次一个 tab 步距,钳在可滚范围内。 - fn nudge_top_tabs( - &mut self, - dir: f32, - step: f32, - strip_w: f32, - viewport_w: f32, - cx: &mut Context, - ) { + fn nudge_top_tabs(&mut self, dir: f32, layout: &TabStripLayout, cx: &mut Context) { let mut offset = self.top_tabs_scroll.offset(); - let next = nudge_offset(f32::from(offset.x), step, dir, strip_w, viewport_w); - if (next - f32::from(offset.x)).abs() < 0.5 { - return; - } - offset.x = px(next); + offset.x = px(layout.nudge(f32::from(offset.x), dir)); self.top_tabs_scroll.set_offset(offset); cx.notify(); } - /// 横向拖拽越界自动滚:指针进到视口左/右边缘 [`TOP_TAB_EDGE_BAND`] 内就 - /// 按半个步距推一次。翻页按钮解决的是"看不见后面还有 tab",这条解决的是 - /// "拖着 tab 推不过滚动边界"——两者少一个,tab 一多都还是废的。 - /// - /// 视口几何这里现算:`update_tab_drag` 拿不到 render 时的那份局部量,而 - /// 两处的减法必须同源,否则边缘带会和真实视口错开。 + /// Use the same frozen geometry as the drag gesture, including optional toolbar controls. pub(super) fn autoscroll_top_tabs_for_drag( &mut self, pointer_x: f32, - window: &Window, + _window: &Window, cx: &mut Context, ) { - if self.tabs_position != nebula_settings::TabsPositionName::Top { + let Some((layout, _)) = self.tab_drag.as_ref().and_then(|drag| drag.horizontal.as_ref()) + else { return; - } - let capacity_w = - (f32::from(window.viewport_size().width) - TOP_TAB_RESERVED_W).max(TOP_TAB_MIN_W); - let tab_w = tab_width(capacity_w, self.top_tab_count()); - let strip_w = tab_strip_width(tab_w, self.top_tab_count()); - if !strip_overflows(strip_w, capacity_w) { + }; + if !layout.overflow { return; } - let viewport_w = (capacity_w - TOP_TAB_NUDGE_W * 2.0).max(TOP_TAB_MIN_W); - // 视口左缘 = 左内边距 + 左侧翻页按钮。 - let left = TOP_TAB_LEFT_INSET + TOP_TAB_NUDGE_W; - let right = left + viewport_w; - let dir = if pointer_x <= left + TOP_TAB_EDGE_BAND { + let dir = if pointer_x <= layout.left + TOP_TAB_EDGE_BAND { -1.0 - } else if pointer_x >= right - TOP_TAB_EDGE_BAND { + } else if pointer_x >= layout.left + layout.viewport - TOP_TAB_EDGE_BAND { 1.0 } else { return; }; - self.nudge_top_tabs(dir, (tab_w + TOP_TAB_GAP) * 0.5, strip_w, viewport_w, cx); + let mut offset = self.top_tabs_scroll.offset(); + offset.x = px(nudge_offset(f32::from(offset.x), 48.0, dir, layout.width, layout.viewport)); + self.top_tabs_scroll.set_offset(offset); + cx.notify(); } } @@ -774,20 +802,11 @@ impl NebulaWorkspace { mod tests { use super::*; - #[test] - fn tab_width_fills_then_clamps_to_single_row_bounds() { - assert_eq!(tab_width(600.0, 3), 200.0); - assert_eq!(tab_width(1200.0, 3), TOP_TAB_MAX_W); - assert_eq!(tab_width(500.0, 10), TOP_TAB_MIN_W); - assert_eq!(tab_width(0.0, 3), TOP_TAB_MAX_W); - assert_eq!(tab_strip_width(200.0, 3), 608.0); - } - #[test] fn nudge_buttons_only_exist_once_the_strip_overflows() { - assert!(!strip_overflows(600.0, 800.0)); - assert!(!strip_overflows(800.0, 800.0)); - assert!(strip_overflows(801.0, 800.0)); + assert!(!TabStripLayout::new([200.0; 3], 800.0, 0.0, layout::GAP).overflow); + assert!(!TabStripLayout::new([200.0; 3], 608.0, 0.0, layout::GAP).overflow); + assert!(TabStripLayout::new([200.0; 3], 607.0, 0.0, layout::GAP).overflow); } #[test] diff --git a/nebula_app/src/gpui_shell/workspace/top_tabs/label.rs b/nebula_app/src/gpui_shell/workspace/top_tabs/label.rs new file mode 100644 index 000000000..f67316848 --- /dev/null +++ b/nebula_app/src/gpui_shell/workspace/top_tabs/label.rs @@ -0,0 +1,116 @@ +//! Static overflow fading; the shell status and close slot remain outside the mask. + +use std::{cell::OnceCell, rc::Rc}; + +use gpui::{ + AnyElement, Bounds, ContentMask, FontWeight, InteractiveElement as _, IntoElement as _, + ParentElement as _, ShapedLine, SharedString, StatefulInteractiveElement as _, Styled as _, + TextAlign, canvas, div, point, px, size, +}; + +const FADE_WIDTH: f32 = 48.0; +const FADE_STEPS: usize = 24; + +pub(super) fn render( + index: usize, + title: SharedString, + family: SharedString, + font_size: f32, + text_width: f32, + overflow: f32, +) -> AnyElement { + let label = div() + .id(("top-tab-label", index)) + .debug_selector(move || format!("top-tab-label-{index}")) + .flex_1() + .min_w_0() + .overflow_hidden() + .font_family(family) + .text_size(px(font_size)) + .font_weight(FontWeight::NORMAL); + if overflow <= 0.5 { + return label + .child( + div() + .debug_selector(move || format!("top-tab-title-static-{index}")) + .whitespace_nowrap() + .child(title), + ) + .into_any_element(); + } + + // Retain the normal text height and accessibility label without painting it twice. + let mut content = div() + .id(("top-tab-title-fade", index)) + .debug_selector(move || format!("top-tab-title-fade-{index}")) + .relative() + .w_full() + .aria_label(title.clone()) + .child( + div() + .debug_selector(move || format!("top-tab-title-text-{index}")) + .w(px(text_width)) + .whitespace_nowrap() + .opacity(0.0) + .child(title.clone()), + ); + let shaped = Rc::new(OnceCell::::new()); + // Reuse one shaped line for a solid center and translucent strips. + // Glyph alpha preserves wallpaper/transparency and also fades color emoji. + for band in 0..=FADE_STEPS { + let shaped = shaped.clone(); + let text = title.clone(); + let opacity = if band == 0 { 1.0 } else { 1.0 - (band as f32 - 0.5) / FADE_STEPS as f32 }; + content = content.child( + // Canvas does not apply style opacity; Div applies it to the painted glyphs. + div().absolute().inset_0().opacity(opacity).child( + canvas( + move |_, window, _| { + shaped.get_or_init(|| { + let style = window.text_style(); + window.text_system().shape_line( + text.clone(), + px(font_size), + &[style.to_run(text.len())], + None, + ) + }); + shaped + }, + move |bounds, shaped, window, cx| { + if !bounds.intersects(&window.content_mask().bounds) { + return; + } + let width = f32::from(bounds.size.width); + let fade = FADE_WIDTH.min(width); + let step = fade / FADE_STEPS as f32; + let (left, width) = if band == 0 { + (0.0, width - fade) + } else { + (width - fade + (band - 1) as f32 * step, step) + }; + if width <= 0.0 { + return; + } + let clip = Bounds::new( + point(bounds.left() + px(left), bounds.top()), + size(px(width), bounds.size.height), + ); + window.with_content_mask(Some(ContentMask { bounds: clip }), |window| { + let _ = shaped.get().unwrap().paint( + bounds.origin, + bounds.size.height, + TextAlign::Left, + None, + window, + cx, + ); + }); + }, + ) + .size_full(), + ), + ); + } + label.child(content).into_any_element() +} diff --git a/nebula_app/src/gpui_shell/workspace/top_tabs/layout.rs b/nebula_app/src/gpui_shell/workspace/top_tabs/layout.rs new file mode 100644 index 000000000..5106afa35 --- /dev/null +++ b/nebula_app/src/gpui_shell/workspace/top_tabs/layout.rs @@ -0,0 +1,180 @@ +//! Measured widths shared by rendering, overflow navigation and drag ordering. + +use std::sync::Arc; + +use gpui::{SharedString, Window, px}; + +pub(super) const MIN_WIDTH: f32 = 80.0; +pub(super) const MAX_WIDTH: f32 = 220.0; +pub(super) const GAP: f32 = 4.0; +pub(super) const NUDGE_WIDTH: f32 = 22.0; + +pub(super) fn gap(style: nebula_settings::TabStyle) -> f32 { + if style == nebula_settings::TabStyle::Chrome { -10.0 } else { GAP } +} + +#[derive(Clone, Debug)] +pub(in crate::gpui_shell::workspace) struct TabStripLayout { + slots: Arc<[(f32, f32)]>, + pub width: f32, + pub viewport: f32, + pub left: f32, + pub overflow: bool, + pub gap: f32, +} + +impl TabStripLayout { + pub(super) fn new( + widths: impl IntoIterator, + capacity: f32, + inset: f32, + gap: f32, + ) -> Self { + let mut end = 0.0; + let slots: Vec<_> = widths + .into_iter() + .map(|width| { + let slot = (end, width.clamp(MIN_WIDTH, MAX_WIDTH)); + end += slot.1 + gap; + slot + }) + .collect(); + let width = if slots.is_empty() { 0.0 } else { end - gap }; + let overflow = width > capacity + 0.5; + let viewport = if overflow { (capacity - 2.0 * NUDGE_WIDTH).max(MIN_WIDTH) } else { width }; + Self { + slots: slots.into(), + width, + viewport, + left: inset + if overflow { NUDGE_WIDTH } else { 0.0 }, + overflow, + gap, + } + } + + pub(super) fn tab_width(&self, index: usize) -> f32 { + self.slots[index].1 + } + + pub(in crate::gpui_shell::workspace) fn tab_count(&self) -> usize { + self.slots.len() + } + + fn center(&self, index: usize) -> f32 { + let (left, width) = self.slots[index]; + left + width * 0.5 + } + + pub(in crate::gpui_shell::workspace) fn drag_limits( + &self, + source: usize, + len: usize, + ) -> (f32, f32) { + let center = self.center(source); + ( + -center + self.center(0), + self.center(len.min(self.slots.len()).saturating_sub(1)) - center, + ) + } + + pub(in crate::gpui_shell::workspace) fn drag_target( + &self, + source: usize, + offset: f32, + len: usize, + ) -> usize { + let center = self.center(source) + offset; + (0..len.min(self.slots.len())) + .min_by(|&a, &b| { + (self.center(a) - center).abs().total_cmp(&(self.center(b) - center).abs()) + }) + .unwrap_or(source) + } + + pub(super) fn shift(&self, index: usize, source: usize, target: usize) -> f32 { + if source < target && index > source && index <= target { + -(self.tab_width(source) + self.gap) + } else if source > target && index >= target && index < source { + self.tab_width(source) + self.gap + } else { + 0.0 + } + } + + pub(super) fn nudge(&self, offset: f32, direction: f32) -> f32 { + let current = -offset; + let next = if direction > 0.0 { + self.slots + .iter() + .map(|slot| slot.0) + .find(|&left| left > current + 0.5) + .unwrap_or(self.width) + } else { + self.slots + .iter() + .rev() + .map(|slot| slot.0) + .find(|&left| left < current - 0.5) + .unwrap_or(0.0) + }; + (-next).clamp(-(self.width - self.viewport).max(0.0), 0.0) + } +} + +pub(super) fn text_width( + text: SharedString, + family: SharedString, + size: f32, + window: &Window, +) -> f32 { + let line = window.text_system().shape_line( + text.clone(), + px(size), + &[gpui::TextRun { + len: text.len(), + font: gpui::font(family), + color: Default::default(), + background_color: None, + underline: None, + strikethrough: None, + }], + None, + ); + f32::from(line.width).ceil() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn chrome_overlap_drives_the_same_navigation_and_reorder_geometry() { + let layout = TabStripLayout::new( + [100.0, 220.0, 100.0, 220.0], + 350.0, + 8.0, + gap(nebula_settings::TabStyle::Chrome), + ); + assert_eq!(layout.width, 610.0); + assert_eq!(layout.nudge(0.0, 1.0), -90.0); + assert_eq!(layout.drag_target(0, 160.0, 4), 1); + assert_eq!(layout.shift(1, 0, 2), -90.0); + assert_eq!(layout.drag_limits(0, 4), (0.0, 450.0)); + } + + #[test] + fn mixed_widths_drive_overflow_navigation_and_drag_targets() { + let layout = TabStripLayout::new([88.0, 220.0, 110.0, 900.0], 380.0, 8.0, GAP); + assert!(layout.overflow); + assert_eq!(layout.viewport, 336.0); + assert_eq!(layout.tab_width(3), MAX_WIDTH); + assert_eq!(layout.nudge(0.0, 1.0), -92.0); + assert_eq!(layout.nudge(-92.0, -1.0), 0.0); + assert_eq!(layout.drag_target(0, 220.0, 4), 1); + assert_eq!(layout.drag_target(3, -1000.0, 4), 0); + assert_eq!(layout.shift(1, 0, 2), -92.0); + assert_eq!(layout.shift(1, 3, 0), 224.0); + assert_eq!(layout.nudge(-10000.0, 1.0), -(layout.width - layout.viewport)); + assert!(!TabStripLayout::new([90.0, 110.0], 380.0, 8.0, GAP).overflow); + } +} diff --git a/nebula_app/src/gpui_shell/workspace/top_tabs/style.rs b/nebula_app/src/gpui_shell/workspace/top_tabs/style.rs new file mode 100644 index 000000000..a7f42030e --- /dev/null +++ b/nebula_app/src/gpui_shell/workspace/top_tabs/style.rs @@ -0,0 +1,104 @@ +//! Theme-aware chrome, line and tag treatments for the same tab controls. + +use gpui::{ + App, Div, InteractiveElement as _, IntoElement as _, ParentElement as _, Stateful, Styled as _, + canvas, div, point, px, +}; +use gpui_component::ActiveTheme as _; +use nebula_settings::TabStyle; + +fn one_dark_background(cx: &App) -> Option { + let resolved = crate::gpui_shell::theme::resolved_theme(cx); + (resolved.base_name().prompt_name() == "OneDark" && !resolved.is_custom()) + .then_some(cx.theme().sidebar_accent) +} + +fn one_dark_edge(cx: &App) -> Option { + one_dark_background(cx).map(|background| background.blend(cx.theme().foreground.opacity(0.18))) +} + +pub(super) fn apply( + mut row: Stateful
, + style: TabStyle, + active: bool, + dragged: bool, + hover_group: gpui::SharedString, + cx: &App, +) -> Stateful
{ + let theme = cx.theme(); + match style { + TabStyle::Chrome => { + row = if active { + row.rounded_tl(px(8.0)).rounded_tr(px(8.0)) + } else { + row.rounded(px(8.0)) + }; + if active { + let color = theme.sidebar_accent; + // The lower corners flare into the page, as in a browser tab. + row = row.child( + canvas( + |_, _, _| (), + move |bounds, _, window, _| { + let w = f32::from(bounds.size.width); + let h = f32::from(bounds.size.height); + let at = |x, y| point(bounds.left() + px(x), bounds.top() + px(y)); + let mut path = gpui::PathBuilder::fill(); + path.move_to(at(0.0, h)); + path.curve_to(at(12.0, h - 12.0), at(12.0, h)); + path.line_to(at(12.0, 8.0)); + path.curve_to(at(20.0, 0.0), at(12.0, 0.0)); + path.line_to(at(w - 20.0, 0.0)); + path.curve_to(at(w - 12.0, 8.0), at(w - 12.0, 0.0)); + path.line_to(at(w - 12.0, h - 12.0)); + path.curve_to(at(w, h), at(w - 12.0, h)); + path.close(); + if let Ok(path) = path.build() { + window.paint_path(path, color); + } + }, + ) + .absolute() + .inset_0() + .into_any_element(), + ); + } + }, + TabStyle::Minimal => { + if active { + row = + row.bg(one_dark_background(cx).unwrap_or(theme.list_hover.opacity(0.4))).child( + div() + .absolute() + .top_0() + .left_0() + .right_0() + .h(px(1.5)) + .bg(one_dark_edge(cx).unwrap_or(theme.primary)), + ); + } + }, + TabStyle::Tag => { + let mut surface = div() + .absolute() + .top(px(2.0)) + .right(px(2.0)) + .bottom(px(2.0)) + .left(px(2.0)) + .rounded(px(7.0)); + if active { + let background = one_dark_background(cx).unwrap_or_else(|| { + theme.primary.opacity(if theme.is_dark() { 0.18 } else { 0.12 }) + }); + let edge = one_dark_edge(cx).unwrap_or(theme.primary.opacity(0.6)); + surface = surface.border_1().border_color(edge).bg(background); + } else if dragged { + surface = surface.bg(theme.list_hover); + } else { + surface = surface.group_hover(hover_group, |style| style.bg(theme.list_hover)); + } + row = row.relative().child(surface); + }, + } + row +} diff --git a/nebula_app/src/gpui_shell/workspace/window_titlebar.rs b/nebula_app/src/gpui_shell/workspace/window_titlebar.rs index e866e80ed..9e190efdf 100644 --- a/nebula_app/src/gpui_shell/workspace/window_titlebar.rs +++ b/nebula_app/src/gpui_shell/workspace/window_titlebar.rs @@ -2,8 +2,8 @@ use super::*; use crate::{gpui_shell::widgets::toolbar_button, i18n::Message}; fn title_bar_height(_density: nebula_settings::DensityName) -> f32 { - // Content density must not compress the application chrome. - 48.0 + // Leave 4px around 32px controls; content density only changes the tab rows. + 40.0 } pub(super) fn effective_title_bar_height( @@ -264,10 +264,10 @@ mod density_tests { #[test] fn content_density_preserves_title_bar_height_and_native_layout() { - assert_eq!(title_bar_height(DensityName::Standard), 48.0); - assert_eq!(title_bar_height(DensityName::Compact), 48.0); - assert_eq!(effective_title_bar_height(DensityName::Standard, None), 48.0); - assert_eq!(effective_title_bar_height(DensityName::Compact, None), 48.0); + assert_eq!(title_bar_height(DensityName::Standard), 40.0); + assert_eq!(title_bar_height(DensityName::Compact), 40.0); + assert_eq!(effective_title_bar_height(DensityName::Standard, None), 40.0); + assert_eq!(effective_title_bar_height(DensityName::Compact, None), 40.0); assert_eq!(effective_title_bar_height(DensityName::Compact, Some((34.0, 7.0))), 34.0); assert!(title_bar_height(DensityName::Compact) >= 32.0 + 2.0 * 4.0); } diff --git a/nebula_settings/src/lib.rs b/nebula_settings/src/lib.rs index 3e660bcf3..a5a6959e4 100644 --- a/nebula_settings/src/lib.rs +++ b/nebula_settings/src/lib.rs @@ -26,6 +26,8 @@ pub use background_effects::{BackgroundEffectRequest, BackgroundEffects}; mod terminal_effects; pub use terminal_effects::{EffectAnimation, TerminalEffects}; mod cursor_motion; +mod tab_style; +pub use tab_style::TabStyle; mod custom_theme; pub use cursor_motion::CursorMotion; pub use custom_theme::{ @@ -1037,6 +1039,7 @@ pub struct RuntimeSettings { /// 全宽字形(CJK 等)bold run 用 Regular 字形(粗体提亮不加粗)。 pub cjk_bold_regular: bool, pub tabs_position: TabsPositionName, + pub tab_style: TabStyle, pub tab_reveal: TabRevealName, pub density: DensityName, pub new_tab_position: NewTabPositionName, @@ -1230,6 +1233,7 @@ impl RuntimeSettings { .value("tabs_position") .and_then(TabsPositionName::from_settings) .unwrap_or_default(), + tab_style: raw.value("tab_style").and_then(TabStyle::from_settings).unwrap_or_default(), tab_reveal: raw .value("tab_reveal") .and_then(TabRevealName::from_settings) diff --git a/nebula_settings/src/reset.rs b/nebula_settings/src/reset.rs index a10d558da..c2016ed02 100644 --- a/nebula_settings/src/reset.rs +++ b/nebula_settings/src/reset.rs @@ -38,6 +38,7 @@ const RESET_KEYS: &[&str] = &[ "completion_style", "cjk_bold_regular", "tabs_position", + "tab_style", "tab_reveal", "density", "new_tab_position", @@ -150,6 +151,14 @@ mod tests { use super::*; use crate::{RawSettings, RuntimeSettings}; + #[test] + fn reset_restores_tab_style_and_preserves_unrelated_keys() { + let text = default_settings_text("tab_style=tag\ncustom=keep\n"); + let runtime = RuntimeSettings::from_raw(&RawSettings::from_text(&text)); + assert_eq!(runtime.tab_style, crate::TabStyle::Chrome); + assert!(text.contains("custom=keep")); + } + #[test] fn reset_restores_auto_scrollbar_visibility() { let text = default_settings_text("scrollbar_visibility=always\ncustom=keep\n"); diff --git a/nebula_settings/src/tab_style.rs b/nebula_settings/src/tab_style.rs new file mode 100644 index 000000000..93d2d5c89 --- /dev/null +++ b/nebula_settings/src/tab_style.rs @@ -0,0 +1,47 @@ +//! Appearance of the horizontal workspace tabs; sidebar tabs keep their layout. + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum TabStyle { + #[default] + Chrome, + Minimal, + Tag, +} + +impl TabStyle { + pub const VALUES: &'static [&'static str] = &["chrome", "minimal", "tag"]; + + pub fn from_settings(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "chrome" => Some(Self::Chrome), + "minimal" => Some(Self::Minimal), + "tag" => Some(Self::Tag), + _ => None, + } + } + + pub fn settings_value(self) -> &'static str { + Self::VALUES[self as usize] + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{RawSettings, RuntimeSettings}; + + #[test] + fn styles_round_trip_and_old_or_invalid_preferences_use_chrome() { + for value in TabStyle::VALUES { + let raw = RawSettings::from_text(&format!("tab_style={value}\n")); + assert_eq!(RuntimeSettings::from_raw(&raw).tab_style.settings_value(), *value); + } + for text in ["", "tab_style=unknown\n"] { + assert_eq!( + RuntimeSettings::from_raw(&RawSettings::from_text(text)).tab_style, + TabStyle::Chrome + ); + } + assert_eq!(TabStyle::from_settings(" TAG "), Some(TabStyle::Tag)); + } +}