refactor: extract a display/ui layer with one surface recipe

改一条视觉规则要动十个文件——这是"配色改不干净"的结构性原因,不是审美问题。
侦察数据:`(x-1, y-1, w+2, h+2)` 的手写描边配方在 display/ 下出现 95 处;
八个浮层用了六种圆角(14/12/12/10/9/8);tokens 明明定义了 space::M = 16,
代码里却有 26 个手写的 s(16.0)。

建立 display/ui/ 作为所有自绘 chrome 的唯一配方来源:

- git mv 五个模块进 ui/(历史保留):design_tokens→tokens、theme、widgets、
  keycap、icons。ui/mod.rs 写明层契约——纯几何+quad 输出、不持有 Display 状态、
  布局/绘制/命中检测调同一个 rect helper、不写 tokens 已覆盖的魔数。
- 新增 ui/surface.rs:push_surface / push_stroke / push_card / push_input。
  Elevation 枚举把"是否画遮罩"编码进类型(Menu/Popover 不画,Modal 才画),
  因为遮罩传达的是一份模态承诺,随手开关的浮层不该给。
  push_stroke 的外圆角自动取 radius + hairline,保证内外弧同心——手写处常常
  内外用同一半径,描边在圆角处会变粗。
- tokens 补 radius 阶梯(OVERLAY 8 / CONTROL 6 / CHIP 4,对齐 Fluent 2 而非
  macOS 的 10-12),elevation 按浮层面积分三档:一组适合小菜单的阴影参数放在
  整块命令面板下面托不住。
- context_menu 首个接入 push_surface,删掉它本地的 alpha 函数改用 surface::fade。

keycap 的 pub(super) 提升为 pub(super 从 display 变成了 ui);
widgets 的 super::contains_rect 改绝对路径。

两处视觉变化(其余为纯结构改动):
- context_menu 面板圆角 9 → 8(收敛到 radius::OVERLAY)
- 浅色 skin.panel #FAFAFB → slate-100 #F1F5F9:与 card 的明度差从 5 级拉到 13 级。
  低于 8 级肉眼分不开,界面就只能靠画边框救场——边框一多画面就碎。

新增 5 个测试(描边环同心、hairline 不低于 1 物理像素、Popover 不产生遮罩 quad、
阴影按层级递增、fade 边界),451 passed。

设计依据见 docs/nebula-visual-design-language.md(逐图证据 + Nielsen/CRAP/
Fitts 映射 + WinUI 3 的一票否决理由)。
This commit is contained in:
kuddev committed 2026-07-29 15:57:47 +08:00
1 parent 0c4f0515ae
commit f6a2bb4009
17 files changed
+12103 -11777

No files matched your search

+2 -1
View File
@@ -11,6 +11,7 @@
#![allow(clippy::wildcard_imports)]
use super::*;
use super::ui::{icons, tokens};
/// Result of hit-testing a pixel against the Nebula top chrome bar.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -1616,7 +1617,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 = design_tokens::type_scale::SUPPORTING;
const HINT_SCALE: f32 = 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);
+19 -12
View File
@@ -961,7 +961,7 @@ pub fn palette_layout(
let s = |v: f32| v * scale;
let margin = s(8.0);
let pad = s(12.0);
let row_h = s(super::design_tokens::control::COMPACT_ROW);
let row_h = s(super::ui::tokens::control::COMPACT_ROW);
// 搜索框与结果行等高:输入仍然可发现,但不会压过真正的数据内容。
let input_h = row_h;
let cards = model.is_picker();
@@ -1095,7 +1095,14 @@ pub(super) fn push_quads(
// (浅色主题还因 alpha 差露出脏渐变)。
quads.push(UiQuad::solid(px, py, pw, ph, s(14.0), sk.panel));
quads.push(UiQuad::solid(ix, iy, iw, ih, s(super::design_tokens::control::RADIUS), sk.input));
quads.push(UiQuad::solid(
ix,
iy,
iw,
ih,
s(super::ui::tokens::radius::CONTROL),
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();
@@ -1115,14 +1122,14 @@ pub(super) fn push_quads(
// Ctrl+K 键帽(图4/图8):仅 shell picker 空查询时展示,指认打开快捷
// 键;输入开始后让位给查询文字。
if model.mode == PaletteMode::Profiles && model.query.is_empty() {
let combo = super::keycap::layout_combo(
let combo = super::ui::keycap::layout_combo(
"Ctrl+K",
ix + iw - s(GUTTER),
iy + ih / 2.0,
cell_w,
scale,
);
super::keycap::push_combo(quads, &sk, &combo, scale);
super::ui::keycap::push_combo(quads, &sk, &combo, scale);
}
if let Some((fx, fy, fw, fh)) = layout.footer {
@@ -1223,7 +1230,7 @@ pub(super) fn push_quads(
// 图8 规范:命令列表右侧的快捷键 hint 画成逐键 chip 底(键名在文
// 字 pass)。挤不下就整组让位,不压进标签。
if !cards && !entry.hint.is_empty() {
let combo = super::keycap::layout_combo(
let combo = super::ui::keycap::layout_combo(
&entry.hint,
ix + iw - s(GUTTER),
ry + rh / 2.0,
@@ -1234,7 +1241,7 @@ pub(super) fn push_quads(
+ entry.label.chars().map(|c| c.width().unwrap_or(1)).sum::<usize>() as f32
* cell_w;
if combo.bounds.0 > label_end + s(HINT_GAP) {
super::keycap::push_combo(quads, &sk, &combo, scale);
super::ui::keycap::push_combo(quads, &sk, &combo, scale);
}
}
if !entry.is_default || (hero && row == 0) {
@@ -1260,11 +1267,11 @@ pub(super) fn push_quads(
if let Some((_, fy, _, fh)) = layout.footer {
// 图8 键帽规范:底栏键帽与 Ctrl+K/设置页共用同一 chip 配方。底栏
// 背景横贯面板,但里面的文字与卡片列对齐(走 ix/iw,不是 panel)。
let key_h = s(super::keycap::KEY_H);
let key_h = s(super::ui::keycap::KEY_H);
let key_y = fy + (fh - key_h) * 0.5;
let mut chip = |quads: &mut Vec<UiQuad>, x: f32, cols: usize| {
let width = cols as f32 * cell_w + s(8.0);
super::keycap::push_chip(quads, &sk, x, key_y, width, key_h, scale);
super::ui::keycap::push_chip(quads, &sk, x, key_y, width, key_h, scale);
};
chip(quads, ix, 3);
chip(quads, ix + iw * 0.42, 5);
@@ -1391,7 +1398,7 @@ pub(super) fn draw_text(
}
// Ctrl+K 键帽文字(chip 底在 quad pass,几何同源)。
if model.mode == PaletteMode::Profiles && model.query.is_empty() {
let combo = super::keycap::layout_combo(
let combo = super::ui::keycap::layout_combo(
"Ctrl+K",
ix + iw - s(GUTTER),
iy + ih / 2.0,
@@ -1479,7 +1486,7 @@ pub(super) fn draw_text(
if !cards {
// 图8 规范:命令列表快捷键 hint 逐键 chip;与 quad pass 用
// 同一 layout_combo 几何与让位守卫。
let combo = super::keycap::layout_combo(
let combo = super::ui::keycap::layout_combo(
&hint,
ix + iw - s(GUTTER),
row_y + row_hh / 2.0,
@@ -1537,10 +1544,10 @@ fn draw_combo_text(
r: &mut Renderer,
gc: &mut GlyphCache,
size: &SizeInfo,
combo: &super::keycap::ComboChips,
combo: &super::ui::keycap::ComboChips,
cell_w: f32,
cell_h: f32,
sk: &super::theme::Skin,
sk: &super::ui::theme::Skin,
) {
let (_, key_y, _, key_h) = combo.bounds;
let ty = key_y + (key_h - cell_h) / 2.0;
+2 -2
View File
@@ -15,7 +15,7 @@ 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::ui::tokens::terminal_feedback;
use crate::display::hint::{self, HintState};
use crate::display::terminal_color::{TerminalColorResolver, is_fixed_color};
use crate::display::{Display, SizeInfo};
@@ -640,7 +640,7 @@ mod tests {
};
use crate::config::color::NEBULA_DEFAULT_CURSOR;
use crate::display::color::Rgb;
use crate::display::design_tokens::terminal_feedback;
use crate::display::ui::tokens::terminal_feedback;
use nebula_terminal::index::{Column, Line, Point};
use nebula_terminal::term::cell::Flags;
+14 -35
View File
@@ -4,7 +4,8 @@
//! 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 super::design_tokens::{control, elevation, space};
use super::ui::surface::{self, fade as alpha};
use super::ui::tokens::{control, space};
use super::*;
// Maple Mono Normal NF CN ships the stable Codicon block below. Keep menu
@@ -468,10 +469,6 @@ pub(super) fn hit_test(
if contains(layout.panel, x, y) { ContextMenuHit::Panel } else { ContextMenuHit::Outside }
}
fn alpha(color: Rgba, opacity: f32) -> Rgba {
Rgba::new(color.r, color.g, color.b, (color.a as f32 * opacity).round() as u8)
}
fn fade_ink(base: Rgba, ink: Rgb, opacity: f32) -> Rgb {
let mix = |a: u8, b: u8| (a as f32 + (b as f32 - a as f32) * opacity).round() as u8;
Rgb::new(mix(base.r, ink.r), mix(base.g, ink.g), mix(base.b, ink.b))
@@ -494,37 +491,19 @@ pub(super) fn draw(display: &mut Display) {
let s = |v: f32| v * scale;
let layout = layout(&menu, size, scale, -s(5.0) * (1.0 - progress));
let sk = display.nebula_theme.skin();
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::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)),
];
// 阴影 + 发丝环 + 面板填充的配方现在只有一处定义(`ui::surface`)。
// 右键菜单是 Menu 层级:不画遮罩,阴影按小浮层的扩散取值。
let mut quads = Vec::new();
surface::push_surface(
&mut quads,
layout.panel,
(size.width(), size.height()),
scale,
&sk,
surface::Elevation::Menu,
progress,
);
for separator in &layout.separators {
quads.push(UiQuad::solid(
separator.0,
+1 -1
View File
@@ -36,7 +36,7 @@ use crate::renderer::ui::UiQuad;
use crate::renderer::{GlyphCache, Renderer};
use super::SizeInfo;
use super::theme::Skin;
use super::ui::theme::Skin;
/// Extensions the viewer opens (double-click in the file tree). Everything
/// else keeps the tree's default activation behaviour.
@@ -8,7 +8,7 @@
use nebula_terminal::term::cell::Flags;
use super::color::Rgb;
use super::theme::Skin;
use super::ui::theme::Skin;
use super::{SizeInfo, UiLanguage};
use crate::renderer::ui::{Gradient, Rgba, UiQuad};
use crate::renderer::{GlyphCache, Renderer};
+8075 -8079
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -2,6 +2,7 @@ use unicode_width::UnicodeWidthChar;
use super::ssh_ui::{SshTestState, auth_sections};
use super::*;
use super::ui::theme;
use crate::ssh_profiles::SshAuthMode;
type Rect = (f32, f32, f32, f32);
+2 -2
View File
@@ -4,7 +4,7 @@ use nebula_terminal::term::color::Colors;
use nebula_terminal::vte::ansi::Color;
use super::color::Rgb;
use super::design_tokens::terminal_feedback::FIXED_TEXT_MIN_CONTRAST;
use super::ui::tokens::terminal_feedback::FIXED_TEXT_MIN_CONTRAST;
const CACHE_ENTRY_LIMIT: usize = 4096;
const SURFACE_CHANNEL_TOLERANCE: u8 = 32;
@@ -205,7 +205,7 @@ mod tests {
use super::{TerminalColorResolver, is_fixed_color};
use crate::display::color::Rgb;
use crate::display::design_tokens::terminal_feedback::FIXED_TEXT_MIN_CONTRAST;
use crate::display::ui::tokens::terminal_feedback::FIXED_TEXT_MIN_CONTRAST;
const LIGHT_FOREGROUND: Rgb = Rgb::new(36, 41, 47);
const LIGHT_BACKGROUND: Rgb = Rgb::new(255, 255, 255);
File renamed without changes.
@@ -11,14 +11,14 @@ use super::theme::Skin;
use crate::renderer::ui::UiQuad;
/// Chip height in logical px(与 palette 底栏既有键帽一致)。
pub(super) const KEY_H: f32 = 20.0;
pub const KEY_H: f32 = 20.0;
fn cols(text: &str) -> usize {
text.chars().map(|c| c.width().unwrap_or(0)).sum()
}
/// One rendered key chip: `(x, w, key)`;`pluses` 是分隔加号的绘制 X。
pub(super) struct ComboChips {
pub struct ComboChips {
pub chips: Vec<(f32, f32, String)>,
pub pluses: Vec<f32>,
/// Bounding box `(x, y, w, h)` across every chip.
@@ -27,7 +27,7 @@ pub(super) struct ComboChips {
/// Lay out a "Ctrl+Shift+P" style combo as per-key chips, right-aligned so
/// the last chip ends at `right_x`, vertically centered on `center_y`.
pub(super) fn layout_combo(
pub fn layout_combo(
combo: &str,
right_x: f32,
center_y: f32,
@@ -57,7 +57,7 @@ pub(super) fn layout_combo(
}
/// The one chip recipe: hairline ring + panel + surface, radius 4.
pub(super) fn push_chip(
pub fn push_chip(
quads: &mut Vec<UiQuad>,
sk: &Skin,
x: f32,
@@ -73,7 +73,7 @@ pub(super) fn push_chip(
}
/// Push every chip of a laid-out combo.
pub(super) fn push_combo(quads: &mut Vec<UiQuad>, sk: &Skin, combo: &ComboChips, scale: f32) {
pub fn push_combo(quads: &mut Vec<UiQuad>, sk: &Skin, combo: &ComboChips, scale: f32) {
let (_, y, _, h) = combo.bounds;
for &(x, w, _) in &combo.chips {
push_chip(quads, sk, x, y, w, h, scale);
+48
View File
@@ -0,0 +1,48 @@
//! Nebula 的 UI 层:所有自绘 chrome 的唯一配方来源。
//!
//! # 这一层为什么存在
//!
//! 2026-07-29 的侦察结果:`(x-1, y-1, w+2, h+2)` 的手写描边配方在 `display/`
//! 下出现 **95 处**;八个浮层用了 **六种**不同圆角(14/12/12/10/9/8);
//! `tokens` 里明明定义好了 `space::M = 16`,代码里却有 26 个手写的 `s(16.0)`。
//!
//! 后果是改一条视觉规则要动十个文件,且必然漏掉几处——「统一配色」在这种结构
//! 下不可能做到。本层把配方收敛成函数,让视觉规则只有一个落点。
//!
//! # 层契约(新增代码必须遵守)
//!
//! 1. **纯几何 + quad 输出。** 不持有 [`Display`](crate::display::Display) 状态,
//! 不做 hover 决策,不读配置。输入是矩形和 [`Skin`](theme::Skin),输出是 quad。
//!
//! 2. **布局、绘制、命中检测调同一个 rect helper。** 一个控件的可点击区域永远
//! 不会与它的绘制区域漂移——这类 bug 曾让手写的逐页控件极难调试。
//!
//! 3. **文字不在这里画。** 渲染器的文字是独立 pass(避免不透明 cell 背景),
//! 本层只暴露几何(`*_rect`、`layout_*`),文字 pass 复用同一份坐标。
//!
//! 4. **不写魔数。** 尺寸走 [`tokens::space`],圆角走 [`tokens::radius`],
//! 阴影走 [`tokens::elevation`]。`tokens` 没有的值说明该往 `tokens` 加。
//!
//! # 模块分工
//!
//! - [`tokens`] —— 间距、圆角、字阶、动效、层级的常量目录
//! - [`theme`] —— [`Skin`](theme::Skin):主题派生的墨色与表面色
//! - [`surface`] —— 面板/卡片/描边/遮罩的配方(本层的核心)
//! - [`widgets`] —— slider / toggle / combobox / spinner
//! - [`keycap`] —— 键帽 chip
//! - [`icons`] —— 图标墨迹
//!
//! # 未来
//!
//! 本目录的内容就是将来 `nebula_ui` crate 的内容。抽 crate 的前置条件是打破
//! `renderer` ↔ `display` 的循环依赖(`renderer/ui.rs` 用 `display::SizeInfo`
//! 和 `display::color::Rgb`,而 `theme.rs` 反过来用 `renderer::ui::Rgba`),
//! 需要先把 `Rgb`/`Rgba`/`UiQuad`/`SizeInfo` 这批纯数据类型下沉到共享底座。
//! 那一步的收益是编译器强制单向依赖 + 独立编译单元(改 UI 不再重编译整个 app)。
pub mod icons;
pub mod keycap;
pub mod surface;
pub mod theme;
pub mod tokens;
pub mod widgets;
+247
View File
@@ -0,0 +1,247 @@
//! 浮层配方:面板、卡片、描边、遮罩。本层的核心。
//!
//! # 为什么需要它
//!
//! 2026-07-29 侦察:`(x-1, y-1, w+2, h+2)` + hairline + 填充这套描边配方在
//! `display/` 下手写了 **95 处**,八个浮层用了**六种**圆角。结果是任何一条视觉
//! 规则(明度阶梯、阴影、遮罩、圆角)都无法一次改全——这正是"配色改不干净"的
//! 结构性原因。
//!
//! 这里的函数是那些配方的唯一来源。新的浮层不应该再手写 quad 序列。
//!
//! # 遮罩的二分(重要)
//!
//! [`Elevation`] 把「是否画遮罩」编码进类型,而不是留给调用方决定。理由是遮罩
//! 传达的是一份**模态承诺**——「我打断了你,请先处理我」。随手开关的浮层给出这
//! 份承诺,会凭空抬高用户的心理成本,还会遮住它自己经常需要被参考的上下文。
//!
//! otty 的命令面板有像素证据:面板打开时背景终端文字和侧栏亮度完全未变,
//! 浮起靠的是「明度对比 + 大扩散阴影」,不是压暗背景。
//! 详见 `docs/nebula-visual-design-language.md` 裁定三。
use super::theme::Skin;
use super::tokens::{control, elevation, radius};
use crate::renderer::ui::{Rgba, UiQuad};
/// `(x, y, width, height)`,逻辑像素。
pub type Rect = (f32, f32, f32, f32);
/// 浮层的高度层级。决定阴影扩散、圆角,以及**是否画遮罩**。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Elevation {
/// 小浮层:右键菜单、下拉 popup、tooltip。不画遮罩。
Menu,
/// 大浮层:命令面板、shell 选择器。可 Esc、无后果、随手开关——
/// **不画遮罩**,靠明度对比与大扩散阴影浮起。
Popover,
/// 模态:确认框、设置页、SSH 编辑器。要求决策、有后果,
/// 画遮罩(冷调压暗,不是白雾)。
Modal,
}
impl Elevation {
/// `(blur, offset_y)`,逻辑像素。阴影扩散必须与被托举的面积成比例:
/// 一组适合小菜单的参数放在整块命令面板下面就托不住。
#[inline]
fn shadow(self) -> (f32, f32) {
match self {
Self::Menu => (elevation::MENU_BLUR, elevation::MENU_OFFSET_Y),
Self::Popover => (elevation::POPOVER_BLUR, elevation::POPOVER_OFFSET_Y),
Self::Modal => (elevation::MODAL_BLUR, elevation::MODAL_OFFSET_Y),
}
}
/// 只有模态阻断交互,所以只有模态画遮罩。
#[inline]
pub fn dims_background(self) -> bool {
matches!(self, Self::Modal)
}
}
/// 浮层内容块的状态。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CardState {
Default,
Hover,
/// 当前选中项。强调色预算一屏只花一次,就花在这里——
/// 不要同时给推荐卡、导航 pill 也染色。
Selected,
}
/// 按入场进度衰减 alpha。`progress` 取 0..1,1 为完全不透明。
#[inline]
pub fn fade(color: Rgba, progress: f32) -> Rgba {
if progress >= 1.0 {
return color;
}
Rgba::new(color.r, color.g, color.b, (color.a as f32 * progress.clamp(0.0, 1.0)).round() as u8)
}
/// 发丝描边环。替代散落各处的手写 `(x-1, y-1, w+2, h+2)`。
///
/// 外圆角自动取 `radius + hairline`,保证内外弧**同心**——手写处常常内外用同一
/// 个半径,那样描边在圆角处会变粗,是"边缘发毛"的来源之一。
///
/// `radius` 传入的是**内**圆角(已乘过 scale)。
pub fn push_stroke(quads: &mut Vec<UiQuad>, rect: Rect, radius: f32, scale: f32, color: Rgba) {
let hairline = (control::HAIRLINE * scale).max(1.0);
let (x, y, w, h) = rect;
quads.push(UiQuad::solid(
x - hairline,
y - hairline,
w + hairline * 2.0,
h + hairline * 2.0,
radius + hairline,
color,
));
}
/// 浮层的底:遮罩(仅 [`Elevation::Modal`])→ 外阴影 → 发丝环 → 面板填充。
///
/// `viewport` 是整窗尺寸 `(width, height)`,只有模态用得上。
/// `progress` 是入场动画进度,所有颜色按它衰减。
pub fn push_surface(
quads: &mut Vec<UiQuad>,
rect: Rect,
viewport: (f32, f32),
scale: f32,
sk: &Skin,
level: Elevation,
progress: f32,
) {
let (x, y, w, h) = rect;
let corner = radius::OVERLAY * scale;
if level.dims_background() {
quads.push(UiQuad::solid(0.0, 0.0, viewport.0, viewport.1, 0.0, fade(sk.veil, progress)));
}
// 外阴影承担 Z 轴层级;发丝环只负责在浅色背景上定义边界。两者分工不同,
// 缺了阴影就只剩一条线,浮层会"贴"在背景上而不是浮起来。
let (blur, offset_y) = level.shadow();
let shadow_alpha = if sk.is_light {
elevation::SHADOW_ALPHA_LIGHT
} else {
elevation::SHADOW_ALPHA_DARK
};
quads.push(UiQuad::shadow(
x,
y,
w,
h,
corner,
blur * scale,
offset_y * scale,
fade(Rgba::new(0, 0, 0, shadow_alpha), progress),
));
push_stroke(quads, rect, corner, scale, fade(sk.hairline, progress));
quads.push(UiQuad::solid(x, y, w, h, corner, fade(sk.panel, progress)));
}
/// 浮层内部的内容块。
pub fn push_card(quads: &mut Vec<UiQuad>, rect: Rect, scale: f32, sk: &Skin, state: CardState) {
let (x, y, w, h) = rect;
let corner = radius::OVERLAY * scale;
let fill = match state {
CardState::Default => sk.card,
CardState::Hover => sk.hover,
CardState::Selected => sk.accent_soft,
};
quads.push(UiQuad::solid(x, y, w, h, corner, fill));
}
/// 下沉表面(文本输入框)。聚焦时描边换成强调色——焦点不能只靠颜色深浅表示。
pub fn push_input(quads: &mut Vec<UiQuad>, rect: Rect, scale: f32, sk: &Skin, focused: bool) {
let (x, y, w, h) = rect;
let corner = radius::CONTROL * scale;
let stroke = if focused {
Rgba::new(sk.accent.r, sk.accent.g, sk.accent.b, 255)
} else {
sk.hairline
};
push_stroke(quads, rect, corner, scale, stroke);
quads.push(UiQuad::solid(x, y, w, h, corner, sk.input));
}
#[cfg(test)]
mod tests {
use super::*;
use crate::display::ui::theme::NebulaTheme;
fn light() -> Skin {
NebulaTheme::SilverLight.skin()
}
#[test]
fn stroke_ring_stays_concentric_with_its_fill() {
// 内外弧同心的判据:外半径 - 内半径 == 描边宽度。不满足时描边在圆角
// 处会变粗,边缘发毛。
let mut quads = Vec::new();
let rect = (100.0, 50.0, 200.0, 80.0);
push_stroke(&mut quads, rect, 8.0, 1.0, Rgba::new(0, 0, 0, 40));
let ring = &quads[0];
assert_eq!(ring.radius - 8.0, 1.0, "外圆角必须等于内圆角 + 描边宽");
assert_eq!((ring.x, ring.y), (99.0, 49.0));
assert_eq!((ring.width, ring.height), (202.0, 82.0));
}
#[test]
fn stroke_hairline_never_falls_below_one_physical_pixel() {
// 缩放小于 1 时 hairline 会算出亚像素宽度,在整数像素栅格上会消失。
let mut quads = Vec::new();
push_stroke(&mut quads, (0.0, 0.0, 10.0, 10.0), 4.0, 0.5, Rgba::new(0, 0, 0, 40));
assert_eq!(quads[0].width, 12.0, "描边宽应被钳到 1px,而不是 0.5px");
}
#[test]
fn only_modal_dims_the_background() {
// 裁定三的回归防线:popover 加遮罩会凭空给出「我打断了你」的承诺,
// 并遮住它自己经常需要被参考的上下文。
assert!(!Elevation::Menu.dims_background());
assert!(!Elevation::Popover.dims_background());
assert!(Elevation::Modal.dims_background());
let sk = light();
for level in [Elevation::Menu, Elevation::Popover] {
let mut quads = Vec::new();
push_surface(&mut quads, (10.0, 10.0, 100.0, 60.0), (800.0, 600.0), 1.0, &sk, level, 1.0);
assert!(
!quads.iter().any(|q| q.width >= 800.0 && q.height >= 600.0),
"{level:?} 不该产生任何整窗遮罩 quad",
);
assert_eq!(quads.len(), 3, "{level:?} 应只有阴影 + 发丝环 + 填充");
}
let mut quads = Vec::new();
push_surface(
&mut quads,
(10.0, 10.0, 100.0, 60.0),
(800.0, 600.0),
1.0,
&sk,
Elevation::Modal,
1.0,
);
let veil = &quads[0];
assert_eq!((veil.x, veil.y, veil.width, veil.height), (0.0, 0.0, 800.0, 600.0));
}
#[test]
fn shadow_spread_scales_with_surface_size() {
// 小菜单的阴影参数放在整块面板下面托不住,所以按层级分档。
let (menu_blur, _) = Elevation::Menu.shadow();
let (popover_blur, _) = Elevation::Popover.shadow();
let (modal_blur, _) = Elevation::Modal.shadow();
assert!(menu_blur < popover_blur && popover_blur < modal_blur);
}
#[test]
fn fade_is_identity_at_full_progress() {
let c = Rgba::new(10, 20, 30, 200);
assert_eq!(fade(c, 1.0).a, 200);
assert_eq!(fade(c, 0.5).a, 100);
assert_eq!(fade(c, 0.0).a, 0);
}
}
@@ -430,34 +430,52 @@ impl NebulaTheme {
let t = p.term_bg;
if p.is_light {
Skin {
// 2026-07-29 用户裁定(图6 吸色):浅色主题的浮层统一"白色
// 打底"——面板近白、卡片纯白,SSH 编辑器与 shell 选择器共
// 用这一组,不再继承主题族的银/岩灰底。
panel: Rgba::new(250, 250, 251, 252),
// 明度阶梯(2026-07-29 裁定,见 docs/nebula-visual-design-language.md
// §3):相邻层必须差 8–14 级,低于 8 级肉眼分不开,界面就只能靠
// 画边框救场——边框一多画面就碎,这才是"脏"的观感来源。
//
// 浅色下浮层的方向是**比内容更暗**(otty 浅色命令面板底是
// #F7F7F7,盖住的终端区是纯白;Fluent 同构:base #F3F3F3 →
// layer #FFFFFF)。此前 panel 250 与 card 255 只差 5 级,等于
// 没有层级。现在 241 → 254 差 13,且比纯白终端暗 14。
//
// 这不推翻"白色打底"的裁定:slate-100 依然是近白冷调,不是回到
// 主题族的银/岩灰底;变的是它与卡片之间终于有了可见的台阶。
panel: Rgba::new(241, 245, 249, 252), // slate-100
// A white inset on light panels: the hairline carries the
// "sunken" read, the fill stays cleaner than a gray wash.
input: Rgba::new(255, 255, 255, 240),
card: Rgba::new(255, 255, 255, 244),
veil: Rgba::new(255, 255, 255, 190),
ink: Rgb::new(55, 65, 81), // #374151 (ui-text-main)
ink_dim: Rgb::new(107, 114, 128), // #6b7280 (ui-text-muted)
ink_strong: Rgb::new(18, 22, 30),
ink_faint: Rgb::new(148, 153, 163),
// modal 专用(popover 不画遮罩,见设计文档裁定三)。冷调压暗
// 而不是白雾:白雾 75% 把背景提亮到和白弹窗同明度,弹窗反而
// 浮不起来,且糊掉全部上下文。20% 压暗下背景仍然可读。
veil: Rgba::new(15, 23, 42, 51), // slate-900 @ 20%
ink: Rgb::new(51, 65, 85), // slate-700
ink_dim: Rgb::new(100, 116, 139), // slate-500
ink_strong: Rgb::new(15, 23, 42), // slate-900
ink_faint: Rgb::new(148, 163, 184), // slate-400
// Light accents are dark grays — pale ink on top.
ink_on_accent: Rgb::new(248, 250, 252),
icon: Rgb::new(73, 80, 87),
icon_hover: Rgb::new(18, 22, 26),
icon: Rgb::new(71, 85, 105), // slate-600
icon_hover: Rgb::new(15, 23, 42), // slate-900
accent: a,
accent_soft: Rgba::new(a.r, a.g, a.b, 34),
danger: Rgba::new(196, 74, 88, 255),
hairline: Rgba::new(0, 0, 0, 20), // rgba(0,0,0,.08) — hairline borders
surface: Rgba::new(0, 0, 0, 12),
hover: Rgba::new(0, 0, 0, 20),
hover_strong: Rgba::new(0, 0, 0, 32),
track_off: Rgba::new(0, 0, 0, 48),
// 2026-07-29 用户裁定:中性灰在屏幕上永远显脏。此前这几个
// 叠加色是纯黑 rgba(0,0,0,.05~.19),而纯黑叠在白底上只能得
// 到**零色相**的死灰——这就是"不干净"的物理来源。现在改叠
// Slate(掺 3–6% 蓝的冷调灰):浅色主题叠深 slate,深色主题
// 叠浅 slate,两边用不同基色,叠加后色相才不会被纯黑/纯白冲
// 淡。alpha 按 (255-底)/(基色-底) 换算过,明度与旧值持平,
// 变的只是色温。
hairline: Rgba::new(51, 65, 85, 30), // slate-700 @ 12%
surface: Rgba::new(100, 116, 139, 20), // slate-500 @ 8%
hover: Rgba::new(100, 116, 139, 33),
hover_strong: Rgba::new(100, 116, 139, 52),
track_off: Rgba::new(100, 116, 139, 78),
knob_off: Rgba::new(255, 255, 255, 255),
knob_on: Rgba::new(250, 250, 250, 255),
scrollbar_thumb: Rgba::new(60, 66, 80, 0),
knob_on: Rgba::new(248, 250, 252, 255), // slate-50
scrollbar_thumb: Rgba::new(71, 85, 105, 0), // slate-600
is_light: true,
}
} else {
@@ -469,25 +487,28 @@ impl NebulaTheme {
input: Rgba::new(t.r, t.g, t.b, 220),
card: Rgba::new(255, 255, 255, 16),
veil: Rgba::new(0, 0, 0, 150),
ink: Rgb::new(228, 231, 246),
ink_dim: Rgb::new(158, 164, 188),
ink_strong: Rgb::new(255, 255, 255),
ink_faint: Rgb::new(118, 124, 148),
ink: Rgb::new(226, 232, 240), // slate-200
ink_dim: Rgb::new(148, 163, 184), // slate-400
ink_strong: Rgb::new(248, 250, 252), // slate-50
ink_faint: Rgb::new(100, 116, 139), // slate-500
// Dark accents are light — near-black ink on top.
ink_on_accent: Rgb::new(12, 16, 22),
icon: Rgb::new(205, 210, 230),
icon_hover: Rgb::new(244, 246, 255),
ink_on_accent: Rgb::new(15, 23, 42), // slate-900
icon: Rgb::new(203, 213, 225), // slate-300
icon_hover: Rgb::new(248, 250, 252), // slate-50
accent: a,
accent_soft: Rgba::new(a.r, a.g, a.b, 46),
danger: Rgba::new(196, 74, 88, 255),
hairline: Rgba::new(255, 255, 255, 22),
surface: Rgba::new(255, 255, 255, 11),
hover: Rgba::new(255, 255, 255, 26),
hover_strong: Rgba::new(255, 255, 255, 40),
track_off: Rgba::new(255, 255, 255, 34),
knob_off: Rgba::new(210, 214, 228, 255),
knob_on: Rgba::new(12, 16, 22, 255),
scrollbar_thumb: Rgba::new(180, 184, 200, 0),
// 深色主题叠 slate-300 而不是纯白:白是中性色,叠上去会把
// 底色的色相**冲淡**,一叠就回到死灰。浅色主题叠深 slate、
// 深色主题叠浅 slate——这就是"深浅两套灰不一样"的技术原因。
hairline: Rgba::new(203, 213, 225, 30),
surface: Rgba::new(203, 213, 225, 15),
hover: Rgba::new(203, 213, 225, 35),
hover_strong: Rgba::new(203, 213, 225, 53),
track_off: Rgba::new(203, 213, 225, 45),
knob_off: Rgba::new(203, 213, 225, 255), // slate-300
knob_on: Rgba::new(15, 23, 42, 255), // slate-900
scrollbar_thumb: Rgba::new(148, 163, 184, 0), // slate-400
is_light: false,
}
}
@@ -1,11 +1,12 @@
//! Nebula's shared UI contract: spacing, typography, motion and control state.
//! Nebula 的共享 UI 契约:间距、圆角、字阶、层级、动效、控件状态。
//!
//! 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.
//! 值是逻辑像素/倍率。调用方在布局时**恰好**应用一次窗口 DPI;绘制、hover、
//! 焦点和命中检测必须从同一个结果矩形派生。
// Token adoption is intentionally incremental: keeping the complete scale in
// one catalog prevents new local magic numbers while existing screens migrate.
// 这个目录是设计系统的完整表达,允许存在暂未采用的条目——补全一个色阶或间距
// 阶梯不该被 dead_code 拦住。约束的方向是反过来的:**代码里不允许出现本目录
// 已经覆盖的魔数**。侦察数据(2026-07-29):仅 3 个文件就有 26 个手写的
// `s(16.0)`(= space::M)、31 个 `s(8.0)`(= radius::OVERLAY)。
#![allow(dead_code)]
pub mod space {
@@ -17,6 +18,22 @@ pub mod space {
pub const XL: f32 = 32.0;
}
/// 圆角阶梯。逐层递减:浮层 → 控件 → chip。
///
/// 参照 Windows 11 Fluent 2(`OverlayCornerRadius` = 8,`ControlCornerRadius` = 4),
/// 不是 macOS 的 10–12。2026-07-29 侦察发现八个浮层用了六种圆角
/// (命令面板 14、设置模态 12、确认框 12、AI 条 10、右键菜单 9、popup 8),
/// 这是界面"不成体系"的直接来源。
pub mod radius {
/// 浮层:命令面板、模态、右键菜单、下拉 popup。
pub const OVERLAY: f32 = 8.0;
/// 控件:combobox、spinner、输入框、按钮。
/// Fluent 本身是 4;我们的控件高 32px,6 更耐看,实机再校。
pub const CONTROL: f32 = 6.0;
/// chip / 键帽 / 徽标。
pub const CHIP: f32 = 4.0;
}
pub mod type_scale {
/// 11px-style sidebar/group caption from the Maple base face.
pub const SECTION_CAPTION: f32 = 0.75;
@@ -33,18 +50,26 @@ pub mod control {
/// 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.
/// 浮层的 Z 轴处理。阴影按浮层面积分档——同一组参数放在小菜单上刚好,
/// 放在整块命令面板上就托不住(阴影扩散必须与被托举的面积成比例)。
///
/// 调用方通常不直接用这些常量,而是走 [`super::surface::Elevation`]。
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;
/// 小浮层:右键菜单、下拉 popup、tooltip。
pub const MENU_BLUR: f32 = 12.0;
pub const MENU_OFFSET_Y: f32 = 4.0;
/// 大浮层:命令面板、shell 选择器。
pub const POPOVER_BLUR: f32 = 28.0;
pub const POPOVER_OFFSET_Y: f32 = 8.0;
/// 模态:确认框、设置页、SSH 编辑器。
pub const MODAL_BLUR: f32 = 40.0;
pub const MODAL_OFFSET_Y: f32 = 12.0;
/// 阴影浓度。浅色底上的黑阴影必须更淡,否则边缘发脏。
pub const SHADOW_ALPHA_LIGHT: u8 = 54;
pub const SHADOW_ALPHA_DARK: u8 = 86;
}
/// Terminal-grid feedback colors are rendered through the cell/rect alpha
@@ -186,7 +186,7 @@ pub(crate) fn popup_row_rect(popup: Rect, index: usize, scale: f32) -> Rect {
}
pub(crate) fn popup_row_at(popup: Rect, count: usize, scale: f32, x: f32, y: f32) -> Option<usize> {
(0..count).find(|&index| super::contains_rect(popup_row_rect(popup, index, scale), x, y))
(0..count).find(|&index| crate::display::contains_rect(popup_row_rect(popup, index, scale), x, y))
}
/// The floating option list: opaque plate + soft shadow so it reads as a