checkpoint: stabilize terminal UX before motion refactor

This commit is contained in:
kuddev committed 2026-07-14 23:47:52 +08:00
1 parent 5513afd573
commit aeadf4fb61
18 files changed
+836 -233

No files matched your search

+3 -1
View File
@@ -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,并替换现有发布压缩包。
+12
View File
@@ -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.
+8 -4
View File
@@ -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(),
+4 -4
View File
@@ -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,
@@ -1461,7 +1461,7 @@ pub(super) fn draw_chrome(d: &mut Display) {
// 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,
+52 -35
View File
@@ -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,9 +764,14 @@ 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));
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();
@@ -786,7 +786,6 @@ pub(super) fn push_quads(
sk.accent_soft,
));
}
}
// Hover background: subtle highlight when the mouse is over a row.
if let Some(hover_row) = model.hover {
@@ -845,19 +844,15 @@ 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);
@@ -867,10 +862,15 @@ pub(super) fn draw_text(
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);
let placeholder = if model.profiles_only {
"搜索 Shell 或 Profile…"
} else {
"搜索命令、Shell 或 Profile…"
};
r.draw_chrome_text(size, query_x + s(10.0), text_y, sk.ink_dim, placeholder, gc);
} else {
// Show query + cursor at the end
let shown = if model.query_all_selected() {
query.to_owned()
} else {
@@ -878,7 +878,6 @@ pub(super) fn draw_text(
};
r.draw_chrome_text(size, query_x, text_y, sk.ink_strong, &shown, gc);
}
}
if model.is_empty() {
r.draw_chrome_text(size, text_x, layout.list_y + s(8.0), sk.ink_dim, "无匹配命令", gc);
@@ -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]
+130 -14
View File
@@ -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 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;
// 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;
}
(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 {
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;
// 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.;
cell.bg_alpha = 1.0;
}
}
return Some(cell);
@@ -253,9 +296,23 @@ impl RenderableCell {
character = c.unwrap_or(character);
} else if is_selected {
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<usize>,
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.
+44 -20
View File
@@ -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(
+32 -12
View File
@@ -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)],
+117
View File
@@ -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);
}
}
+117 -80
View File
@@ -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<usize>,
pinned_index: Option<usize>,
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<String>,
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<T = f32> {
/// 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<SizeInfo<f32>> for SizeInfo<u32> {
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<T: Clone + Copy> SizeInfo<T> {
pub fn padding_y(&self) -> T {
self.padding_y
}
#[inline]
pub fn padding_bottom(&self) -> T {
self.padding_bottom
}
}
impl SizeInfo<f32> {
@@ -1210,7 +1162,16 @@ impl SizeInfo<f32> {
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<f32> {
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<f32> {
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<f32> {
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<String> {
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);
}
}
+73
View File
@@ -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<usize>,
pub(super) pinned_index: Option<usize>,
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<String>,
pub destination: String,
pub password: String,
pub save_password: bool,
pub show_password: bool,
pub field: SshEditorField,
pub error: Option<String>,
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),
}
+1 -3
View File
@@ -2860,9 +2860,7 @@ impl input::Processor<EventProxy, ActionContext<'_, Notifier, EventProxy>> {
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.
+3 -41
View File
@@ -255,47 +255,9 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
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(),
-4
View File
@@ -793,12 +793,10 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
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<T: EventListener, A: ActionContext<T>> Processor<T, A> {
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 {
+10 -1
View File
@@ -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());
}
}
+51 -2
View File
@@ -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);
}
}
+46 -4
View File
@@ -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<String>,
/// User-selected tab light-strip color. `None` follows the current theme.
#[serde(default)]
pub color: Option<Rgb>,
}
#[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<Session> {
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);
}
}
+133 -8
View File
@@ -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<String>,
/// Optional override for the narrow tab light strip. `None` follows the
/// active theme, so changing themes never leaves stale default colors.
custom_color: Option<Rgb>,
/// 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<crate::display::markdown_view::DocView>,
}
#[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 {