From aeadf4fb61bc77cabf0474ec14529cd09a1b34b4 Mon Sep 17 00:00:00 2001 From: kuddev Date: Tue, 14 Jul 2026 19:30:15 +0800 Subject: [PATCH] checkpoint: stabilize terminal UX before motion refactor --- TASKS.md | 4 +- nebula_app/res/ui.f.glsl | 12 ++ nebula_app/src/config/color.rs | 12 +- nebula_app/src/display/chrome.rs | 10 +- nebula_app/src/display/command_palette.rs | 141 +++++++------ nebula_app/src/display/content.rs | 174 +++++++++++++--- nebula_app/src/display/context_menu.rs | 64 ++++-- nebula_app/src/display/cursor.rs | 44 +++-- nebula_app/src/display/design_tokens.rs | 117 +++++++++++ nebula_app/src/display/mod.rs | 197 +++++++++++-------- nebula_app/src/display/ssh_ui.rs | 73 +++++++ nebula_app/src/event.rs | 4 +- nebula_app/src/input/keyboard.rs | 44 +---- nebula_app/src/input/mod.rs | 4 - nebula_app/src/renderer/text/builtin_font.rs | 11 +- nebula_app/src/renderer/ui.rs | 53 ++++- nebula_app/src/session.rs | 50 ++++- nebula_app/src/window_context.rs | 141 ++++++++++++- 18 files changed, 879 insertions(+), 276 deletions(-) create mode 100644 nebula_app/src/display/design_tokens.rs create mode 100644 nebula_app/src/display/ssh_ui.rs diff --git a/TASKS.md b/TASKS.md index add7777..10c6997 100644 --- a/TASKS.md +++ b/TASKS.md @@ -9,7 +9,9 @@ - [x] 保存并恢复 Tab 自定义颜色及必要的会话元数据。 - [x] 自绘输入框统一支持 Ctrl+A / Ctrl+C / Ctrl+V;全选后输入/粘贴替换,选择有可见背景;隐藏密码仅允许粘贴,显示后才允许复制。 - [x] 侧栏按 `D:/下载/gemini-code-1784017517204.html` 收敛:TABS / SSH HOSTS 使用真实小字号层级,两个加号仅标题 hover 时显现,Tabs 下拉改为竖向三点。 -- [x] `cargo check -p nebula` 与 `cargo test -p nebula` 通过(173 passed,0 failed;2026-07-14)。 +- [x] `cargo check -p nebula` 与 `cargo test -p nebula` 通过(174 passed,0 failed;2026-07-14)。 +- [x] 建立 `display/design_tokens.rs`(4px 空间阶梯、字号、控件尺寸、动画和统一状态枚举),菜单动画与侧栏层级已开始实际引用;SSH UI 状态类型已从 `display/mod.rs` 拆至 `display/ssh_ui.rs`。 +- [ ] 继续把 `display/mod.rs` 中 SSH 方法/绘制、确认弹窗和其他大块职责拆到子模块,并逐屏迁移散落的局部数值到 design tokens。 - [ ] 完成 Debug 运行检查和真实窗口交互验收(hover-only 控件、菜单脱焦、删除/撤销/恢复、输入框快捷键和 DPI 字体观感)。 - [ ] 验收完成后重打 0.3.0 Release,并替换现有发布压缩包。 diff --git a/nebula_app/res/ui.f.glsl b/nebula_app/res/ui.f.glsl index f8fa63d..7aeff31 100644 --- a/nebula_app/res/ui.f.glsl +++ b/nebula_app/res/ui.f.glsl @@ -56,6 +56,18 @@ void main() { float_t dc = length(p) / max(maxr, 1.0); coverage = clamp(1.0 - dc, 0.0, 1.0); coverage = coverage * coverage; + } else if (quadFeather < -0.5) { + // Rounded-rectangle outer shadow. The quad is expanded by `blur` on + // every edge; evaluate the original inset card and fade only through + // the expansion band. The opaque interior is covered by the card drawn + // immediately afterwards. + float_t blur = -quadFeather; + vec2 innerHalf = max(halfSize - vec2(blur), vec2(1.0)); + float_t lim = min(innerHalf.x, innerHalf.y); + vec4 radii = min(corners, vec4(lim)); + float_t d = sdRoundedBox(p, innerHalf, radii); + float_t edge = clamp(max(d, 0.0) / blur, 0.0, 1.0); + coverage = 1.0 - smoothstep(0.0, 1.0, edge); } else if (quadRadius < 0.0) { // Flat fill: the shape comes from the geometry (e.g. slanted powerline // parallelograms), so cover every fragment. diff --git a/nebula_app/src/config/color.rs b/nebula_app/src/config/color.rs index 2028fa3..fdd7788 100644 --- a/nebula_app/src/config/color.rs +++ b/nebula_app/src/config/color.rs @@ -5,6 +5,13 @@ use nebula_config_derive::ConfigDeserialize; use crate::display::color::{CellRgb, Rgb}; +/// Sentinel used by the theme-following cursor path. A user/OSC override that +/// differs from this value keeps its explicit color and full opacity. +pub(crate) const NEBULA_DEFAULT_CURSOR: InvertedCellColors = InvertedCellColors { + foreground: CellRgb::CellBackground, + background: CellRgb::Rgb(Rgb::new(0x49, 0x4d, 0x72)), +}; + #[derive(ConfigDeserialize, Serialize, Clone, Debug, PartialEq, Eq)] pub struct Colors { pub primary: PrimaryColors, @@ -28,10 +35,7 @@ impl Default for Colors { // 只定制默认光标,不改 InvertedCellColors::default():该类型也被 // selection 复用,直接改会把选区颜色一起染偏。用户显式配置的 // [colors.cursor] 会继续覆盖这里的默认值。 - let cursor = InvertedCellColors { - foreground: CellRgb::CellBackground, - background: CellRgb::Rgb(Rgb::new(0x49, 0x4d, 0x72)), - }; + let cursor = NEBULA_DEFAULT_CURSOR; Self { primary: Default::default(), diff --git a/nebula_app/src/display/chrome.rs b/nebula_app/src/display/chrome.rs index 87ac290..e6713fd 100644 --- a/nebula_app/src/display/chrome.rs +++ b/nebula_app/src/display/chrome.rs @@ -1229,7 +1229,7 @@ pub(super) fn draw_chrome(d: &mut Display) { // inside the header band to keep clearance from the join. let (pnl_x, pnl_y, _, _) = tab_layout.panel; let tabs_chevron = if d.nebula_tabs_section_open { "\u{eab4}" } else { "\u{eab6}" }; - const SECTION_TITLE_SCALE: f32 = 0.75; + const SECTION_TITLE_SCALE: f32 = design_tokens::type_scale::SECTION_CAPTION; let section_title_flags = nebula_terminal::term::cell::Flags::BOLD; d.renderer.draw_doc_text( &size, @@ -1455,13 +1455,13 @@ pub(super) fn draw_chrome(d: &mut Display) { // destination here once the connection confirms (`~/.ssh/config` // aliases still appear automatically too). Styled as helper text, // NOT as content: smaller and fainter than the caption above it, - // and indented to the row-label depth (`tab_pad + s(14)`, where + // and indented to the row-label depth (`tab_pad + s(14)`, where // tab/host row text starts) so it reads as a child of the section. // The hint wraps to the sidebar width; drawing it as one line // would bleed across the seam onto the terminal grid. if d.nebula_ssh_hosts.is_empty() && d.nebula_hosts_section_open { use unicode_width::UnicodeWidthChar; - const HINT_SCALE: f32 = 0.80; + const HINT_SCALE: f32 = design_tokens::type_scale::SUPPORTING; let hint_flags = nebula_terminal::term::cell::Flags::empty(); let (pnl_x, _, pnl_w, _) = tab_layout.panel; let text_x = hh_x + s(28.0); @@ -1482,7 +1482,7 @@ pub(super) fn draw_chrome(d: &mut Display) { text_x, line_y, HINT_SCALE, - sk.ink_faint, + sk.ink_dim, hint_flags, line.trim_start(), &mut d.glyph_cache, @@ -1500,7 +1500,7 @@ pub(super) fn draw_chrome(d: &mut Display) { text_x, line_y, HINT_SCALE, - sk.ink_faint, + sk.ink_dim, hint_flags, line.trim_start(), &mut d.glyph_cache, diff --git a/nebula_app/src/display/command_palette.rs b/nebula_app/src/display/command_palette.rs index 407224b..cb1bb4d 100644 --- a/nebula_app/src/display/command_palette.rs +++ b/nebula_app/src/display/command_palette.rs @@ -315,14 +315,14 @@ impl CommandPalette { self.open } - /// Whether the palette is in default-shell picking mode (WT-style selector: - /// no search box, list from top, shell icons emphasized). + /// Whether the palette is in default-shell picking mode. pub fn is_picking_default(&self) -> bool { self.picking_default } - /// Lightweight shell/profile selector mode. Unlike the full command - /// palette it has no search field and should release focus naturally. + /// Shell/profile selector mode. This identity is used only for natural + /// dismissal on window focus loss; the selector remains searchable because + /// a long shell/profile list otherwise becomes needlessly hard to scan. pub fn is_picker(&self) -> bool { self.open && self.profiles_only } @@ -684,28 +684,24 @@ pub struct PaletteLayout { /// Compute the centered popup layout for a window of `win_w` × `win_h`. The /// panel height is fixed (sized for `max_rows`) so it doesn't jump as the match -/// count changes while typing. Pass `with_input=false` for the WT-style -/// shell-picker mode: no search box, list starts at the top. -pub fn palette_layout(win_w: f32, win_h: f32, scale: f32, with_input: bool) -> PaletteLayout { +/// count changes while typing. Every palette mode uses the same search-input +/// geometry, keeping rendering, hover and click hit-testing on one contract. +pub fn palette_layout(win_w: f32, win_h: f32, scale: f32) -> PaletteLayout { let s = |v: f32| v * scale; let margin = s(8.0); let pad = s(12.0); - let input_h = s(50.0); - let row_h = s(38.0); + let row_h = s(super::design_tokens::control::COMPACT_ROW); + // 搜索框与结果行等高:输入仍然可发现,但不会压过真正的数据内容。 + let input_h = row_h; let max_rows = 8usize; let pw = s(640.0).min(win_w - 2.0 * margin); - let ph = if with_input { - pad + input_h + s(8.0) + max_rows as f32 * row_h + pad - } else { - // No search box: list fills the whole panel - pad + max_rows as f32 * row_h + pad - }; + let ph = pad + input_h + s(8.0) + max_rows as f32 * row_h + pad; let px = ((win_w - pw) * 0.5).max(margin); let py = ((win_h - ph) * 0.5).max(s(48.0)); let input = (px + pad, py + pad, pw - 2.0 * pad, input_h); - let list_y = if with_input { py + pad + input_h + s(8.0) } else { py + pad }; + let list_y = py + pad + input_h + s(8.0); PaletteLayout { panel: (px, py, pw, ph), input, row_h, list_y, max_rows } } @@ -718,7 +714,7 @@ use crate::renderer::{GlyphCache, Renderer}; /// Push the palette's background quads: a dim veil over the window, the glass /// panel (glow + gradient border + fill, matching the settings modal), the -/// query input box (hidden in picking_default mode), and the selected-row +/// query input box, and the selected-row /// highlight. No-op while closed. pub(super) fn push_quads( model: &CommandPalette, @@ -735,8 +731,7 @@ pub(super) fn push_quads( let s = |v: f32| v * scale; let palette = theme.palette(); let sk = theme.skin(); - let with_input = !model.is_picker(); - let layout = palette_layout(w, h, scale, with_input); + let layout = palette_layout(w, h, scale); let (px, py, pw, ph) = layout.panel; let (ix, iy, iw, ih) = layout.input; @@ -769,23 +764,27 @@ pub(super) fn push_quads( Gradient::Axis([0.25, 0.95]), )); - // Query input box: only in full palette mode, not the WT-style picker. - if with_input { - quads.push(UiQuad::solid(ix, iy, iw, ih, s(10.0), sk.input)); - if model.query_all_selected() && !model.query.is_empty() { - let cell_w = size.cell_width(); - let columns: usize = model.query.chars().map(|c| c.width().unwrap_or(0)).sum(); - let selection_x = ix + s(14.0 + 28.0); - let selection_w = (columns as f32 * cell_w).min(iw - s(56.0)); - quads.push(UiQuad::solid( - selection_x - s(2.0), - iy + s(7.0), - selection_w + s(4.0), - ih - s(14.0), - s(4.0), - sk.accent_soft, - )); - } + quads.push(UiQuad::solid( + ix, + iy, + iw, + ih, + s(super::design_tokens::control::RADIUS), + sk.input, + )); + if model.query_all_selected() && !model.query.is_empty() { + let cell_w = size.cell_width(); + let columns: usize = model.query.chars().map(|c| c.width().unwrap_or(0)).sum(); + let selection_x = ix + s(14.0 + 28.0); + let selection_w = (columns as f32 * cell_w).min(iw - s(56.0)); + quads.push(UiQuad::solid( + selection_x - s(2.0), + iy + s(7.0), + selection_w + s(4.0), + ih - s(14.0), + s(4.0), + sk.accent_soft, + )); } // Hover background: subtle highlight when the mouse is over a row. @@ -845,39 +844,39 @@ pub(super) fn draw_text( let h = size.height(); let cell_w = size.cell_width(); let cell_h = size.cell_height(); - let with_input = !model.is_picker(); - let layout = palette_layout(w, h, scale, with_input); + let layout = palette_layout(w, h, scale); let (ix, iy, iw, ih) = layout.input; // Inks from the theme skin: dark text on light panels, pale on dark. let sk = theme.skin(); - // Left edge for text content (list rows). In full palette mode this is - // indented past the search icon; in WT picker mode it starts at the panel pad. + // Left edge for result text and the search icon. let text_x = ix + s(14.0); - // Search icon and query text: only in full palette mode, not WT picker. - if with_input { - const ICON_SEARCH: &str = "\u{f0349}"; // mdi-magnify - r.draw_chrome_text(size, text_x, iy + (ih - cell_h) / 2.0, sk.accent, ICON_SEARCH, gc); + const ICON_SEARCH: &str = "\u{f0349}"; // mdi-magnify + r.draw_chrome_text(size, text_x, iy + (ih - cell_h) / 2.0, sk.accent, ICON_SEARCH, gc); - let query_x = text_x + s(28.0); - let text_y = iy + (ih - cell_h) / 2.0; - let query = model.query(); - let cursor = if model.cursor_visible() { "▏" } else { "" }; + let query_x = text_x + s(28.0); + let text_y = iy + (ih - cell_h) / 2.0; + let query = model.query(); + let cursor = if model.cursor_visible() { "▏" } else { "" }; - if query.is_empty() { - // Empty: only show blinking cursor at the start (no placeholder) - r.draw_chrome_text(size, query_x, text_y, sk.ink_strong, cursor, gc); + if query.is_empty() { + // 空态同时给出输入光标和用途提示,避免只有一个空白色块让用户猜测。 + r.draw_chrome_text(size, query_x, text_y, sk.ink_strong, cursor, gc); + let placeholder = if model.profiles_only { + "搜索 Shell 或 Profile…" } else { - // Show query + cursor at the end - let shown = if model.query_all_selected() { - query.to_owned() - } else { - format!("{query}{cursor}") - }; - r.draw_chrome_text(size, query_x, text_y, sk.ink_strong, &shown, gc); - } + "搜索命令、Shell 或 Profile…" + }; + r.draw_chrome_text(size, query_x + s(10.0), text_y, sk.ink_dim, placeholder, gc); + } else { + let shown = if model.query_all_selected() { + query.to_owned() + } else { + format!("{query}{cursor}") + }; + r.draw_chrome_text(size, query_x, text_y, sk.ink_strong, &shown, gc); } if model.is_empty() { @@ -1068,7 +1067,7 @@ mod tests { } #[test] - fn only_profile_modes_are_lightweight_pickers() { + fn profile_modes_keep_their_dismissible_picker_identity() { let mut palette = CommandPalette::new(); assert!(!palette.is_picker()); @@ -1076,13 +1075,31 @@ mod tests { assert!(!palette.is_picker(), "full palette keeps its search focus"); palette.open_profiles(); - assert!(palette.is_picker(), "new-tab shell menu releases focus naturally"); + assert!(palette.is_picker(), "new-tab shell selector dismisses on focus loss"); palette.close(); assert!(!palette.is_picker()); palette.open_default_picker(); - assert!(palette.is_picker(), "default-shell selector shares picker semantics"); + assert!(palette.is_picker(), "default-shell selector shares dismissal semantics"); + } + + #[test] + fn profile_picker_query_filters_rows() { + let mut palette = CommandPalette::new(); + palette.set_shell_menu(&[], &["Windows PowerShell".into(), "Git Bash".into()]); + palette.open_profiles(); + + palette.input_text("git"); + let (rows, _) = palette.visible(8); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].label, "Git Bash"); + } + + #[test] + fn search_input_and_result_rows_share_compact_height() { + let layout = palette_layout(1600.0, 900.0, 1.5); + assert_eq!(layout.input.3, layout.row_h); } #[test] diff --git a/nebula_app/src/display/content.rs b/nebula_app/src/display/content.rs index 1a13d6f..24a9ff7 100644 --- a/nebula_app/src/display/content.rs +++ b/nebula_app/src/display/content.rs @@ -13,7 +13,9 @@ use nebula_terminal::term::{self, RenderableContent as TerminalContent, Term, Te use nebula_terminal::vte::ansi::{Color, CursorShape, NamedColor}; use crate::config::UiConfig; +use crate::config::color::{InvertedCellColors, NEBULA_DEFAULT_CURSOR}; use crate::display::color::{CellRgb, DIM_FACTOR, List, Rgb}; +use crate::display::design_tokens::terminal_feedback; use crate::display::hint::{self, HintState}; use crate::display::{Display, SizeInfo}; use crate::event::SearchState; @@ -35,6 +37,9 @@ pub struct RenderableContent<'a> { colors: &'a List, focused_match: Option<&'a Match>, size: &'a SizeInfo, + theme_anchor: Rgb, + theme_is_light: bool, + themed_selection: bool, } impl<'a> RenderableContent<'a> { @@ -48,6 +53,21 @@ impl<'a> RenderableContent<'a> { let search = search_state.dfas().map(|dfas| HintMatches::visible_regex_matches(term, dfas)); let focused_match = search_state.focused_match(); let terminal_content = term.renderable_content(); + // The ANSI magenta slot is also used by the prompt caret. Following it + // keeps the grid cursor visually tied to the terminal theme instead of + // inventing an unrelated fixed chrome color. + let theme_is_light = display.nebula_theme.palette().is_light; + let neutral_mix = if theme_is_light { + terminal_feedback::ANCHOR_NEUTRAL_MIX_LIGHT + } else { + terminal_feedback::ANCHOR_NEUTRAL_MIX_DARK + }; + let theme_anchor = mix_rgb( + display.colors[NamedColor::Magenta], + display.nebula_theme.skin().ink_dim, + neutral_mix, + ); + let themed_selection = config.colors.selection == InvertedCellColors::default(); // Find terminal cursor shape. let cursor_shape = if terminal_content.cursor.shape == CursorShape::Hidden @@ -85,6 +105,9 @@ impl<'a> RenderableContent<'a> { search, config, hint, + theme_anchor, + theme_is_light, + themed_selection, } } @@ -118,23 +141,36 @@ impl<'a> RenderableContent<'a> { } else { self.config.colors.cursor }; - let cursor_color = self.terminal_content.colors[NamedColor::Cursor] - .map_or(color.background, |c| CellRgb::Rgb(Rgb(c))); - let text_color = color.foreground; - - let insufficient_contrast = (!matches!(cursor_color, CellRgb::Rgb(_)) - || !matches!(text_color, CellRgb::Rgb(_))) - && cell.fg.contrast(*cell.bg) < MIN_CURSOR_CONTRAST; - - // Convert from cell colors to RGB. - let mut text_color = text_color.color(cell.fg, cell.bg); - let mut cursor_color = cursor_color.color(cell.fg, cell.bg); - - // Invert cursor color with insufficient contrast to prevent invisible cursors. - if insufficient_contrast { - cursor_color = self.config.colors.primary.foreground; - text_color = self.config.colors.primary.background; - } + let osc_cursor = self.terminal_content.colors[NamedColor::Cursor].map(|c| Rgb(c)); + let follows_theme = osc_cursor.is_none() && color == NEBULA_DEFAULT_CURSOR; + let (cursor_color, text_color, opacity) = if follows_theme { + let opacity = if self.cursor_shape == CursorShape::Block { + if self.theme_is_light { + terminal_feedback::BLOCK_CURSOR_ALPHA_LIGHT + } else { + terminal_feedback::BLOCK_CURSOR_ALPHA_DARK + } + } else if self.theme_is_light { + terminal_feedback::STROKE_CURSOR_ALPHA_LIGHT + } else { + terminal_feedback::STROKE_CURSOR_ALPHA_DARK + }; + // 半透明方块让原字形直接透出,不再强制翻转文字颜色。 + (self.theme_anchor, cell.fg, opacity) + } else { + let cursor_color = osc_cursor.map_or(color.background, CellRgb::Rgb); + let text_color = color.foreground; + let insufficient_contrast = (!matches!(cursor_color, CellRgb::Rgb(_)) + || !matches!(text_color, CellRgb::Rgb(_))) + && cell.fg.contrast(*cell.bg) < MIN_CURSOR_CONTRAST; + let mut text_color = text_color.color(cell.fg, cell.bg); + let mut cursor_color = cursor_color.color(cell.fg, cell.bg); + if insufficient_contrast { + cursor_color = self.config.colors.primary.foreground; + text_color = self.config.colors.primary.background; + } + (cursor_color, text_color, 1.0) + }; let width = if cell.flags.contains(Flags::WIDE_CHAR) { NonZeroU32::new(2).unwrap() @@ -147,6 +183,7 @@ impl<'a> RenderableContent<'a> { point: self.cursor_point, cursor_color, text_color, + opacity, } } } @@ -168,12 +205,18 @@ impl Iterator for RenderableContent<'_> { // Store the cursor which should be rendered. self.cursor = self.renderable_cursor(&cell); if self.cursor.shape == CursorShape::Block { - cell.fg = self.cursor.text_color; - cell.bg = self.cursor.cursor_color; - - // Since we draw Block cursor by drawing cell below it with a proper color, - // we must adjust alpha to make it visible. - cell.bg_alpha = 1.; + if self.cursor.opacity < 1.0 { + (cell.bg, cell.bg_alpha) = composite_overlay( + self.cursor.cursor_color, + self.cursor.opacity, + cell.bg, + cell.bg_alpha, + ); + } else { + cell.fg = self.cursor.text_color; + cell.bg = self.cursor.cursor_color; + cell.bg_alpha = 1.0; + } } return Some(cell); @@ -253,9 +296,23 @@ impl RenderableCell { character = c.unwrap_or(character); } else if is_selected { - let config_fg = colors.selection.foreground; - let config_bg = colors.selection.background; - Self::compute_cell_rgb(&mut fg, &mut bg, &mut bg_alpha, config_fg, config_bg); + if content.themed_selection { + let opacity = if content.theme_is_light { + terminal_feedback::SELECTION_ALPHA_LIGHT + } else { + terminal_feedback::SELECTION_ALPHA_DARK + }; + (bg, bg_alpha) = composite_overlay( + content.theme_anchor, + opacity, + bg, + bg_alpha, + ); + } else { + let config_fg = colors.selection.foreground; + let config_bg = colors.selection.background; + Self::compute_cell_rgb(&mut fg, &mut bg, &mut bg_alpha, config_fg, config_bg); + } if fg == bg && !cell.flags.contains(Flags::HIDDEN) { // Reveal inversed text when fg/bg is the same. @@ -397,14 +454,37 @@ impl RenderableCell { } } +/// Alpha-compose a theme overlay over the cell's existing background. Keeping +/// this in the render model preserves explicit ANSI cell backgrounds while +/// still allowing the default transparent terminal surface/image to show. +fn composite_overlay(overlay: Rgb, overlay_alpha: f32, base: Rgb, base_alpha: f32) -> (Rgb, f32) { + let oa = overlay_alpha.clamp(0.0, 1.0); + let ba = base_alpha.clamp(0.0, 1.0); + let out_a = oa + ba * (1.0 - oa); + if out_a <= f32::EPSILON { + return (base, 0.0); + } + let channel = |o: u8, b: u8| { + ((o as f32 * oa + b as f32 * ba * (1.0 - oa)) / out_a).round() as u8 + }; + (Rgb::new(channel(overlay.r, base.r), channel(overlay.g, base.g), channel(overlay.b, base.b)), out_a) +} + +fn mix_rgb(color: Rgb, neutral: Rgb, neutral_amount: f32) -> Rgb { + let t = neutral_amount.clamp(0.0, 1.0); + let channel = |a: u8, b: u8| (a as f32 + (b as f32 - a as f32) * t).round() as u8; + Rgb::new(channel(color.r, neutral.r), channel(color.g, neutral.g), channel(color.b, neutral.b)) +} + /// Cursor storing all information relevant for rendering. -#[derive(Debug, Eq, PartialEq, Copy, Clone)] +#[derive(Debug, PartialEq, Copy, Clone)] pub struct RenderableCursor { shape: CursorShape, cursor_color: Rgb, text_color: Rgb, width: NonZeroU32, point: Point, + opacity: f32, } impl RenderableCursor { @@ -414,7 +494,7 @@ impl RenderableCursor { let text_color = Rgb::default(); let width = NonZeroU32::new(1).unwrap(); let point = Point::default(); - Self { shape, cursor_color, text_color, width, point } + Self { shape, cursor_color, text_color, width, point, opacity: 0.0 } } } @@ -425,13 +505,17 @@ impl RenderableCursor { cursor_color: Rgb, width: NonZeroU32, ) -> Self { - Self { shape, cursor_color, text_color: cursor_color, width, point } + Self { shape, cursor_color, text_color: cursor_color, width, point, opacity: 1.0 } } pub fn color(&self) -> Rgb { self.cursor_color } + pub fn opacity(&self) -> f32 { + self.opacity + } + pub fn shape(&self) -> CursorShape { self.shape } @@ -445,6 +529,38 @@ impl RenderableCursor { } } +#[cfg(test)] +mod tests { + use super::{composite_overlay, mix_rgb}; + use crate::display::color::Rgb; + + #[test] + fn translucent_overlay_preserves_transparent_surface() { + let purple = Rgb::new(130, 80, 223); + let (color, alpha) = composite_overlay(purple, 0.20, Rgb::new(255, 255, 255), 0.0); + assert_eq!(color, purple); + assert!((alpha - 0.20).abs() < f32::EPSILON); + } + + #[test] + fn translucent_overlay_composites_over_opaque_ansi_background() { + let (color, alpha) = composite_overlay( + Rgb::new(130, 80, 223), + 0.20, + Rgb::new(255, 255, 255), + 1.0, + ); + assert_eq!(color, Rgb::new(230, 220, 249)); + assert!((alpha - 1.0).abs() < f32::EPSILON); + } + + #[test] + fn theme_anchor_is_softened_toward_neutral_ink() { + let softened = mix_rgb(Rgb::new(130, 80, 223), Rgb::new(107, 114, 128), 0.28); + assert_eq!(softened, Rgb::new(124, 90, 196)); + } +} + /// Regex hints for keyboard shortcuts. struct Hint<'a> { /// Hint matches and position. diff --git a/nebula_app/src/display/context_menu.rs b/nebula_app/src/display/context_menu.rs index 0e2a2f9..3870085 100644 --- a/nebula_app/src/display/context_menu.rs +++ b/nebula_app/src/display/context_menu.rs @@ -4,12 +4,10 @@ //! hit-testing and animation live together here so pointer targets cannot drift //! away from the pixels the user sees at non-integer DPI scales. -use std::time::{Duration, Instant}; +use std::time::Instant; use super::*; - -const OPEN_DURATION: Duration = Duration::from_millis(120); -const CLOSE_DURATION: Duration = Duration::from_millis(90); +use super::design_tokens::{control, elevation, motion, space}; // Maple Mono Normal NF CN ships the stable Codicon block below. Keep menu // symbols on that block: newer Codicon additions such as U+EC86 are absent @@ -99,18 +97,18 @@ impl ContextMenu { fn progress(&self) -> f32 { if let Some(start) = self.closing_at { - return (1.0 - start.elapsed().as_secs_f32() / CLOSE_DURATION.as_secs_f32()) + return (1.0 - start.elapsed().as_secs_f32() / motion::MENU_CLOSE.as_secs_f32()) .clamp(0.0, 1.0); } - (self.opened_at.elapsed().as_secs_f32() / OPEN_DURATION.as_secs_f32()).clamp(0.0, 1.0) + (self.opened_at.elapsed().as_secs_f32() / motion::MENU_OPEN.as_secs_f32()).clamp(0.0, 1.0) } pub(super) fn finished(&self) -> bool { - self.closing_at.is_some_and(|start| start.elapsed() >= CLOSE_DURATION) + self.closing_at.is_some_and(|start| start.elapsed() >= motion::MENU_CLOSE) } pub(super) fn animating(&self) -> bool { - self.closing_at.is_some() || self.opened_at.elapsed() < OPEN_DURATION + self.closing_at.is_some() || self.opened_at.elapsed() < motion::MENU_OPEN } pub(super) fn interactive(&self) -> bool { @@ -147,7 +145,15 @@ fn layout(menu: &ContextMenu, size: SizeInfo, scale: f32, animated_y_offset: f32 let row_h = s(38.0); let pad = s(7.0); let sep_h = s(9.0); - let color_h = s(62.0); + let color_top_pad = s(space::XS); + let color_label_gap = s(space::XS); + let swatch = s(control::ICON_BUTTON); + let color_bottom_pad = s(10.0); + let color_h = color_top_pad + + size.cell_height() + + color_label_gap + + swatch + + color_bottom_pad; let (row_count, separators, extra) = match menu.target { ContextMenuTarget::Tab(_) => (5usize, 2usize, color_h), ContextMenuTarget::Ssh(_) => (4usize, 1usize, 0.0), @@ -240,11 +246,12 @@ fn layout(menu: &ContextMenu, size: SizeInfo, scale: f32, animated_y_offset: f32 )); cursor_y += row_h; - color_label = Some((x + s(14.0), cursor_y + s(8.0))); - let swatch = s(20.0); - let gap = s(8.0); + let label_y = cursor_y + color_top_pad; + color_label = Some((x + s(14.0), label_y)); + let gap = s(space::XS); let start_x = x + s(14.0); - let swatch_y = cursor_y + s(32.0); + // 标签字盒结束后保留完整 XS 间距,避免标题和色板粘连。 + let swatch_y = label_y + size.cell_height() + color_label_gap; let all = std::iter::once(None).chain(TAB_COLORS.into_iter().map(Some)); for (slot, color) in all.enumerate() { let rect = (start_x + slot as f32 * (swatch + gap), swatch_y, swatch, swatch); @@ -342,16 +349,33 @@ pub(super) fn draw(display: &mut Display) { let radius = s(9.0); let (px, py, pw, ph) = layout.panel; + let shadow_alpha = if sk.is_light { + elevation::FLOATING_SHADOW_ALPHA_LIGHT + } else { + elevation::FLOATING_SHADOW_ALPHA_DARK + }; + let hairline = s(control::HAIRLINE).max(1.0); let mut quads = vec![ - UiQuad::glow( - px - s(16.0), - py - s(12.0), - pw + s(32.0), - ph + s(30.0), - alpha(Rgba::new(0, 0, 0, 72), progress), + UiQuad::shadow( + px, + py, + pw, + ph, + radius, + s(elevation::FLOATING_BLUR), + s(elevation::FLOATING_OFFSET_Y), + alpha(Rgba::new(0, 0, 0, shadow_alpha), progress), + ), + // 全周 hairline 在浅色背景上定义边界;外阴影承担 Z 轴层级。 + UiQuad::solid( + px - hairline, + py - hairline, + pw + hairline * 2.0, + ph + hairline * 2.0, + radius + hairline, + alpha(sk.hairline, progress), ), UiQuad::solid(px, py, pw, ph, radius, alpha(sk.panel, progress)), - UiQuad::solid(px, py, pw, s(1.0).max(1.0), radius, alpha(sk.hairline, progress)), ]; for separator in &layout.separators { quads.push(UiQuad::solid( diff --git a/nebula_app/src/display/cursor.rs b/nebula_app/src/display/cursor.rs index 4e65718..bcb4dfa 100644 --- a/nebula_app/src/display/cursor.rs +++ b/nebula_app/src/display/cursor.rs @@ -27,9 +27,13 @@ impl IntoRects for RenderableCursor { width *= self.width().get() as f32; match self.shape() { - CursorShape::Beam => beam(x, y, height, thickness, self.color()), - CursorShape::Underline => underline(x, y, width, height, thickness, self.color()), - CursorShape::HollowBlock => hollow(x, y, width, height, thickness, self.color()), + CursorShape::Beam => beam(x, y, height, thickness, self.color(), self.opacity()), + CursorShape::Underline => { + underline(x, y, width, height, thickness, self.color(), self.opacity()) + }, + CursorShape::HollowBlock => { + hollow(x, y, width, height, thickness, self.color(), self.opacity()) + }, _ => CursorRects::default(), } } @@ -59,29 +63,45 @@ impl Iterator for CursorRects { } /// Create an iterator yielding a single beam rect. -fn beam(x: f32, y: f32, height: f32, thickness: f32, color: Rgb) -> CursorRects { - RenderRect::new(x, y, thickness, height, color, 1.).into() +fn beam(x: f32, y: f32, height: f32, thickness: f32, color: Rgb, alpha: f32) -> CursorRects { + RenderRect::new(x, y, thickness, height, color, alpha).into() } /// Create an iterator yielding a single underline rect. -fn underline(x: f32, y: f32, width: f32, height: f32, thickness: f32, color: Rgb) -> CursorRects { +fn underline( + x: f32, + y: f32, + width: f32, + height: f32, + thickness: f32, + color: Rgb, + alpha: f32, +) -> CursorRects { let y = y + height - thickness; - RenderRect::new(x, y, width, thickness, color, 1.).into() + RenderRect::new(x, y, width, thickness, color, alpha).into() } /// Create an iterator yielding a rect for each side of the hollow block cursor. -fn hollow(x: f32, y: f32, width: f32, height: f32, thickness: f32, color: Rgb) -> CursorRects { - let top_line = RenderRect::new(x, y, width, thickness, color, 1.); +fn hollow( + x: f32, + y: f32, + width: f32, + height: f32, + thickness: f32, + color: Rgb, + alpha: f32, +) -> CursorRects { + let top_line = RenderRect::new(x, y, width, thickness, color, alpha); let vertical_y = y + thickness; let vertical_height = height - 2. * thickness; - let left_line = RenderRect::new(x, vertical_y, thickness, vertical_height, color, 1.); + let left_line = RenderRect::new(x, vertical_y, thickness, vertical_height, color, alpha); let bottom_y = y + height - thickness; - let bottom_line = RenderRect::new(x, bottom_y, width, thickness, color, 1.); + let bottom_line = RenderRect::new(x, bottom_y, width, thickness, color, alpha); let right_x = x + width - thickness; - let right_line = RenderRect::new(right_x, vertical_y, thickness, vertical_height, color, 1.); + let right_line = RenderRect::new(right_x, vertical_y, thickness, vertical_height, color, alpha); CursorRects { rects: [Some(top_line), Some(bottom_line), Some(left_line), Some(right_line)], diff --git a/nebula_app/src/display/design_tokens.rs b/nebula_app/src/display/design_tokens.rs new file mode 100644 index 0000000..717d597 --- /dev/null +++ b/nebula_app/src/display/design_tokens.rs @@ -0,0 +1,117 @@ +//! Nebula's shared UI contract: spacing, typography, motion and control state. +//! +//! Values are logical pixels/scales. Callers apply the window DPI exactly once +//! at layout time; draw, hover, focus and hit-testing must derive from the same +//! resulting rectangle. + +// Token adoption is intentionally incremental: keeping the complete scale in +// one catalog prevents new local magic numbers while existing screens migrate. +#![allow(dead_code)] + +pub mod space { + pub const XXS: f32 = 4.0; + pub const XS: f32 = 8.0; + pub const S: f32 = 12.0; + pub const M: f32 = 16.0; + pub const L: f32 = 24.0; + pub const XL: f32 = 32.0; +} + +pub mod type_scale { + /// 11px-style sidebar/group caption from the Maple base face. + pub const SECTION_CAPTION: f32 = 0.75; + /// Supporting/empty-state copy: subordinate without becoming unreadable. + pub const SUPPORTING: f32 = 0.80; + pub const BODY: f32 = 1.0; + pub const DIALOG_TITLE: f32 = 1.35; +} + +pub mod control { + pub const ICON_BUTTON: f32 = 20.0; + pub const MIN_HIT_TARGET: f32 = 32.0; + /// Dense rows used by command/search pickers where several choices must + /// remain visible without making the input visually dominate the content. + pub const COMPACT_ROW: f32 = 38.0; + pub const ROW: f32 = 44.0; + pub const RADIUS: f32 = 8.0; + pub const HAIRLINE: f32 = 1.0; +} + +/// Z-axis treatment for transient surfaces rendered above normal content. +pub mod elevation { + /// Soft edge around a floating menu/card. + pub const FLOATING_BLUR: f32 = 12.0; + /// A small downward bias makes the card read as lifted rather than glowing. + pub const FLOATING_OFFSET_Y: f32 = 4.0; + pub const FLOATING_SHADOW_ALPHA_LIGHT: u8 = 54; + pub const FLOATING_SHADOW_ALPHA_DARK: u8 = 86; +} + +/// Terminal-grid feedback colors are rendered through the cell/rect alpha +/// pipeline. Thin cursors need more opacity than a full block to remain visible. +pub mod terminal_feedback { + /// Blend the terminal hue anchor toward theme-neutral ink before applying + /// alpha; this preserves hue continuity without an electric saturation. + pub const ANCHOR_NEUTRAL_MIX_LIGHT: f32 = 0.28; + pub const ANCHOR_NEUTRAL_MIX_DARK: f32 = 0.18; + pub const BLOCK_CURSOR_ALPHA_LIGHT: f32 = 0.20; + pub const BLOCK_CURSOR_ALPHA_DARK: f32 = 0.30; + pub const STROKE_CURSOR_ALPHA_LIGHT: f32 = 0.72; + pub const STROKE_CURSOR_ALPHA_DARK: f32 = 0.82; + pub const SELECTION_ALPHA_LIGHT: f32 = 0.15; + pub const SELECTION_ALPHA_DARK: f32 = 0.22; +} + +pub mod motion { + use std::time::Duration; + + pub const MENU_OPEN: Duration = Duration::from_millis(120); + pub const MENU_CLOSE: Duration = Duration::from_millis(90); + pub const HOVER: Duration = Duration::from_millis(150); + /// CSS-like emphasized-decelerate curve used by short reveal motion. + pub const EMPHASIZED_DECELERATE: [f32; 4] = [0.16, 1.0, 0.3, 1.0]; +} + +/// Required states for every self-drawn control. Empty/loading/error describe +/// content surfaces; pointer/keyboard controls use the first five. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ControlState { + Default, + Hover, + Focus, + Active, + Disabled, + Empty, + Loading, + Error, +} + +impl ControlState { + /// Persistent/blocked states outrank transient pointer feedback. + pub fn interactive(disabled: bool, active: bool, focused: bool, hovered: bool) -> Self { + if disabled { + Self::Disabled + } else if active { + Self::Active + } else if focused { + Self::Focus + } else if hovered { + Self::Hover + } else { + Self::Default + } + } +} + +#[cfg(test)] +mod tests { + use super::ControlState; + + #[test] + fn persistent_control_states_outrank_hover() { + assert_eq!(ControlState::interactive(true, true, true, true), ControlState::Disabled); + assert_eq!(ControlState::interactive(false, true, true, true), ControlState::Active); + assert_eq!(ControlState::interactive(false, false, true, true), ControlState::Focus); + assert_eq!(ControlState::interactive(false, false, false, true), ControlState::Hover); + } +} diff --git a/nebula_app/src/display/mod.rs b/nebula_app/src/display/mod.rs index 059592e..bbb4e6a 100644 --- a/nebula_app/src/display/mod.rs +++ b/nebula_app/src/display/mod.rs @@ -73,6 +73,7 @@ pub mod window; mod chrome; mod context_menu; +pub mod design_tokens; pub mod command_palette; pub mod markdown_view; pub mod side_panel; @@ -86,9 +87,12 @@ use chrome::{ }; mod settings; +mod ssh_ui; mod theme; mod text_input; +pub use ssh_ui::{SSH_DELETE_UNDO_DURATION, SshEditorField, SshEditorHit, SshEditorRects, SshHostEditor}; +use ssh_ui::SshDeleteUndo; pub use theme::NebulaTheme; pub(crate) use theme::write_nebula_prompt_theme; @@ -188,6 +192,14 @@ pub fn chrome_reserve(scale_factor: f32) -> f32 { (CHROME_BAR_LOGICAL * scale_factor).round() } +/// Bottom grid reserve: card seam plus the same 8px inner breathing room used +/// above the first row. Unlike [`chrome_reserve`], there is no title bar below +/// the terminal, so mirroring the 64px top reserve creates a large dead band. +#[inline] +pub fn bottom_content_reserve(scale_factor: f32) -> f32 { + ((UI_CARD_SEAM_LOGICAL + 8.0) * scale_factor).round() +} + /// Horizontal content padding, in physical pixels for `scale_factor`. #[inline] pub fn content_pad_x(scale_factor: f32) -> f32 { @@ -434,76 +446,6 @@ pub enum NebulaConfirm { DeleteSsh { host: String, from_config: bool }, } -/// How long the destructive SSH action stays reversible in the in-app bar. -pub const SSH_DELETE_UNDO_DURATION: std::time::Duration = std::time::Duration::from_secs(8); - -/// Exact list state removed by one SSH deletion. Credential deletion is -/// deliberately delayed until this value is dropped at undo expiry: Undo can -/// therefore restore the complete action without retaining a password copy in -/// Nebula's memory. -#[derive(Debug)] -struct SshDeleteUndo { - host: String, - saved_index: Option, - pinned_index: Option, - was_hidden: bool, - from_config: bool, - started_at: std::time::Instant, - delete_credential_on_drop: bool, -} - -impl Drop for SshDeleteUndo { - fn drop(&mut self) { - #[cfg(windows)] - if self.delete_credential_on_drop { - let _ = crate::ssh_credentials::forget_password(&self.host); - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SshEditorField { - Destination, - Password, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SshEditorHit { - None, - Destination, - Password, - PasswordToggle, - SaveToggleBox, - SaveToggleLabel, - Primary, - Cancel, -} - -#[derive(Debug, Clone)] -pub struct SshHostEditor { - /// Destination before editing, when this modal was opened from a row. - /// Kept separately so changing an address can retire the old saved entry. - pub original_destination: Option, - pub destination: String, - pub password: String, - pub save_password: bool, - pub show_password: bool, - pub field: SshEditorField, - destination_selection: text_input::SelectAllState, - password_selection: text_input::SelectAllState, -} - -#[derive(Debug, Clone, Copy)] -pub struct SshEditorRects { - pub destination: (f32, f32, f32, f32), - pub password: (f32, f32, f32, f32), - pub password_toggle: (f32, f32, f32, f32), - pub save_checkbox: (f32, f32, f32, f32), - pub save_toggle: (f32, f32, f32, f32), - pub primary: (f32, f32, f32, f32), - pub cancel: (f32, f32, f32, f32), -} - /// One OSC 1337 inline image, anchored to an absolute grid row (see /// `Grid::scrolled_out`). Pixels are shared via `Arc` so pane-state clones /// stay cheap; the GPU texture is cached per `id` inside the renderer. @@ -1117,9 +1059,13 @@ pub struct SizeInfo { /// grid only pays the sidebar cost on one side. padding_right: T, - /// Vertical window padding. + /// Top window padding / grid origin. padding_y: T, + /// Bottom window padding. Kept separate because Nebula's title bar exists + /// only above the grid; using `padding_y` symmetrically wastes terminal rows. + padding_bottom: T, + /// Number of lines in the viewport. screen_lines: usize, @@ -1137,6 +1083,7 @@ impl From> for SizeInfo { padding_x: size_info.padding_x as u32, padding_right: size_info.padding_right as u32, padding_y: size_info.padding_y as u32, + padding_bottom: size_info.padding_bottom as u32, screen_lines: size_info.screen_lines, columns: size_info.columns, } @@ -1189,6 +1136,11 @@ impl SizeInfo { pub fn padding_y(&self) -> T { self.padding_y } + + #[inline] + pub fn padding_bottom(&self) -> T { + self.padding_bottom + } } impl SizeInfo { @@ -1210,7 +1162,16 @@ impl SizeInfo { padding_y = Self::dynamic_padding(padding_y.floor(), height, cell_height); } - Self::assemble(width, height, cell_width, cell_height, padding_x, padding_right, padding_y) + Self::assemble( + width, + height, + cell_width, + cell_height, + padding_x, + padding_right, + padding_y, + padding_y, + ) } /// Build a grid whose left and right paddings differ. The left padding is @@ -1227,7 +1188,42 @@ impl SizeInfo { padding_right: f32, padding_y: f32, ) -> SizeInfo { - Self::assemble(width, height, cell_width, cell_height, padding_x, padding_right, padding_y) + Self::assemble( + width, + height, + cell_width, + cell_height, + padding_x, + padding_right, + padding_y, + padding_y, + ) + } + + /// Build a grid with independent padding on all four sides. This is the + /// full-window Nebula path: sidebar/drawer make X asymmetric, while the + /// title bar makes Y asymmetric. + #[allow(clippy::too_many_arguments)] + pub fn new_fully_asymmetric( + width: f32, + height: f32, + cell_width: f32, + cell_height: f32, + padding_x: f32, + padding_right: f32, + padding_y: f32, + padding_bottom: f32, + ) -> SizeInfo { + Self::assemble( + width, + height, + cell_width, + cell_height, + padding_x, + padding_right, + padding_y, + padding_bottom, + ) } #[allow(clippy::too_many_arguments)] @@ -1239,8 +1235,9 @@ impl SizeInfo { padding_x: f32, padding_right: f32, padding_y: f32, + padding_bottom: f32, ) -> SizeInfo { - let lines = (height - 2. * padding_y) / cell_height; + let lines = (height - padding_y - padding_bottom) / cell_height; let screen_lines = cmp::max(lines as usize, MIN_SCREEN_LINES); let columns = (width - padding_x - padding_right) / cell_width; @@ -1254,6 +1251,7 @@ impl SizeInfo { padding_x: padding_x.floor(), padding_right: padding_right.floor(), padding_y: padding_y.floor(), + padding_bottom: padding_bottom.floor(), screen_lines, columns, } @@ -1646,7 +1644,7 @@ impl Display { // margin. Dynamic padding is dropped — the sidebar fixes the left edge. let scale = window.scale_factor as f32; let content_pad = content_pad_x(scale); - let size_info = SizeInfo::new_asymmetric( + let size_info = SizeInfo::new_fully_asymmetric( viewport_size.width as f32, viewport_size.height as f32, cell_width, @@ -1654,6 +1652,7 @@ impl Display { padding.0 + content_pad + sidebar_width(scale, false), padding.0 + content_pad, padding.1 + chrome, + padding.1 + bottom_content_reserve(scale), ); info!("Cell size: {cell_width} x {cell_height}"); @@ -1888,6 +1887,7 @@ impl Display { pub fn open_ssh_editor(&mut self) { self.nebula_ssh_editor = Some(SshHostEditor { original_destination: None, + error: None, destination_selection: Default::default(), password_selection: Default::default(), destination: String::new(), @@ -1908,6 +1908,7 @@ impl Display { let Some(destination) = self.nebula_ssh_hosts.get(index).cloned() else { return }; self.nebula_ssh_editor = Some(SshHostEditor { original_destination: Some(destination.clone()), + error: None, destination_selection: Default::default(), password_selection: Default::default(), destination, @@ -1971,6 +1972,7 @@ impl Display { pub fn ssh_editor_insert(&mut self, text: &str) { if let Some(editor) = self.nebula_ssh_editor.as_mut() { + editor.error = None; match editor.field { SshEditorField::Destination => { editor.destination_selection.insert(&mut editor.destination, text) @@ -2086,7 +2088,15 @@ impl Display { .chars() .any(|c| c.is_whitespace() || c.is_control() || ";&|<>\"'`".contains(c)); if !valid { + editor.error = Some(if destination.is_empty() { + "请输入 SSH 地址,例如 user@example.com".to_owned() + } else { + "地址不能包含空白、控制字符或 shell 分隔符".to_owned() + }); + editor.field = SshEditorField::Destination; self.nebula_ssh_editor = Some(editor); + self.pending_update.dirty = true; + self.window.request_redraw(); return; } if let Some(original) = editor.original_destination.as_deref() { @@ -3508,7 +3518,7 @@ impl Display { } else { 0.0 }; - let mut new_size = SizeInfo::new_asymmetric( + let mut new_size = SizeInfo::new_fully_asymmetric( width, height, cell_width, @@ -3516,6 +3526,7 @@ impl Display { padding.0 + content_pad + sidebar, padding.0 + content_pad + drawer, padding.1 + chrome, + padding.1 + bottom_content_reserve(scale), ); // Update number of column/lines in the viewport. @@ -4752,8 +4763,15 @@ impl Display { &size, destination.0, destination.1 - cell_h - s(6.0), - sk.ink_dim, - "用户名@地址(非 22 端口可用 ssh://user@host:port)", + if editor.error.is_some() { + if sk.is_light { Rgb::new(207, 34, 46) } else { Rgb::new(248, 81, 73) } + } else { + sk.ink_dim + }, + editor + .error + .as_deref() + .unwrap_or("用户名@地址(非 22 端口可用 ssh://user@host:port)"), gc, ); let destination_text = @@ -6221,14 +6239,18 @@ fn window_size( let pad_left = padding.0 + content_pad_x(scale_factor) + sidebar_width(scale_factor, false); let pad_right = padding.0 + content_pad_x(scale_factor); let width = (grid_width + pad_left + pad_right).floor(); - let height = (padding.1 + chrome).mul_add(2., grid_height).floor(); + let pad_top = padding.1 + chrome; + let pad_bottom = padding.1 + bottom_content_reserve(scale_factor); + let height = (pad_top + grid_height + pad_bottom).floor(); PhysicalSize::new(width as u32, height as u32) } #[cfg(test)] mod nebula_ux_tests { - use super::{remove_ssh_host_from_lists, restore_ssh_host_to_lists}; + use nebula_terminal::grid::Dimensions; + + use super::{SizeInfo, remove_ssh_host_from_lists, restore_ssh_host_to_lists}; fn strings(values: &[&str]) -> Vec { values.iter().map(|value| (*value).to_owned()).collect() @@ -6285,4 +6307,19 @@ mod nebula_ux_tests { assert!(pinned.is_empty()); assert!(hidden.is_empty()); } + + #[test] + fn asymmetric_bottom_reserve_recovers_rows_hidden_by_top_chrome() { + let size = SizeInfo::new_fully_asymmetric( + 1000.0, 1000.0, 10.0, 20.0, 0.0, 0.0, 64.0, 16.0, + ); + assert_eq!(size.screen_lines(), 46); + assert_eq!(size.padding_y(), 64.0); + assert_eq!(size.padding_bottom(), 16.0); + + let old_symmetric = SizeInfo::new_asymmetric( + 1000.0, 1000.0, 10.0, 20.0, 0.0, 0.0, 64.0, + ); + assert_eq!(old_symmetric.screen_lines(), 43); + } } diff --git a/nebula_app/src/display/ssh_ui.rs b/nebula_app/src/display/ssh_ui.rs new file mode 100644 index 0000000..9d88fce --- /dev/null +++ b/nebula_app/src/display/ssh_ui.rs @@ -0,0 +1,73 @@ +//! SSH editor and reversible-deletion state types. +//! +//! Keeping these out of `display::mod` makes the security-sensitive lifetime +//! rule explicit: a pending deletion owns credential cleanup and only Undo may +//! disarm it. + +use super::text_input::SelectAllState; + +/// How long a destructive SSH action stays reversible in the in-app bar. +pub const SSH_DELETE_UNDO_DURATION: std::time::Duration = std::time::Duration::from_secs(8); + +#[derive(Debug)] +pub(super) struct SshDeleteUndo { + pub(super) host: String, + pub(super) saved_index: Option, + pub(super) pinned_index: Option, + pub(super) was_hidden: bool, + pub(super) from_config: bool, + pub(super) started_at: std::time::Instant, + pub(super) delete_credential_on_drop: bool, +} + +impl Drop for SshDeleteUndo { + fn drop(&mut self) { + #[cfg(windows)] + if self.delete_credential_on_drop { + let _ = crate::ssh_credentials::forget_password(&self.host); + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SshEditorField { + Destination, + Password, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SshEditorHit { + None, + Destination, + Password, + PasswordToggle, + SaveToggleBox, + SaveToggleLabel, + Primary, + Cancel, +} + +#[derive(Debug, Clone)] +pub struct SshHostEditor { + /// Destination before editing, when this modal was opened from a row. + pub original_destination: Option, + pub destination: String, + pub password: String, + pub save_password: bool, + pub show_password: bool, + pub field: SshEditorField, + pub error: Option, + pub(super) destination_selection: SelectAllState, + pub(super) password_selection: SelectAllState, +} + +#[derive(Debug, Clone, Copy)] +pub struct SshEditorRects { + pub destination: (f32, f32, f32, f32), + pub password: (f32, f32, f32, f32), + pub password_toggle: (f32, f32, f32, f32), + pub save_checkbox: (f32, f32, f32, f32), + pub save_toggle: (f32, f32, f32, f32), + pub primary: (f32, f32, f32, f32), + pub cancel: (f32, f32, f32, f32), +} diff --git a/nebula_app/src/event.rs b/nebula_app/src/event.rs index 4f71a4f..fb079b2 100644 --- a/nebula_app/src/event.rs +++ b/nebula_app/src/event.rs @@ -2860,9 +2860,7 @@ impl input::Processor> { self.ctx.display.nebula_side_panel.commit_input(&text); } else if self.ctx.display.nebula_ssh_editor.is_some() { self.ctx.display.ssh_editor_insert(&text); - } else if self.ctx.display.command_palette_open() - && !self.ctx.display.command_palette_picker_open() - { + } else if self.ctx.display.command_palette_open() { self.ctx.display.palette_input_text(&text); } else { // Don't use bracketed paste for single char input. diff --git a/nebula_app/src/input/keyboard.rs b/nebula_app/src/input/keyboard.rs index 11cbf11..b5dca27 100644 --- a/nebula_app/src/input/keyboard.rs +++ b/nebula_app/src/input/keyboard.rs @@ -255,47 +255,9 @@ impl> Processor { return; } - // The new-tab shell/profile list is a lightweight menu: navigation and - // confirmation stay inside it, while unrelated keys dismiss it and - // continue through normal shortcuts/terminal input. - if self.ctx.display().command_palette_picker_open() { - let handled = match &key.logical_key { - Key::Named(NamedKey::Escape) => { - self.ctx.display().close_command_palette(); - true - }, - Key::Named(NamedKey::Enter) => { - if let Some(action) = self.ctx.display().palette_confirm() { - self.run_palette_action(action); - } - true - }, - Key::Named(NamedKey::Tab) => { - self.ctx.display().palette_move(if mods.shift_key() { -1 } else { 1 }); - true - }, - Key::Named(NamedKey::ArrowDown) => { - self.ctx.display().palette_move(1); - true - }, - Key::Named(NamedKey::ArrowUp) => { - self.ctx.display().palette_move(-1); - true - }, - _ => { - self.ctx.display().close_command_palette(); - false - }, - }; - self.ctx.mark_dirty(); - if handled { - return; - } - } - - // The full command palette owns the keyboard while open: route typing / - // navigation / confirm into it and swallow everything else, so no - // terminal binding fires behind the modal. + // Command and Shell/Profile palettes both own the keyboard while open: + // typing, IME and editing shortcuts target their visible search box, so + // nothing is accidentally forwarded to the terminal behind the veil. if self.ctx.display().command_palette_open() { match &key.logical_key { Key::Named(NamedKey::Escape) => self.ctx.display().close_command_palette(), diff --git a/nebula_app/src/input/mod.rs b/nebula_app/src/input/mod.rs index fd402ff..1438b54 100644 --- a/nebula_app/src/input/mod.rs +++ b/nebula_app/src/input/mod.rs @@ -793,12 +793,10 @@ impl> Processor { if self.ctx.display().command_palette_open() { let px = x as f32; let py = y as f32; - let with_input = !self.ctx.display().command_palette_picker_open(); let layout = crate::display::command_palette::palette_layout( window_size.width(), window_size.height(), scale, - with_input, ); let (ix, iy, iw, ih) = layout.panel; // Hover detection: the pointer must be inside the panel rectangle, @@ -1213,12 +1211,10 @@ impl> Processor { let y = self.ctx.mouse().y as f32; let size = self.ctx.display().size_info; let scale = self.ctx.window().scale_factor as f32; - let with_input = !self.ctx.display().command_palette_picker_open(); let layout = crate::display::command_palette::palette_layout( size.width(), size.height(), scale, - with_input, ); let (px, py, pw, ph) = layout.panel; if x >= px && x < px + pw && y >= py && y < py + ph { diff --git a/nebula_app/src/renderer/text/builtin_font.rs b/nebula_app/src/renderer/text/builtin_font.rs index ac103c4..30305a1 100644 --- a/nebula_app/src/renderer/text/builtin_font.rs +++ b/nebula_app/src/renderer/text/builtin_font.rs @@ -412,7 +412,16 @@ mod tests { assert!(builtin_glyph(character, &METRICS, &offset, &glyph_offset).is_some()); } - for character in ('\u{e0a0}'..'\u{e0b0}').chain('\u{e0b4}'..'\u{e0c0}') { + // Rounded caps were intentionally added after the original four + // triangle/arrow glyphs, so they are coverage too rather than holes. + for character in ['\u{e0b4}', '\u{e0b6}'] { + assert!(builtin_glyph(character, &METRICS, &offset, &glyph_offset).is_some()); + } + + for character in ('\u{e0a0}'..'\u{e0b0}') + .chain('\u{e0b5}'..'\u{e0b6}') + .chain('\u{e0b7}'..'\u{e0c0}') + { assert!(builtin_glyph(character, &METRICS, &offset, &glyph_offset).is_none()); } } diff --git a/nebula_app/src/renderer/ui.rs b/nebula_app/src/renderer/ui.rs index e63cd09..ba7ab8e 100644 --- a/nebula_app/src/renderer/ui.rs +++ b/nebula_app/src/renderer/ui.rs @@ -86,7 +86,9 @@ pub struct UiQuad { /// where the join corners are square and only the outer corners curve). /// `radius` still drives the flat/glow shader flags, so keep it ≥ 0 here. pub corner_radii: [f32; 4], - /// Soft glow falloff in pixels; `0.0` means a crisp rounded rectangle. + /// Soft-effect parameter: `0.0` is a crisp rounded rectangle, positive is + /// the legacy radial glow, and negative is the blur radius of a rounded + /// outer shadow. Constructors hide this encoding from call sites. pub feather: f32, /// Explicit corner positions in pixels `[top-left, bottom-left, top-right, /// bottom-right]` for slanted/parallelogram shapes (flat-filled). When set, @@ -131,7 +133,7 @@ impl UiQuad { /// rather than vanishing. Glows (feathered) and explicit-corner polygons /// are soft/slanted by design and pass through untouched. pub fn pixel_snapped(&self) -> Self { - if self.corners.is_some() || self.feather > 0.0 { + if self.corners.is_some() || self.feather.abs() > 0.0 { return *self; } let x1 = (self.x + self.width).round(); @@ -190,6 +192,39 @@ impl UiQuad { } } + /// Soft shadow following a rounded rectangle instead of radiating from its + /// center. The generated geometry includes the blur extent; the shader + /// evaluates the original card shape inside it. `offset_y` provides the + /// small directional cue expected from a floating surface. + #[inline] + pub fn shadow( + x: f32, + y: f32, + width: f32, + height: f32, + radius: f32, + blur: f32, + offset_y: f32, + color: Rgba, + ) -> Self { + let blur = blur.max(1.0); + Self { + x: x - blur, + y: y - blur + offset_y, + width: width + blur * 2.0, + height: height + blur * 2.0, + radius, + corner_radii: [radius; 4], + // 负值区分圆角外阴影与既有的中心径向 glow,避免改变旧调用行为。 + feather: -blur, + corners: None, + v_range: [0.0, 1.0], + color0: color, + color1: color, + gradient: Gradient::None, + } + } + /// Flat-filled gradient polygon from explicit pixel corners /// `[top-left, bottom-left, top-right, bottom-right]` (for powerline slants). #[inline] @@ -447,3 +482,17 @@ impl Drop for UiRenderer { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shadow_geometry_includes_blur_and_vertical_offset() { + let shadow = UiQuad::shadow(10.0, 20.0, 100.0, 80.0, 8.0, 12.0, 4.0, Rgba::new(0, 0, 0, 54)); + assert_eq!((shadow.x, shadow.y), (-2.0, 12.0)); + assert_eq!((shadow.width, shadow.height), (124.0, 104.0)); + assert_eq!(shadow.corner_radii, [8.0; 4]); + assert_eq!(shadow.feather, -12.0); + } +} diff --git a/nebula_app/src/session.rs b/nebula_app/src/session.rs index 06efa7b..f059e2d 100644 --- a/nebula_app/src/session.rs +++ b/nebula_app/src/session.rs @@ -7,15 +7,17 @@ //! restore is attempted and cleared by the first successful autosave, so after //! three failed launches Nebula starts clean to break the cycle. //! -//! v1 restores the tab list + per-tab working directory + active tab. Split -//! trees inside a tab collapse to their focused pane's cwd for now. +//! v2 additionally preserves each tab's custom name and optional color. Split +//! trees inside a tab still collapse to their focused pane's cwd for now. use std::path::PathBuf; use serde::{Deserialize, Serialize}; +use crate::display::color::Rgb; + /// Highest snapshot format this build understands. -const VERSION: u32 = 1; +const VERSION: u32 = 2; /// Give up restoring after this many launches that never reached a successful /// autosave (i.e. crashed within the first second). @@ -25,6 +27,12 @@ const MAX_BOOT_ATTEMPTS: u32 = 3; pub struct TabSession { /// Working directory of the tab's focused pane. pub cwd: String, + /// User override from inline rename. `None` keeps cwd/title-derived labels. + #[serde(default)] + pub custom_name: Option, + /// User-selected tab light-strip color. `None` follows the current theme. + #[serde(default)] + pub color: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -52,7 +60,12 @@ fn session_path() -> PathBuf { /// Load the previous session, if any and version-compatible. pub fn load() -> Option { let data = std::fs::read_to_string(session_path()).ok()?; - let session: Session = serde_json::from_str(&data).ok()?; + let mut session: Session = serde_json::from_str(&data).ok()?; + // v1 has the same outer shape; serde defaults fill the two v2 fields. + // Upgrade in memory so the first successful autosave rewrites it as v2. + if session.version == 1 { + session.version = VERSION; + } (session.version == VERSION).then_some(session) } @@ -88,3 +101,32 @@ pub fn mark_boot_attempt(session: &mut Session) { session.boot_attempts += 1; save(session); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn v1_tabs_deserialize_with_default_metadata() { + let json = r#"{"version":1,"boot_attempts":0,"active_tab":0,"tabs":[{"cwd":"D:/work"}]}"#; + let session: Session = serde_json::from_str(json).unwrap(); + assert_eq!(session.version, 1); + assert_eq!(session.tabs[0].custom_name, None); + assert_eq!(session.tabs[0].color, None); + } + + #[test] + fn v2_round_trip_preserves_tab_name_and_color() { + let session = Session::new( + 0, + vec![TabSession { + cwd: "D:/work".into(), + custom_name: Some("Backend".into()), + color: Some(Rgb::new(97, 175, 239)), + }], + ); + let json = serde_json::to_string(&session).unwrap(); + let restored: Session = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, session); + } +} diff --git a/nebula_app/src/window_context.rs b/nebula_app/src/window_context.rs index 5392e41..0051ec1 100644 --- a/nebula_app/src/window_context.rs +++ b/nebula_app/src/window_context.rs @@ -34,6 +34,8 @@ use nebula_terminal::tty; use crate::cli::{ParsedOptions, WindowOptions}; use crate::clipboard::Clipboard; use crate::config::UiConfig; +use crate::config::ui_config::Profile; +use crate::display::color::Rgb; use crate::display::window::Window; use crate::display::{Display, NebulaPaneState}; use crate::event::{ @@ -122,11 +124,27 @@ struct TabEntry { /// Custom user-assigned name for this tab. When `None`, the label is derived /// from the working directory (Windows Terminal style). custom_name: Option, + /// Optional override for the narrow tab light strip. `None` follows the + /// active theme, so changing themes never leaves stale default colors. + custom_color: Option, + /// Runtime launch identity used by "复制标签页". It deliberately stays out + /// of session restore: reconnecting SSH automatically after restart would + /// be surprising and can trigger credentials/network side effects. + launch: TabLaunch, /// Document viewer content (markdown/text/json). `Some` marks this as a /// doc tab: it has no pane, and the draw/input paths route to the viewer. doc: Option, } +#[derive(Clone)] +enum TabLaunch { + Default, + Profile(Profile), + Shell { name: String, shell: tty::Shell }, + Ssh(String), + Document(std::path::PathBuf), +} + /// How a new window context gets its initial tabs. pub enum WindowBoot { /// Spawn the default shell as a single fresh tab. @@ -397,7 +415,15 @@ impl WindowContext { layout: Layout::Leaf(first_id), active_pane: first_id, has_bell: false, - custom_name: None, + custom_name: restore + .as_ref() + .and_then(|session| session.tabs.first()) + .and_then(|tab| tab.custom_name.clone()), + custom_color: restore + .as_ref() + .and_then(|session| session.tabs.first()) + .and_then(|tab| tab.color), + launch: TabLaunch::Default, doc: None, }], 0, @@ -682,6 +708,10 @@ impl WindowContext { } self.close_tab(index) }, + TabRequest::Duplicate(index) => { + self.duplicate_tab(index); + false + }, TabRequest::CloseWindow => { // A normal window close DETACHES: the PTYs live on in the // resident process, so a running claude/build is not lost @@ -740,6 +770,13 @@ impl WindowContext { self.split_focused(direction); false }, + TabRequest::SplitIndex { index, direction } => { + if self.tabs.get(index).is_some_and(|tab| tab.doc.is_none()) { + self.select_tab(index); + self.split_focused(direction); + } + false + }, TabRequest::DockSplit { source, nav } => { self.dock_tab_into_active(source, nav); false @@ -785,6 +822,15 @@ impl WindowContext { } false }, + TabRequest::SetColor { index, color } => { + if let Some(tab) = self.tabs.get_mut(index) { + tab.custom_color = color; + self.sync_chrome_tabs(); + self.mark_session_dirty(); + self.dirty = true; + } + false + }, TabRequest::CancelRename => { self.display.nebula_tab_rename_select_all = false; if self.display.nebula_tab_rename.take().is_some() { @@ -829,6 +875,8 @@ impl WindowContext { active_pane: id, has_bell: false, custom_name: None, + custom_color: None, + launch: TabLaunch::Default, doc: None, }); self.active_tab = at; @@ -851,6 +899,10 @@ impl WindowContext { /// profile so an `ssh host` entry reads as its destination, not "ssh". fn spawn_tab_profile(&mut self, index: usize) { let Some(profile) = self.config.profiles.get(index).cloned() else { return }; + self.spawn_tab_profile_value(profile); + } + + fn spawn_tab_profile_value(&mut self, profile: Profile) { // Profile cwd wins when it exists; else inherit the focused pane's. let cwd = profile .cwd @@ -865,7 +917,9 @@ impl WindowContext { layout: Layout::Leaf(id), active_pane: id, has_bell: false, - custom_name: Some(profile.name), + custom_name: Some(profile.name.clone()), + custom_color: None, + launch: TabLaunch::Profile(profile), doc: None, }); self.active_tab = at; @@ -879,14 +933,20 @@ impl WindowContext { /// the config, and the cwd inherits the focused pane's. fn spawn_tab_shell(&mut self, name: String, shell: nebula_terminal::tty::Shell) { if let Some(id) = - self.spawn_pane_detached_with(self.focused_cwd(), self.display.size_info, Some(shell)) + self.spawn_pane_detached_with( + self.focused_cwd(), + self.display.size_info, + Some(shell.clone()), + ) { let at = (self.active_tab + 1).min(self.tabs.len()); self.tabs.insert(at, TabEntry { layout: Layout::Leaf(id), active_pane: id, has_bell: false, - custom_name: Some(name), + custom_name: Some(name.clone()), + custom_color: None, + launch: TabLaunch::Shell { name, shell }, doc: None, }); self.active_tab = at; @@ -918,7 +978,9 @@ impl WindowContext { layout: Layout::Leaf(pane_id), active_pane: pane_id, has_bell: false, - custom_name: Some(host), + custom_name: Some(host.clone()), + custom_color: None, + launch: TabLaunch::Ssh(host), doc: None, }); self.active_tab = at; @@ -928,6 +990,14 @@ impl WindowContext { }, Err(err) => { error!("创建直连 SSH Pane 失败: {err}"); + self.message_buffer.push(crate::message_bar::Message::new( + format!( + "SSH {host} 连接创建失败:{err}。请检查地址/SSH 配置,右键编辑后重试。" + ), + crate::message_bar::MessageType::Error, + )); + self.dirty = true; + self.display.window.request_redraw(); return; }, } @@ -963,7 +1033,9 @@ impl WindowContext { layout: Layout::Leaf(id), active_pane: id, has_bell: false, - custom_name: Some(host), + custom_name: Some(host.clone()), + custom_color: None, + launch: TabLaunch::Ssh(host), doc: None, }); self.active_tab = at; @@ -1003,12 +1075,59 @@ impl WindowContext { active_pane: DOC_PANE_ID, has_bell: false, custom_name: Some(label), + custom_color: None, + launch: TabLaunch::Document(doc.path.clone()), doc: Some(doc), }); self.active_tab = at; self.dirty = true; } + /// Duplicate the selected tab next to itself. We copy the launch identity, + /// current cwd, user name and color, but intentionally not the live grid or + /// split tree: a duplicate is a fresh process/session, matching Windows + /// Terminal and avoiding shared PTY ownership. + fn duplicate_tab(&mut self, index: usize) { + let Some(tab) = self.tabs.get(index) else { return }; + let launch = tab.launch.clone(); + let custom_name = tab.custom_name.clone(); + let custom_color = tab.custom_color; + self.select_tab(index); + let before = self.tabs.len(); + + match launch { + TabLaunch::Default => self.spawn_tab(), + TabLaunch::Profile(profile) => self.spawn_tab_profile_value(profile), + TabLaunch::Shell { name, shell } => self.spawn_tab_shell(name, shell), + TabLaunch::Ssh(host) => self.spawn_tab_ssh(host), + TabLaunch::Document(path) => { + let doc = crate::display::markdown_view::DocView::open(path.clone()); + let label = format!("\u{eb1d} {}", doc.title); + let at = (self.active_tab + 1).min(self.tabs.len()); + self.tabs.insert(at, TabEntry { + layout: Layout::Leaf(DOC_PANE_ID), + active_pane: DOC_PANE_ID, + has_bell: false, + custom_name: Some(label), + custom_color: None, + launch: TabLaunch::Document(path), + doc: Some(doc), + }); + self.active_tab = at; + self.dirty = true; + }, + } + + if self.tabs.len() > before { + if let Some(duplicate) = self.tabs.get_mut(self.active_tab) { + duplicate.custom_name = custom_name; + duplicate.custom_color = custom_color; + } + self.sync_chrome_tabs(); + self.mark_session_dirty(); + } + } + /// Rebuild the remaining tabs of a restored session (its first tab became /// the initial pane) and refocus the tab that was active at close. fn restore_session_tabs(&mut self, session: &session::Session) { @@ -1021,7 +1140,9 @@ impl WindowContext { layout: Layout::Leaf(id), active_pane: id, has_bell: false, - custom_name: None, + custom_name: tab.custom_name.clone(), + custom_color: tab.color, + launch: TabLaunch::Default, doc: None, }); self.run_fastfetch_intro(id); @@ -1102,6 +1223,8 @@ impl WindowContext { .pane(t.active_pane) .map(|p| p.nebula_state.cwd.trim().to_owned()) .unwrap_or_default(), + custom_name: t.custom_name.clone(), + color: t.custom_color, }) .collect(); session::Session::new(self.active_tab, tabs) @@ -1761,6 +1884,7 @@ impl WindowContext { } let mut labels = Vec::with_capacity(self.tabs.len()); + let mut colors = Vec::with_capacity(self.tabs.len()); let mut dots = Vec::with_capacity(self.tabs.len()); let mut running = Vec::with_capacity(self.tabs.len()); let mut logos = Vec::with_capacity(self.tabs.len()); @@ -1786,6 +1910,7 @@ impl WindowContext { } logos.push(logo); labels.push(label); + colors.push(tab.custom_color); // Unseen-result dot: bell in a background tab, a tracked command // that finished unseen, or a tracked program parked at "waiting // for input" (claude between turns). The ring collapsing into a @@ -1802,7 +1927,7 @@ impl WindowContext { } let active = self.active_tab.min(labels.len().saturating_sub(1)); // displayed == storage index always holds now, so the bar is reorderable. - self.display.set_chrome_tabs(labels, dots, running, logos, active, true); + self.display.set_chrome_tabs(labels, colors, dots, running, logos, active, true); } fn chrome_tab_label(pane: &Pane) -> String {