Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions nebula_app/i18n/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
7 changes: 7 additions & 0 deletions nebula_app/i18n/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,13 @@
"refresh": "新建终端时刷新系统环境变量",
"refresh_description": "打开终端时读取 Windows 当前环境设置。关闭后继承启动 Pebrel 时的环境,包括临时添加的 PATH。已打开的终端不受影响。"
},
"tabs": {
"title": "顶部标签栏样式",
"description": "谷歌、顶部细线或标签外观。标签宽度随名称自动调整,超长名称在末尾渐隐。",
"chrome": "谷歌样式",
"minimal": "简约样式",
"tag": "标签样式"
},
"cursor_motion": {
"title": "平滑光标",
"description": "让终端光标平滑移动,大范围跳转仍立即定位。",
Expand Down
2 changes: 2 additions & 0 deletions nebula_app/src/gpui_shell/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions nebula_app/src/gpui_shell/settings_pane.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
22 changes: 22 additions & 0 deletions nebula_app/src/gpui_shell/settings_pane/initialization.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions nebula_app/src/gpui_shell/settings_pane/localization.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
},
Expand Down
Original file line number Diff line number Diff line change
@@ -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::<Settings>();
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"
);
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
7 changes: 4 additions & 3 deletions nebula_app/src/gpui_shell/terminal/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 网格与首帧布局一致,避免启动
Expand Down Expand Up @@ -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))
Expand Down
14 changes: 14 additions & 0 deletions nebula_app/src/gpui_shell/terminal/view/pointer/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,20 @@ fn clipboard(cx: &mut VisualTestContext) -> Option<String> {
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> ");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Self>) {
Expand Down
Loading
Loading