checkpoint: stabilize terminal UX before motion refactor
This commit is contained in:
1 parent
5513afd573
commit
aeadf4fb61
18 files changed
+879
-276
No files matched your search
@@ -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,并替换现有发布压缩包。
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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<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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user