From ce2eb5316239fa2eb71b564fd47a01f5e1ceebc4 Mon Sep 17 00:00:00 2001 From: YinBuLiao <35324248+YinBuLiao@users.noreply.github.com> Date: Fri, 11 Sep 2026 16:24:03 +0800 Subject: [PATCH 1/3] fix(toast): bound notification height so a long agent message can't cover the UI Codex's turn-complete notification carries the model's `last-assistant-message` (bounded only at 4 000 chars in `ai_hook`) and an OSC 9 body is unbounded. Rendered at a fixed 440 px width that became a card taller than the window: anchored bottom-right it overflowed off the top, taking its hover-only close button out of reach, and covered a split pane for the whole 90 s banner TTL. Notification bodies are now clamped to `TOAST_BODY_MAX_CHARS`; the full text stays in the log and in the terminal's answer reader. The notification layer clips to the window, and plain banners dismiss on click. --- nebula_app/src/gpui_shell/toast.rs | 15 +++++++- nebula_app/src/notify.rs | 56 +++++++++++++++++++++++++++++- 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/nebula_app/src/gpui_shell/toast.rs b/nebula_app/src/gpui_shell/toast.rs index 487f384..ae89218 100644 --- a/nebula_app/src/gpui_shell/toast.rs +++ b/nebula_app/src/gpui_shell/toast.rs @@ -79,6 +79,11 @@ fn is_duplicate(text: &str) -> bool { } fn note(kind: ToastKind, text: String) -> Notification { + // 通知栏是「一眼带过」的层。正文先收短:模型回合完成的正文可能是整段 + // 回答(OSC 9 更是无界),不设上限时按固定宽度排出来比窗口还高,锚在 + // 右下就会整条溢出屏幕、把关闭按钮顶到看不见的地方。完整原文仍在日志 + // 与终端的「阅读 / 原文」里。 + let text = crate::notify::clamp_toast_body(&text); let note = match kind { ToastKind::Info => Notification::info(text), ToastKind::Success => Notification::success(text), @@ -132,6 +137,10 @@ pub fn render_layer(window: &mut Window, cx: &mut App) -> Option { .items_end() .justify_end() .p(px(20.0)) + // 硬保证:任何一条通知都不可能画到窗口之外。正文长度已在 + // [`note`] 收短,这里是兜底——溢出到屏幕外的卡片连关闭按钮都 + // 够不着。 + .overflow_hidden() // 用全视口透明宿主明确右下锚点;宿主不注册命中,只有实际 // Notification 卡片会 occlude/接收鼠标,周围终端仍可正常点击。 .child(v_flex().items_end().gap_2().children(items)) @@ -151,13 +160,17 @@ pub fn toast(window: &mut Window, cx: &mut App, kind: ToastKind, text: impl Into /// 驻留一条消息(消息栏层):停留远长于 toast,但**有上限**,见 /// [`BANNER_TTL`]。用于「值得驻留」的事——没什么值得驻留的请用 [`toast`]。 +/// +/// 这里也挂一个空的 `on_click`:组件库的点击包装会先 `dismiss` 再调回调, +/// 所以整张卡片点一下就消失。驻留消息的关闭按钮只在悬停时出现,没有这条 +/// 路径的话「怎么都关不掉」就是字面事实。 pub fn banner(window: &mut Window, cx: &mut App, kind: ToastKind, text: impl Into) { let text = text.into(); if text.trim().is_empty() { return; } log::warn!("banner [{kind:?}]: {text}"); - push_banner(window, cx, note(kind, text)); + push_banner(window, cx, note(kind, text).on_click(|_, _, _| {})); } pub(crate) fn banner_for_pane( diff --git a/nebula_app/src/notify.rs b/nebula_app/src/notify.rs index 3bd18be..7d25cd1 100644 --- a/nebula_app/src/notify.rs +++ b/nebula_app/src/notify.rs @@ -106,10 +106,38 @@ pub enum Notification { AiTurn { program: String, message: Option, attention: bool }, } +/// Longest notification body any shell may render. +/// +/// A toast/banner is a glance layer, not a reader. Codex's turn-complete +/// notification carries the model's `last-assistant-message` (bounded only at +/// 4 000 chars in `ai_hook`), and an OSC 9 body is unbounded; rendered at a +/// fixed width that becomes a card taller than the window. Anchored to the +/// bottom-right it then overflows off the top, taking its close button out of +/// reach, and sits there for the whole banner TTL while covering whatever is +/// underneath (including a split pane). Only the preview is bounded here — the +/// full text stays in the log and in the terminal's answer reader. +pub(crate) const TOAST_BODY_MAX_CHARS: usize = 160; + +/// Clamp a notification body to [`TOAST_BODY_MAX_CHARS`], ending with `…` when +/// anything was dropped. The ellipsis replaces the last kept character, so the +/// result never exceeds the limit and clamping again is a no-op. +pub(crate) fn clamp_toast_body(body: &str) -> String { + if body.chars().count() <= TOAST_BODY_MAX_CHARS { + return body.to_owned(); + } + let head: String = body.chars().take(TOAST_BODY_MAX_CHARS - 1).collect(); + format!("{head}…") +} + impl Notification { /// Toast title + body. Title names the source ("Pebrel" or the program); - /// body carries the human detail. + /// body carries the human detail, bounded to a glanceable length. pub(crate) fn toast_text(&self) -> (String, String) { + let (title, body) = self.raw_toast_text(); + (title, clamp_toast_body(&body)) + } + + fn raw_toast_text(&self) -> (String, String) { match self { Self::Bell { program } => match program { Some(p) => (p.clone(), "任务完成,等待输入".to_owned()), @@ -303,6 +331,32 @@ mod delivery_tests { ); } + /// 回归锁:一篇很长的模型回答不能变成一张撑破窗口的通知卡。正文只保留 + /// 一眼能读的预览,其余在日志和终端「原文」里。 + #[test] + fn oversized_notification_bodies_are_clamped_to_a_glanceable_preview() { + let long = "回答正文。".repeat(1_000); + let done = Notification::AiTurn { + program: "codex".to_owned(), + message: Some(long.clone()), + attention: false, + }; + let (title, body) = done.toast_text(); + assert_eq!(title, "codex"); + assert!(body.chars().count() <= TOAST_BODY_MAX_CHARS, "正文必须被收短: {}", body.len()); + assert!(body.ends_with('…'), "被截断的正文要以省略号收尾"); + assert!(long.starts_with(body.trim_end_matches('…')), "保留的是原文开头"); + + // OSC 9 原文同样无界,走 Text 分支也要收短。 + let (_, osc) = + Notification::Text { body: long, program: Some("claude".to_owned()) }.toast_text(); + assert!(osc.chars().count() <= TOAST_BODY_MAX_CHARS); + + // 幂等:已经收短过的正文再收一次不会多出第二个省略号。 + let once = clamp_toast_body(&"x".repeat(1_000)); + assert_eq!(clamp_toast_body(&once), once); + } + #[cfg(feature = "gpui-shell")] #[test] fn activation_preserves_exact_pane_and_generic_application_targets() { From 6128e216a57ce7374d8c1b741773e11cda89945e Mon Sep 17 00:00:00 2001 From: YinBuLiao <35324248+YinBuLiao@users.noreply.github.com> Date: Fri, 11 Sep 2026 16:25:15 +0800 Subject: [PATCH 2/3] fix(shell): resolve the real default shell on every surface Settings -> Terminal's Default shell picker and the new-tab shell palette fell back to a hard-coded `powershell` when no `shell=` was saved, so a Mac with no saved value displayed and recommended PowerShell instead of its login shell. All four call sites now go through one authority, `platform::shell::effective_shell_id`: it keeps a configured id and otherwise resolves the host default (`zsh` on macOS). The settings picker's empty-detection fallback also stops hard-coding PowerShell/Git Bash. --- nebula_app/src/gpui_shell/settings_pane.rs | 4 +-- .../settings_pane/initialization.rs | 2 +- .../gpui_shell/settings_pane/shell_picker.rs | 15 +++++---- nebula_app/src/gpui_shell/workspace.rs | 8 ++--- nebula_app/src/platform/shell.rs | 32 +++++++++++++++++++ 5 files changed, 48 insertions(+), 13 deletions(-) diff --git a/nebula_app/src/gpui_shell/settings_pane.rs b/nebula_app/src/gpui_shell/settings_pane.rs index 502f0cf..91f2c68 100644 --- a/nebula_app/src/gpui_shell/settings_pane.rs +++ b/nebula_app/src/gpui_shell/settings_pane.rs @@ -530,7 +530,7 @@ impl SettingsPane { /// 按当前设置值重建下拉候选(导入后新增行即时可见)。 fn refresh_shell_items(&mut self, window: &mut Window, cx: &mut Context) { - let current = self.runtime.shell.clone().unwrap_or_else(|| "powershell".into()); + let current = crate::platform::shell::effective_shell_id(self.runtime.shell.as_deref()); let (items, selected) = shell_select_items( ¤t, window.scale_factor().max(0.5), @@ -544,7 +544,7 @@ impl SettingsPane { /// 把下拉选中项拨回设置里真正生效的 shell。 fn restore_shell_selection(&mut self, window: &mut Window, cx: &mut Context) { - let current = self.runtime.shell.clone().unwrap_or_else(|| "powershell".into()); + let current = crate::platform::shell::effective_shell_id(self.runtime.shell.as_deref()); self.shell_select.update(cx, |state, cx| { state.set_selected_value(¤t, window, cx); }); diff --git a/nebula_app/src/gpui_shell/settings_pane/initialization.rs b/nebula_app/src/gpui_shell/settings_pane/initialization.rs index c17126e..405f25a 100644 --- a/nebula_app/src/gpui_shell/settings_pane/initialization.rs +++ b/nebula_app/src/gpui_shell/settings_pane/initialization.rs @@ -46,7 +46,7 @@ impl SettingsPane { let cursor_current = runtime.cursor_shape.map(|shape| shape.settings_value()).unwrap_or("beam"); - let shell_current = runtime.shell.clone().unwrap_or_else(|| "powershell".into()); + let shell_current = crate::platform::shell::effective_shell_id(runtime.shell.as_deref()); add_select( "language", diff --git a/nebula_app/src/gpui_shell/settings_pane/shell_picker.rs b/nebula_app/src/gpui_shell/settings_pane/shell_picker.rs index 9830cf6..040eb14 100644 --- a/nebula_app/src/gpui_shell/settings_pane/shell_picker.rs +++ b/nebula_app/src/gpui_shell/settings_pane/shell_picker.rs @@ -75,12 +75,15 @@ pub(super) fn shell_select_items( .map(|shell| ShellSelectItem::new(shell.id, shell.name, scale_factor)) .collect(); if items.is_empty() { - // 非 Windows 构建不做安装探测,但历史配置仍支持这两个由 PTY - // 集成层负责启动的稳定 id,设置页不能因此变成空下拉。 - items = vec![ - ShellSelectItem::new("powershell".into(), "PowerShell".into(), scale_factor), - ShellSelectItem::new("bash".into(), "Git Bash".into(), scale_factor), - ]; + // 探测不到任何已安装 shell 时也要给出一行——用宿主默认 id,而不是 + // 写死的 PowerShell/Git Bash:没有安装探测结果的 Unix 机器(例如 + // `$SHELL` 与 `/etc/shells` 都读不到)默认是登录 shell,不是 PS。 + let id = crate::platform::shell::default_shell_id(); + items = vec![ShellSelectItem::new( + id.clone(), + crate::shell_detect::display_name_for_id(&id), + scale_factor, + )]; } // 导入的终端目录:`merge_terminal_profiles` 已把它们并进配置的 profile // 列表,这里让设置页也能直接选为默认 Shell——否则导入完看不见结果。 diff --git a/nebula_app/src/gpui_shell/workspace.rs b/nebula_app/src/gpui_shell/workspace.rs index 13710fd..c472ef6 100644 --- a/nebula_app/src/gpui_shell/workspace.rs +++ b/nebula_app/src/gpui_shell/workspace.rs @@ -3194,10 +3194,10 @@ impl NebulaWorkspace { /// 三点 / Ctrl+K:旧壳 `NewTabMenu` → `open_shell_menu` → `PaletteMode::Profiles`。 fn open_shell_palette(&mut self, window: &mut Window, cx: &mut Context) { self.command_manager_open = false; - let default_shell_id = cx - .try_global::() - .and_then(|settings| settings.shell_id.clone()) - .unwrap_or_else(|| "powershell".to_owned()); + let default_shell_id = crate::platform::shell::effective_shell_id( + cx.try_global::() + .and_then(|settings| settings.shell_id.as_deref()), + ); let language = workspace_ui_language(); let rows = shell_palette_rows( crate::shell_detect::detect_shells(), diff --git a/nebula_app/src/platform/shell.rs b/nebula_app/src/platform/shell.rs index b9af1b8..565fa57 100644 --- a/nebula_app/src/platform/shell.rs +++ b/nebula_app/src/platform/shell.rs @@ -26,6 +26,21 @@ pub fn default_shell_id() -> String { } } +/// The shell id a picker or label should treat as current: the configured +/// value when it names anything, otherwise the host's own default. An empty or +/// whitespace-only value is "unset", not a shell named `""`. +/// +/// Every GPUI surface that shows a "default shell" must go through this — +/// falling back to a hard-coded `powershell` made a Mac with no saved `shell=` +/// display and recommend PowerShell instead of its login shell. +pub fn effective_shell_id(configured: Option<&str>) -> String { + configured + .map(str::trim) + .filter(|id| !id.is_empty()) + .map(str::to_owned) + .unwrap_or_else(default_shell_id) +} + pub fn interactive_args(id: &str) -> Vec { if cfg!(target_os = "macos") && matches!(id, "zsh" | "bash" | "fish") { vec!["-l".to_owned()] @@ -84,6 +99,23 @@ mod tests { assert!(!default_shell_id().is_empty()); } + /// 未保存 `shell=` 时必须落到宿主默认,而不是某个硬编码 id。回归锁: + /// 设置页「默认 Shell」和新终端选择弹窗此前都硬编码回落到 `powershell`, + /// 于是没存过 `shell=` 的 Mac 上显示并推荐的是 PowerShell 而不是登录 shell。 + #[test] + fn effective_shell_id_falls_back_to_the_host_default() { + let host = default_shell_id(); + for configured in [None, Some(""), Some(" ")] { + assert_eq!( + effective_shell_id(configured), + host, + "未设置的值必须解析成宿主默认,不能是硬编码 id" + ); + } + assert_eq!(effective_shell_id(Some(" bash ")), "bash"); + assert_eq!(effective_shell_id(Some("pwsh")), "pwsh"); + } + #[test] fn bash_bootstrap_matches_the_host_contract() { assert_eq!(uses_legacy_pty_bootstrap("bash"), cfg!(windows)); From 846018010a4809633a5a6a44727b2c1564f7b90e Mon Sep 17 00:00:00 2001 From: YinBuLiao <35324248+YinBuLiao@users.noreply.github.com> Date: Fri, 11 Sep 2026 16:25:54 +0800 Subject: [PATCH 3/3] fix(shell): one same-sized marker per shell, and space the picker rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The settings picker mixed a brand PNG (24 px), a `dev-terminal` glyph (~0.7 em) and a `cod-terminal` glyph (~0.9 em), so icons rendered at three different sizes; the new-tab palette passed no glyph at all, leaving zsh, csh, ksh, sh, dash and tcsh without any icon. Both surfaces now fall back to the shared id-keyed glyph from `shell_detect::icon_for_id`, all at `FALLBACK_ICON_SCALE` so the glyph ink matches the brand artwork's 12% safe margin (roughly 0.77 of the slot). The picker also reserves one fixed icon slot per row, which removes the row overlap, and spaces rows by exactly `SHELL_ROW_GAP` — the component's virtual list has no item `gap_y`, so the measured first row is padded to act as the slot height. A selected row no longer touches the row above it. --- .../gpui_shell/settings_pane/shell_picker.rs | 53 ++++++++++++++-- .../src/gpui_shell/settings_pane/tests.rs | 62 +++++++++++++++++++ nebula_app/src/gpui_shell/workspace.rs | 31 +++++++--- .../src/gpui_shell/workspace/palette.rs | 41 ++++++++++-- nebula_app/src/gpui_shell/workspace/tests.rs | 44 +++++++++++++ nebula_app/src/shell_detect.rs | 32 +++++++++- 6 files changed, 242 insertions(+), 21 deletions(-) diff --git a/nebula_app/src/gpui_shell/settings_pane/shell_picker.rs b/nebula_app/src/gpui_shell/settings_pane/shell_picker.rs index 9830cf6..ea51e48 100644 --- a/nebula_app/src/gpui_shell/settings_pane/shell_picker.rs +++ b/nebula_app/src/gpui_shell/settings_pane/shell_picker.rs @@ -15,6 +15,21 @@ pub(super) struct ShellSelectItem { /// `wsl:`、`profile:<家族>|`),双下划线包裹不可能与之相撞。 pub(super) const SHELL_IMPORT_ACTION_ID: &str = "__nebula_import_terminal_dir__"; +/// 菜单行的逻辑边长(品牌贴图与图标槽同用)。 +pub(super) const SHELL_ROW_ICON_SIZE: f32 = 24.0; + +/// 回落字形的字号系数:见 `shell_detect::FALLBACK_ICON_SCALE`。 +const FALLBACK_ICON_SCALE: f32 = crate::shell_detect::FALLBACK_ICON_SCALE; + +/// 真实 shell 行之间的垂直间距。 +/// +/// `gpui-component` 的虚拟列表不支持条目 `gap_y`(`list.rs` 里明确写了), +/// 每行背景按同一槽高首尾相接。可用的唯一着力点:它只量**首行**的槽高再拿 +/// 这个高度排所有行。给置顶的「导入终端目录」动作行加一段下内边距,槽高就 +/// 比普通行多出这一段,余下每一行之间便空出同样的间隔;动作行自身也在文字 +/// 下方留出这段空白,选中高亮不再贴着它。 +pub(super) const SHELL_ROW_GAP: f32 = 4.0; + impl ShellSelectItem { pub(super) fn new(id: String, name: String, scale_factor: f32) -> Self { // Select 的闭态和菜单行尺寸不同。分别生成与物理像素一一对应的纹理, @@ -47,15 +62,36 @@ impl ShellSelectItem { .into_any_element() } else if self.is_import_action() { // 动作行与真实 shell 行必须一眼分得开:文件夹口 = 「去别处拿」。 - Icon::new(IconName::FolderOpen).xsmall().into_any_element() + Icon::new(IconName::FolderOpen).size(px(size * FALLBACK_ICON_SCALE)).into_any_element() } else { - Icon::new(IconName::SquareTerminal).xsmall().into_any_element() + // 没有品牌贴图的 shell 沿用旧壳那张按 id 取字的 Nerd Font 表 + // (`icon_for_id`,设置行/命令面板同一口径)。字号按回落字形的 + // 墨迹比例配平:品牌 PNG 的可见部分是自己边长的约 0.77(12% + // 安全边距),codicon terminal 的墨迹约 0.88 em——乘 0.88 之后 + // 两种图标才真的同尺寸。 + div() + .font_family(crate::font_install::REQUIRED_FONT_FAMILY) + .text_size(px(size * FALLBACK_ICON_SCALE)) + .child(crate::shell_detect::icon_for_id(&self.id)) + .into_any_element() }; h_flex() .gap_2() .items_center() - .child(icon) - .child(div().flex_1().min_w_0().child(self.name.clone())) + // 图标槽固定成同一个边长,绝不随图标种类伸缩:gpui-component 的 + // `List` 只量首行(这里是「导入终端目录」动作行)的高度,再拿它 + // 排所有行;品牌 PNG 有 24px、回落图标只有 xsmall,混排时高行会 + // 溢出自己的槽位,压住上下相邻行(选中高亮错位就是这么来的)。 + .child( + div() + .size(px(size)) + .flex_shrink_0() + .flex() + .items_center() + .justify_center() + .child(icon), + ) + .child(div().flex_1().min_w_0().truncate().child(self.name.clone())) .into_any_element() } } @@ -171,7 +207,14 @@ impl SelectItem for ShellSelectItem { fn render(&self, _: &mut Window, _: &mut App) -> impl IntoElement { // 旧壳 ShellPickerRow 的品牌图标是 24×24 逻辑像素。 - self.view(24.0, self.row_image.as_ref()) + let row = div().child(self.view(SHELL_ROW_ICON_SIZE, self.row_image.as_ref())); + if self.is_import_action() { + // 见 SHELL_ROW_GAP:首行独自垫高,成为所有行的槽高,才谈得上 + // 「行与行之间有间隔」。 + row.pb(px(SHELL_ROW_GAP)) + } else { + row + } } fn value(&self) -> &Self::Value { diff --git a/nebula_app/src/gpui_shell/settings_pane/tests.rs b/nebula_app/src/gpui_shell/settings_pane/tests.rs index 26895c5..f838c5f 100644 --- a/nebula_app/src/gpui_shell/settings_pane/tests.rs +++ b/nebula_app/src/gpui_shell/settings_pane/tests.rs @@ -97,3 +97,65 @@ fn cached_semantic_statuses_render_in_the_current_language() { assert_eq!(ssh.text(crate::display::UiLanguage::ZhCn), "正在打开 server.example…"); assert_eq!(ssh.text(crate::display::UiLanguage::EnUs), "Opening server.example…"); } + +/// 回归锁:默认 Shell 下拉里,真实 shell 行共用同一个图标槽高度,只有置顶的 +/// 「导入终端目录」动作行比它们多出一个行距。 +/// +/// `gpui-component` 的 `List` 只量**首行**的槽高,再拿这个高度排所有行,而它 +/// 的虚拟列表又不支持条目 `gap_y`。所以两个不变量要一起锁住:品牌 PNG 行和 +/// 字形回落行必须等高(否则高行溢出槽位、压住相邻行),动作行必须恰好高出 +/// 一个 `SHELL_ROW_GAP`(否则行与行之间没有间隔)。 +#[cfg(feature = "gpui-test-support")] +mod shell_row_geometry { + use super::*; + use gpui::TestAppContext; + + struct ShellRowProbe { + rows: Vec<(&'static str, ShellSelectItem)>, + } + + impl Render for ShellRowProbe { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + // 把继承字号压到图标槽以下,让行高只由图标槽决定。这样断言测的 + // 就是「行高与图标种类无关」这条不变量本身,而不是某套字体度量。 + // 走真实的 `SelectItem::render`,连带把动作行的行距一起量进去。 + v_flex().w(px(240.0)).text_size(px(8.0)).children(self.rows.iter().map( + |(selector, item)| { + div() + .debug_selector(move || (*selector).to_owned()) + .child(item.render(window, cx)) + }, + )) + } + } + + #[gpui::test] + fn every_row_shares_one_icon_slot_height(cx: &mut TestAppContext) { + cx.update(gpui_component::init); + let rows = vec![ + ( + "shell-probe-import", + ShellSelectItem::import_action(crate::display::UiLanguage::ZhCn), + ), + ( + "shell-probe-brand", + ShellSelectItem::new("pwsh".to_owned(), "PowerShell 7".to_owned(), 1.0), + ), + ("shell-probe-glyph", ShellSelectItem::new("zsh".to_owned(), "Zsh".to_owned(), 1.0)), + ]; + let (_, cx) = cx.add_window_view(|_, _| ShellRowProbe { rows }); + let heights: Vec = ["shell-probe-import", "shell-probe-brand", "shell-probe-glyph"] + .iter() + .map(|selector| { + f32::from(cx.debug_bounds(selector).expect("shell row bounds").size.height) + }) + .collect(); + assert_eq!(heights[1], heights[2], "品牌图标行与字形回落行必须等高: {heights:?}"); + assert_eq!(heights[1], SHELL_ROW_ICON_SIZE, "普通行的图标槽即整行高度: {heights:?}"); + assert_eq!( + heights[0], + heights[1] + SHELL_ROW_GAP, + "动作行必须恰好比普通行多一个行距,这个差值就是所有行的间距: {heights:?}" + ); + } +} diff --git a/nebula_app/src/gpui_shell/workspace.rs b/nebula_app/src/gpui_shell/workspace.rs index 13710fd..ea978fb 100644 --- a/nebula_app/src/gpui_shell/workspace.rs +++ b/nebula_app/src/gpui_shell/workspace.rs @@ -811,6 +811,17 @@ fn settings_should_fold_sidebar(tabs_position: nebula_settings::TabsPositionName tabs_position == nebula_settings::TabsPositionName::Sidebar } +/// shell 行的回落字形:没有品牌贴图时用 `shell_detect::icon_for_id` 的 +/// id-keyed Nerd Font 字形(与设置页下拉、命令面板同一口径)。 +/// +/// `has_brand` 为真时返回 `None`——贴图已经画了,两个都留同行会出现两个图标。 +fn fallback_shell_glyph(id: &str, has_brand: bool) -> Option { + if has_brand { + return None; + } + crate::shell_detect::icon_for_id(id).chars().next() +} + /// 新建终端弹窗的行:已检测 shell + SSH 主机,分组对照旧壳 /// `CommandPalette::open_profiles`(推荐 / 所有 Shell / SSH 主机)。 /// 三点菜单与 Ctrl+K 打开的是这份列表,不是通用命令面板。 @@ -830,6 +841,14 @@ fn shell_palette_rows( .into_iter() .map(|shell| { let is_default = shell.id == default_shell_id; + let icon = crate::gpui_shell::widgets::shell_brand_image( + &shell.id, + SHELL_ICON_PX, + scale_factor, + ); + // 没有品牌贴图的 shell(zsh、csh、ksh…)不能就这么空着:回落到 + // 按 id 取字的 Nerd Font 字形,与设置页下拉同一口径。 + let icon_glyph = fallback_shell_glyph(&shell.id, icon.is_some()); WorkspacePaletteRow { group_order: if is_default { 0 } else { 1 }, group: if is_default { recommended.to_owned() } else { all_shells.to_owned() }, @@ -837,12 +856,8 @@ fn shell_palette_rows( hint: shell.program.clone(), hint_style: WorkspacePaletteHintStyle::Metadata, search: format!("{} {} shell profile", shell.name, shell.id).to_lowercase(), - icon: crate::gpui_shell::widgets::shell_brand_image( - &shell.id, - SHELL_ICON_PX, - scale_factor, - ), - icon_glyph: None, + icon, + icon_glyph, icon_path: None, action: WorkspacePaletteAction::LaunchShell(shell), } @@ -854,6 +869,8 @@ fn shell_palette_rows( let icon_id = profile.shell_id.as_deref().unwrap_or(&id); let icon = crate::gpui_shell::widgets::shell_brand_image(icon_id, SHELL_ICON_PX, scale_factor); + // 借用在 `profile` 被移进 action 之前结束。 + let icon_glyph = fallback_shell_glyph(icon_id, icon.is_some()); let label = profile.name.clone(); let hint = profile.command.clone(); Some(WorkspacePaletteRow { @@ -865,8 +882,8 @@ fn shell_palette_rows( hint, hint_style: WorkspacePaletteHintStyle::Metadata, action: WorkspacePaletteAction::LaunchProfile(profile), + icon_glyph, icon, - icon_glyph: None, icon_path: None, }) })); diff --git a/nebula_app/src/gpui_shell/workspace/palette.rs b/nebula_app/src/gpui_shell/workspace/palette.rs index 80d1109..f10a63f 100644 --- a/nebula_app/src/gpui_shell/workspace/palette.rs +++ b/nebula_app/src/gpui_shell/workspace/palette.rs @@ -1,5 +1,19 @@ use super::*; +/// 命令面板 / Shell 选择弹窗里图标轨的逻辑边长。品牌贴图、Nerd Font 字形 +/// 与组件库线性图标都排进同一个槽,行与行之间才不会有的缩进有的不缩进。 +const PALETTE_ICON_PX: f32 = 22.0; + +/// 非 shell 行(SSH 主机等)的字形字号。shell 行另有配平后的字号,见 +/// `shell_glyph_size`。 +const PALETTE_GLYPH_PX: f32 = 16.0; + +/// shell 行字形的字号:与品牌贴图按同一视觉尺寸配平(`shell_detect` 的 +/// 安全边距/墨迹比例是这条换算的唯一出处)。 +fn shell_glyph_size() -> f32 { + PALETTE_ICON_PX * crate::shell_detect::FALLBACK_ICON_SCALE +} + impl NebulaWorkspace { pub(super) fn render_command_palette(&mut self, cx: &mut Context) -> gpui::AnyElement { use crate::display::ui::tokens::{control, radius, space}; @@ -103,20 +117,31 @@ impl NebulaWorkspace { let icon_content = if let Some(image) = item.icon.clone() { Some( gpui::StyledImage::object_fit( - img(image).size(px(22.0)), + img(image).size(px(PALETTE_ICON_PX)), gpui::ObjectFit::Contain, ) .into_any_element(), ) } else if let Some(glyph) = item.icon_glyph { + // shell 行没有品牌贴图时画的是同一个 id-keyed 字形,字号要 + // 跟品牌贴图配平;SSH 主机等行保持原来的字号。 + let glyph_px = if matches!( + &item.action, + WorkspacePaletteAction::LaunchShell(_) + | WorkspacePaletteAction::LaunchProfile(_) + ) { + shell_glyph_size() + } else { + PALETTE_GLYPH_PX + }; Some( div() - .size(px(22.0)) + .size(px(PALETTE_ICON_PX)) .flex() .items_center() .justify_center() .font_family(crate::font_install::REQUIRED_FONT_FAMILY) - .text_size(px(16.0)) + .text_size(px(glyph_px)) .text_color(foreground) .child(glyph.to_string()) .into_any_element(), @@ -124,7 +149,7 @@ impl NebulaWorkspace { } else { item.icon_path.clone().map(|path| { div() - .size(px(22.0)) + .size(px(PALETTE_ICON_PX)) .flex() .items_center() .justify_center() @@ -133,8 +158,12 @@ impl NebulaWorkspace { }) }; let icon_slot = has_icon_rail.then(|| { - let slot = - div().size(px(22.0)).flex_shrink_0().flex().items_center().justify_center(); + let slot = div() + .size(px(PALETTE_ICON_PX)) + .flex_shrink_0() + .flex() + .items_center() + .justify_center(); match icon_content { Some(icon) => slot.child(icon).into_any_element(), None => slot.into_any_element(), diff --git a/nebula_app/src/gpui_shell/workspace/tests.rs b/nebula_app/src/gpui_shell/workspace/tests.rs index 0551b03..a1156a0 100644 --- a/nebula_app/src/gpui_shell/workspace/tests.rs +++ b/nebula_app/src/gpui_shell/workspace/tests.rs @@ -581,6 +581,50 @@ fn shell_palette_puts_the_default_shell_first() { assert_eq!(rows[0].group, "所有 Shell"); } +/// 回归锁:新建终端弹窗里,没有品牌贴图的 shell 不能空着——必须回落到按 id +/// 取字的 Nerd Font 字形。之前这一列只填了 `icon`,于是 zsh、csh、ksh 这些 +/// 整行左边什么都没有。 +#[test] +fn shell_palette_falls_back_to_an_id_glyph_when_brand_art_is_absent() { + let shells = ["zsh", "csh", "ksh", "bash", "pwsh"] + .into_iter() + .map(|id| crate::shell_detect::DetectedShell { + name: crate::shell_detect::display_name_for_id(id), + id: id.to_owned(), + program: format!("/bin/{id}"), + args: Vec::new(), + }) + .collect::>(); + let rows = shell_palette_rows( + shells, + Vec::new(), + None::, + "zsh", + crate::display::UiLanguage::ZhCn, + 1.0, + ); + let mut brandless = 0; + for row in &rows { + let WorkspacePaletteAction::LaunchShell(shell) = &row.action else { continue }; + assert_eq!( + row.icon.is_none(), + row.icon_glyph.is_some(), + "`{}` 必须恰好有一种图标来源(品牌贴图或回落字形)", + shell.id + ); + if row.icon.is_none() { + brandless += 1; + assert_eq!( + row.icon_glyph, + crate::shell_detect::icon_for_id(&shell.id).chars().next(), + "`{}` 的回落字形要与共享图标口径一致", + shell.id + ); + } + } + assert!(brandless >= 3, "zsh/csh/ksh 都没有品牌贴图,都要有字形: {brandless}"); +} + #[test] fn shell_palette_includes_imported_terminal_profiles() { let profile = crate::config::ui_config::Profile { diff --git a/nebula_app/src/shell_detect.rs b/nebula_app/src/shell_detect.rs index e8ba914..76d0b14 100644 --- a/nebula_app/src/shell_detect.rs +++ b/nebula_app/src/shell_detect.rs @@ -94,6 +94,12 @@ impl DetectedShell { /// Nerd Font glyph for a shell id — shared by detected shells and the settings /// row so a saved `shell=` always draws the same mark. WSL distro ids carry /// a `wsl:` prefix; everything else matches whole. +/// +/// Shells without brand artwork (zsh, sh, dash, csh, ksh, tcsh…) all share the +/// one `cod-terminal` codepoint. Different Nerd Font glyphs carry very +/// different ink for the same em (dev-terminal is ~0.7 em, codicon ~0.9), so +/// mixing them in one dropdown renders at visibly different sizes; a single +/// codepoint is what makes "every icon the same size" true by construction. pub fn icon_for_id(id: &str) -> &'static str { let id = profile_shell_id(id).unwrap_or(id).to_ascii_lowercase(); if id.starts_with("wsl") { @@ -102,12 +108,17 @@ pub fn icon_for_id(id: &str) -> &'static str { match id.as_str() { "pwsh" | "powershell" => "\u{ebc7}", // codicon terminal-powershell "cmd" => "\u{ebc4}", // codicon terminal-cmd - "bash" | "git-bash" | "gitbash" | "zsh" | "sh" | "dash" => "\u{e795}", // devicon bash/terminal - "fish" | "nu" => "\u{f489}", // generic terminal glyph - _ => "\u{ea85}", // codicon terminal (fallback) + _ => "\u{ea85}", // codicon terminal (every other shell) } } +/// 回落字形相对图标槽边长的字号系数。 +/// +/// 品牌 PNG 是从矢量图按 12% 安全边距栅格化的,可见部分约为自身边长的 +/// 0.77;`icon_for_id` 返回的 codicon terminal 墨迹约 0.88 em。两者乘上这 +/// 个系数之后才在视觉上是同一个尺寸——不乘就会「有的图标大、有的小」。 +pub const FALLBACK_ICON_SCALE: f32 = 0.88; + /// Full-color brand icon (embedded PNG, rasterized from vector artwork with /// a 12% safe margin; see THIRD-PARTY-NOTICES) for a shell id — the terminal picker draws this textured /// quad instead of the flat Nerd Font glyph. WSL distro ids map by distro @@ -1060,6 +1071,21 @@ mod tests { assert!(color_icon_png(profile_key).is_some()); } + /// 回归锁:没有品牌贴图的 shell 必须共用同一个字形码位。下拉把它们排在同 + /// 一槽位、同一字号;混用不同 Nerd 码位时墨迹大小差很多(dev-terminal + /// 约 0.7 em,codicon 约 0.9),看起来就是「有的图标大、有的小」。 + #[test] + fn fallback_shell_marks_share_one_glyph_so_they_render_at_one_size() { + let expected = icon_for_id("zsh"); + for id in ["zsh", "sh", "dash", "csh", "ksh", "tcsh", "fish", "nu", "unknown-shell"] { + assert_eq!(icon_for_id(id), expected, "`{id}` 的回落字形必须与其它 shell 一致"); + } + // 有品牌贴图的仍然保留各自的字形:贴图取不到时(导入的 profile 家族) + // 也不该和 POSIX shell 混成同一个印记。 + assert_ne!(icon_for_id("pwsh"), expected); + assert_ne!(icon_for_id("cmd"), expected); + } + #[cfg(windows)] #[test] fn windows_detection_finds_the_builtins() {