feat: live carets, one-chip keycaps, aligned path column, enforced UI contract
The visual work and the reason the visual work kept regressing, in one pass. Picker and palette - Rows drop their card borders: a row is transparent until hover/selection. Five ringed white rectangles in a column read as a form, not a list, and the panel-to-row lightness difference already separates them. - Option paths start at one shared column instead of being right-aligned. Right alignment floated every row's path START with its own length (cmd.exe began ~300px right of Nushell's), so scanning meant hunting left and right per line. - Footer hints drop their chips. A chip background means "clickable" here; the arrow/Enter/Esc legends aren't buttons, so key and description separate by ink weight instead. Keycaps go back to one chip per combination Per-key chips are a macOS form, where the modifiers are single-character glyphs that tile evenly. Ctrl/Shift/Alt are words, so splitting produced a row of ragged blocks; and Windows users already read Ctrl+Shift+P as one string in VS Code and Office. Carets that look alive The blink phase hung off the wall clock, so a field taking focus had a 50% chance of showing its caret up to half a second late — which reads as "not focused". Phase now starts at the last edit: lit immediately, steady while typing, breathing only after the hand stops, at the system's GetCaretBlinkTime (including the accessibility setting that disables blinking). The palette was also missing from the fast-redraw list, so its caret froze once the open animation ended. The palette caret is now a 1.5px beam rather than a full-cell glyph, which lets placeholder and real input share one x — the first keystroke no longer shifts the line. Overlays share one recipe Confirm dialogs and the SSH editor gain a real drop shadow and the common radius; both previously drew hairline + fill only, so a dialog demanding an answer floated lower than the palette you dismiss with Esc. The SSH editor's dim was a hard-coded 67% black ignoring the theme. Dropdowns cast a shadow instead of a glow (a glow spreads brightness without establishing height) and are opaque, since a menu sits directly on the content it hides. Why this kept regressing ui/mod.rs has said "layer contract: no magic numbers" for a while, and widgets.rs violated it — a constraint expressed only in a comment is not a constraint. ui/guardrails.rs turns three of them into failing tests, each carrying a budget equal to today's real count: new violations fail the build, and clearing old ones fails it too until the budget is lowered, so the number only goes down. This pass: radius 121 -> 94, strokes 69 -> 56, glow-as-shadow 12 -> 9. Probe script now runs a COPY of the debug binary. Running the build output directly makes the next cargo build die with an opaque mingw link error, and it invites cleaning up by image name — which would kill the user's own window, possibly the one hosting this session.
This commit is contained in:
1 parent
f6a2bb4009
commit
b795ebbc85
16 files changed
+1048
-427
No files matched your search
+20
-6
@@ -30,10 +30,17 @@ Every release entry is provided in English and Simplified Chinese.
|
||||
#### Improved
|
||||
|
||||
- Reworked the SSH host editor into a compact content-sized dialog with consistent 32 px controls, helper text below the destination, a segmented authentication selector, deliberate label/control and section spacing, a lightweight private-key section, visibly blinking text carets, and clearly separated Test / Cancel / Save actions.
|
||||
- Reworked the Shell / Profile picker into cards: content-sized height with a maximum ten-row viewport, a "Recommended" section carrying a taller hero card (name above, full program path below, Enter chip on the right) and an "All options" section of uniform cards, neutral initial selection, soft accent selection, a bordered default-shell badge, and a keyboard-hint footer. Paths keep their drive/root context and ellipsize at the tail. Hover and click hit-test against the layout's per-row rectangles, so section captions and the gaps between cards no longer highlight as if they were rows.
|
||||
- One keycap recipe everywhere — every place that displays a key combination (the picker's Ctrl+K badge, the command list's shortcut hints, Settings → Key bindings, confirm dialogs, the picker footer) now draws the same per-key chip: hairline ring, panel fill, weak-ink `+` separators. Combos are split per key instead of being one wide pill, and confirm-dialog keycaps no longer restack the button's own fill.
|
||||
- Reworked the Shell / Profile picker into cards: content-sized height with a maximum ten-row viewport, a "Recommended" section carrying a taller hero card (name above, full program path below, Enter chip on the right) and an "All options" section, neutral initial selection, soft accent selection, a bordered default-shell badge, and a keyboard-hint footer. Paths keep their drive/root context and ellipsize at the tail. Hover and click hit-test against the layout's per-row rectangles, so section captions and the gaps between rows no longer highlight as if they were rows.
|
||||
- Picker rows are no longer drawn as bordered cards. A row is transparent until you hover or select it, because the panel-to-row lightness difference already separates them — five ringed white rectangles in a column read as a form, not as a list. Selection uses a neutral wash rather than the accent: the accent budget is spent once per screen, on the current row.
|
||||
- All option paths now start at one shared column, so they line up in a single vertical edge. Right-aligning them made every row's path *start* float with its own length (`cmd.exe` began nearly 300 px right of Nushell's), so scanning the list meant hunting left and right for each line.
|
||||
- One keycap recipe everywhere — every place that shows a key combination (the picker's Ctrl+K badge, the command list's shortcut hints, Settings → Key bindings, confirm dialogs) draws the same chip: hairline ring, panel fill, chip radius. A combination is **one** chip carrying the whole string, matching what Windows users already read in VS Code, Windows Terminal and Office; the per-key split it replaces is a macOS form, where `⌘ ⇧ ⌥` are single-character glyphs that tile evenly — `Ctrl` `Shift` `Alt` do not.
|
||||
- Chip backgrounds now mean "you can click this". The picker footer's ↑↓ / Enter / Esc hints are keyboard legends, not buttons, so they dropped their chips and separate the key from its description by ink weight instead.
|
||||
- Text carets are visibly alive. The blink phase now starts from your last edit instead of running off the wall clock, so a caret is lit the instant a field takes focus (previously it had a 50 % chance of appearing up to half a second late, which read as "this field isn't focused"). The caret holds steady while you type and only breathes once you stop, and the cadence follows the system's `GetCaretBlinkTime`, including the accessibility setting that turns blinking off. The command palette was also missing from the fast-redraw list, so its caret froze once the open animation ended.
|
||||
- The command palette's caret is a 1.5 px beam instead of a full-cell glyph, which lets the placeholder and your actual query start at the same x — typing the first character no longer shifts the line sideways.
|
||||
- Confirm dialogs and the SSH host editor now cast a real drop shadow and share the app's corner radius. Both previously drew only a hairline and a fill, so a dialog that demands an answer floated lower than the command palette you can dismiss with Esc. The SSH editor's dim was also a hard-coded 67 % black that ignored the theme, veiling light themes more heavily than dark ones.
|
||||
- Dropdown lists are fully opaque and cast a shadow instead of a glow. A glow spreads brightness outward without establishing height, which on light themes just grayed the area around the list.
|
||||
- Light-theme overlays are white-based: the command palette, the SSH host editor and the settings surfaces no longer inherit a silver/slate fill from the theme family.
|
||||
- The palette's right edge now lines up. The Ctrl+K badge, the hero card's Enter chip, the right-aligned path column, the command list's shortcut chips and the footer's Esc hint previously measured from four different insets — the footer even measured from the panel instead of the card column — leaving a ragged right margin that drifted with font size.
|
||||
- The palette's right edge now lines up. The Ctrl+K badge, the hero card's Enter chip, the command list's shortcut chips and the footer's Esc hint previously measured from four different insets — the footer even measured from the panel instead of the card column — leaving a ragged right margin that drifted with font size.
|
||||
- Aligned Settings with the new navigation-and-row system: equal-height hairline groups, consistent right-aligned controls, restrained section headings, theme previews, and editable keycaps share one layout and interaction model.
|
||||
|
||||
### 简体中文
|
||||
@@ -60,10 +67,17 @@ Every release entry is provided in English and Simplified Chinese.
|
||||
#### 改进
|
||||
|
||||
- SSH 主机编辑器重排为紧凑的内容自适应弹窗:控件统一为 32px 高,地址提示移到输入框下方,认证方式改为分段选择,标签/控件与分组间距遵循统一节奏,私钥区降级为轻量小节,文本光标真实闪烁,并清晰分隔“测试连接 / 取消 / 保存”。
|
||||
- Shell / Profile 选择器改为卡片式:按条目数量决定高度,最多显示十行;分为“推荐”区(一张更高的大卡片,名称在上、完整程序路径在下、右侧回车键帽)和“所有选项”区(等高卡片);初次打开保持中性,选中态使用柔和 accent,保留默认 Shell 发丝描边徽标和键盘提示页脚。路径保留盘符/根路径上下文、尾部省略。悬停与点击改为按布局的逐行矩形命中,分区标题和卡片之间的缝隙不再被当成行高亮。
|
||||
- 键帽样式全局统一:所有展示键位的地方(选择器的 Ctrl+K 徽标、命令列表的快捷键提示、设置 → 按键映射、确认弹窗、选择器页脚)现在画同一种逐键 chip——发丝圈边、面板填充、弱墨 `+` 分隔。组合键按键拆分,不再是一颗宽药丸;确认弹窗的键帽也不再重叠按钮自身的填充色。
|
||||
- Shell / Profile 选择器改为卡片式:按条目数量决定高度,最多显示十行;分为“推荐”区(一张更高的大卡片,名称在上、完整程序路径在下、右侧回车键帽)和“所有选项”区;初次打开保持中性,选中态使用柔和 accent,保留默认 Shell 发丝描边徽标和键盘提示页脚。路径保留盘符/根路径上下文、尾部省略。悬停与点击改为按布局的逐行矩形命中,分区标题和行之间的缝隙不再被当成行高亮。
|
||||
- 选择器的行不再画成带框卡片。默认完全透明,只有悬停/选中才上底色——面板与行之间的明度差已经足够把它们分开,而五个带圈的白矩形排成一列读起来是表单,不是列表。选中态改用中性底色而不是 accent:强调色预算一屏只花一次,就花在“当前选中”上。
|
||||
- 所有选项的路径改为从同一列起画,左缘对齐成一条竖线。此前是右对齐,每行路径的**起点**随它自身长度浮动(`cmd.exe` 的起点比 Nushell 靠右近 300px),眼睛沿列表往下扫时要不停地左右找落点。
|
||||
- 键帽样式全局统一:所有展示键位的地方(选择器的 Ctrl+K 徽标、命令列表的快捷键提示、设置 → 按键映射、确认弹窗)画同一种 chip——发丝圈边、面板填充、统一圆角。一个组合键是**一颗**承载整串的 chip,与 Windows 用户在 VS Code、Windows Terminal、Office 里已经熟悉的写法一致;被它替换掉的逐键拆分是 macOS 的形式,那里 `⌘ ⇧ ⌥` 是单字符图形、排起来宽度整齐,而 `Ctrl` `Shift` `Alt` 不是。
|
||||
- chip 底色现在只表示“这里可以点”。选择器页脚的 ↑↓ / Enter / Esc 是键位说明而非按钮,因此去掉 chip 底,改用墨色深浅区分键名与释义。
|
||||
- 文本光标有了活动感。闪烁相位改为从**最后一次编辑**起算,而不是跟着系统挂钟走:输入框一获得焦点光标立刻是亮的(此前有一半概率要等最多半秒才出现,读起来像“这个框没聚焦”)。打字期间光标保持常亮,停手后才开始呼吸,节律取自系统的 `GetCaretBlinkTime`,并尊重“关闭闪烁”这项无障碍设置。命令面板此前还漏在快速重绘名单外,入场动画一结束它的光标就不再翻转。
|
||||
- 命令面板的光标改为 1.5px 细梁而不是占满一格的字形,于是提示文字与真实输入从同一个 x 起画——打下第一个字符时整行不再横向跳动。
|
||||
- 确认弹窗与 SSH 主机编辑器现在有真正的外阴影,圆角也并入统一阶梯。此前两者只画发丝描边加填充,于是一个要求用户作答的弹窗,浮起高度还不如按 Esc 就能关掉的命令面板。SSH 编辑器的遮罩此前是写死的 67% 纯黑、不读主题,浅色主题下比深色主题罩得还重。
|
||||
- 下拉列表改为完全不透明,并用阴影而不是辉光。辉光只是向外扩散亮度、不建立高度关系,在浅色主题下只会让列表四周发灰。
|
||||
- 浅色主题的浮层统一白色打底:命令面板、SSH 主机编辑器与设置页表面不再从主题族继承银/岩灰底色。
|
||||
- 命令面板右缘现在对齐成一条线。此前 Ctrl+K 徽标、大卡片的回车键帽、右对齐路径列、命令列表的快捷键 chip 与页脚的 Esc 提示各用一套内边距——页脚甚至是按面板而非卡片列计算的——右边缘参差,且会随字号漂移。
|
||||
- 命令面板右缘现在对齐成一条线。此前 Ctrl+K 徽标、大卡片的回车键帽、命令列表的快捷键 chip 与页脚的 Esc 提示各用一套内边距——页脚甚至是按面板而非卡片列计算的——右边缘参差,且会随字号漂移。
|
||||
- 设置页统一到新的导航与等高行体系:hairline 分组、右对齐控件、克制的分节标题、主题预览和可编辑键帽共用一致的布局与交互模型。
|
||||
|
||||
## 0.7.0 - 2026-07-24
|
||||
|
||||
@@ -660,10 +660,6 @@ pub(super) fn draw_chrome(d: &mut Display) {
|
||||
advance_spinner_phase(d.nebula_ui_anims.spinner_phase, motion_frame.delta);
|
||||
}
|
||||
let spinner_phase = d.nebula_ui_anims.spinner_phase;
|
||||
// Tick command palette cursor animation (blink cycle).
|
||||
if d.nebula_palette.is_open() {
|
||||
d.nebula_palette.tick_cursor(motion_frame);
|
||||
}
|
||||
|
||||
// Chrome colors come from the theme skin (hover washes flip to dark
|
||||
// smoke on the light themes); close-hover red stays semantic.
|
||||
|
||||
@@ -374,8 +374,6 @@ pub struct CommandPalette {
|
||||
hover_armed: bool,
|
||||
/// 武装期间首次上报的指针位置(解除武装的位移基准)。
|
||||
pointer_baseline: Option<(f32, f32)>,
|
||||
/// Cursor blink animation state for the search input.
|
||||
cursor_pulse: crate::motion::Pulse,
|
||||
}
|
||||
|
||||
impl CommandPalette {
|
||||
@@ -395,7 +393,6 @@ impl CommandPalette {
|
||||
hover: None,
|
||||
hover_armed: false,
|
||||
pointer_baseline: None,
|
||||
cursor_pulse: crate::motion::Pulse::new(std::time::Duration::from_millis(1060)),
|
||||
};
|
||||
palette.refilter();
|
||||
palette
|
||||
@@ -607,13 +604,15 @@ impl CommandPalette {
|
||||
true
|
||||
}
|
||||
|
||||
/// Update cursor blink state. Call this each frame while the palette is open.
|
||||
pub fn tick_cursor(&mut self, frame: crate::motion::Frame) {
|
||||
self.cursor_pulse.step(frame);
|
||||
}
|
||||
|
||||
/// 光标该不该画。相位来自 [`ui::caret`](super::ui::caret) 的共享节律,
|
||||
/// 与标签重命名框、SSH 编辑器、侧栏搜索框完全一致——打字时常亮、
|
||||
/// 停手后按系统的 `GetCaretBlinkTime` 呼吸。
|
||||
///
|
||||
/// 这里曾经自带一个 1060ms、占空比 0.5 的 `Pulse`。它的问题不在参数,
|
||||
/// 而在于**每个输入框各造一套节律**:同一个窗口里两个光标以不同相位
|
||||
/// 闪烁,读起来像界面有两个焦点。
|
||||
pub fn cursor_visible(&self) -> bool {
|
||||
self.cursor_pulse.visible(0.5)
|
||||
super::ui::caret::is_on()
|
||||
}
|
||||
|
||||
pub fn toggle(&mut self) {
|
||||
@@ -1031,6 +1030,7 @@ pub fn palette_layout(
|
||||
// ---- rendering (the parent `display::mod` hands in the model + renderer;
|
||||
// this module owns the palette's pixels — same split as `side_panel.rs`) ----
|
||||
|
||||
use super::ui::surface;
|
||||
use crate::renderer::ui::{Rgba, UiQuad};
|
||||
use crate::renderer::{GlyphCache, Renderer};
|
||||
|
||||
@@ -1049,6 +1049,18 @@ const HINT_GAP: f32 = 24.0;
|
||||
/// 自带视觉边界,不需要靠空白来分隔。
|
||||
const CHIP_GAP: f32 = 12.0;
|
||||
|
||||
/// 输入框与列表行共用的左内边距。列表文字必须与查询文字**左缘对齐**,
|
||||
/// 否则输入框读起来像悬在列表外面的另一个控件。
|
||||
const INPUT_PAD_X: f32 = 14.0;
|
||||
|
||||
/// 搜索图标占的列数(图标一列 + 一列缝)。按 cell 列而不是固定 px 计量:
|
||||
/// 固定 px 的缝隙在字号变化时会与字形脱节——大字号显挤、小字号显空。
|
||||
const SEARCH_SLOT_COLS: f32 = 2.0;
|
||||
|
||||
/// 文本光标的梁宽(逻辑 px)。细梁不占列宽,placeholder 与真实输入因此
|
||||
/// 能落在同一个 x 上。
|
||||
const CARET_W: f32 = 1.5;
|
||||
|
||||
/// Push the palette's background quads: a dim veil over the window, the glass
|
||||
/// panel (glow + gradient border + solid fill, matching the settings modal),
|
||||
/// the query input box, and the selected-row
|
||||
@@ -1066,34 +1078,26 @@ pub(super) fn push_quads(
|
||||
let w = size.width();
|
||||
let h = size.height();
|
||||
let s = |v: f32| v * scale;
|
||||
let palette = theme.palette();
|
||||
let sk = theme.skin();
|
||||
let layout = palette_layout(model, w, h, scale);
|
||||
let (px, py, pw, ph) = layout.panel;
|
||||
let (ix, iy, iw, ih) = layout.input;
|
||||
|
||||
quads.push(UiQuad::solid(0.0, 0.0, w, h, 0.0, sk.veil));
|
||||
quads.push(UiQuad::glow(
|
||||
px - s(24.0),
|
||||
py - s(22.0),
|
||||
pw + s(48.0),
|
||||
ph + s(48.0),
|
||||
palette.edge_glow_l,
|
||||
));
|
||||
// Hairline ring, not a gradient stroke: the picker reads as one soft gray
|
||||
// sheet (图4/图5), and the chrome shell stays neutral.
|
||||
quads.push(UiQuad::solid(
|
||||
px - s(1.0),
|
||||
py - s(1.0),
|
||||
pw + s(2.0),
|
||||
ph + s(2.0),
|
||||
s(15.0),
|
||||
sk.hairline,
|
||||
));
|
||||
// 面板填充是纯色柔和灰:chrome 壳保持中性,此前 panel→term_bg 的渐变
|
||||
// 让弹窗在暗色主题下自成一套明暗,与设置模态和侧栏的平面感割裂
|
||||
// (浅色主题还因 alpha 差露出脏渐变)。
|
||||
quads.push(UiQuad::solid(px, py, pw, ph, s(14.0), sk.panel));
|
||||
// 命令面板是 Popover:可 Esc、无后果、随手开关,所以**不画遮罩**。
|
||||
// 遮罩传达的是「我打断了你」这份模态承诺,给随手开关的浮层套上它,既凭空
|
||||
// 抬高心理成本,又遮住这个面板自己经常需要被参考的上下文("我刚才在哪个
|
||||
// 目录来着")。浮起改由阴影承担 —— 加法而不是减法,背景信息完整保留。
|
||||
//
|
||||
// 同时移除了此前的品牌辉光(palette.edge_glow_l):glow 是"发光",不建立
|
||||
// Z 轴层级;与外阴影叠加只会让边缘浑浊。品牌预算留给 logo 与 tab。
|
||||
surface::push_surface(
|
||||
quads,
|
||||
layout.panel,
|
||||
(w, h),
|
||||
scale,
|
||||
&sk,
|
||||
surface::Elevation::Popover,
|
||||
1.0,
|
||||
);
|
||||
|
||||
quads.push(UiQuad::solid(
|
||||
ix,
|
||||
@@ -1106,21 +1110,46 @@ pub(super) fn push_quads(
|
||||
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));
|
||||
let selection_x = ix + s(INPUT_PAD_X) + cell_w * SEARCH_SLOT_COLS;
|
||||
let selection_w = (columns as f32 * cell_w).min(iw - s(INPUT_PAD_X * 2.0) - cell_w * SEARCH_SLOT_COLS);
|
||||
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),
|
||||
s(super::ui::tokens::radius::CHIP),
|
||||
sk.accent_soft,
|
||||
));
|
||||
}
|
||||
|
||||
let cell_w = size.cell_width();
|
||||
// Ctrl+K 键帽(图4/图8):仅 shell picker 空查询时展示,指认打开快捷
|
||||
// 键;输入开始后让位给查询文字。
|
||||
// 搜索图标槽与查询文字的起点。槽宽按 **cell 列**算而不是固定 px:
|
||||
// 字号一变,固定 px 的缝隙就会与字形脱节(大字号显挤、小字号显空)。
|
||||
let query_x = ix + s(INPUT_PAD_X) + cell_w * SEARCH_SLOT_COLS;
|
||||
|
||||
// 文本光标画成一条细梁 quad,而不是 `▏` 字形。
|
||||
//
|
||||
// 字形光标占满一个 cell,于是空态的 placeholder 必须整体右让一格给它,
|
||||
// 打下第一个字符时文字又跳回来——一次可见的位移。细梁不占列宽,
|
||||
// placeholder 与真实输入因此能落在**同一个 x** 上,输入过程没有跳动。
|
||||
// 顺带它也更接近原生文本框的观感(1.5px 竖线,不是一个方块)。
|
||||
if !model.query_all_selected() {
|
||||
if super::ui::caret::is_on() {
|
||||
let columns: usize = model.query.chars().map(|c| c.width().unwrap_or(0)).sum();
|
||||
let cell_h = size.cell_height();
|
||||
quads.push(UiQuad::solid(
|
||||
query_x + columns as f32 * cell_w,
|
||||
iy + (ih - cell_h) / 2.0,
|
||||
s(CARET_W),
|
||||
cell_h,
|
||||
0.0,
|
||||
Rgba::new(sk.ink_strong.r, sk.ink_strong.g, sk.ink_strong.b, 255),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl+K 键帽:仅 shell picker 空查询时展示,指认打开快捷键;
|
||||
// 输入开始后让位给查询文字。
|
||||
if model.mode == PaletteMode::Profiles && model.query.is_empty() {
|
||||
let combo = super::ui::keycap::layout_combo(
|
||||
"Ctrl+K",
|
||||
@@ -1142,27 +1171,24 @@ pub(super) fn push_quads(
|
||||
let hero = model.hero_row();
|
||||
let accent_ring = Rgba::new(sk.accent.r, sk.accent.g, sk.accent.b, 255);
|
||||
if cards {
|
||||
// 卡片行(图4):柔和灰面板上的浅色卡片,hairline 圈边。选中 =
|
||||
// accent 描边 + 轻染,hover = 中性 wash;推荐大卡片自带 accent
|
||||
// 轻染和右侧回车 chip(glyph 在文字 pass 画)。
|
||||
// 列表行**不画卡片**(2026-07-29 用户裁定):默认完全透明,露出面板
|
||||
// 底;只有 hover / 选中才上一层底色。
|
||||
//
|
||||
// 此前每行都是「hairline 圈 + 白底卡片」,五行排下来是五个带框的白
|
||||
// 矩形——那读作表单,不读作列表。边框在这里不传递任何信息(明度差
|
||||
// 已经把行分开了),删掉之后噪音立刻降一个量级。
|
||||
//
|
||||
// 选中态用中性的 hover_strong 而不是 accent_soft:强调色预算一屏
|
||||
// 只花一次,而这个面板里真正需要它的是"当前选中"这一处。
|
||||
// 推荐卡也不再染色——它已经被"推荐"分区标题、更大的尺寸、双行布局
|
||||
// 和右侧 ↵ chip 标记了四次身份,第五次是浪费。
|
||||
let corner = s(super::ui::tokens::radius::OVERLAY);
|
||||
for (row, &(ry, rh)) in layout.rows.iter().enumerate() {
|
||||
let is_hero = hero && row == 0;
|
||||
let selected = selected_row == Some(row);
|
||||
let ring = if selected { accent_ring } else { sk.hairline };
|
||||
quads.push(UiQuad::solid(
|
||||
ix - s(1.0),
|
||||
ry - s(1.0),
|
||||
iw + s(2.0),
|
||||
rh + s(2.0),
|
||||
s(9.0),
|
||||
ring,
|
||||
));
|
||||
quads.push(UiQuad::solid(ix, ry, iw, rh, s(8.0), sk.card));
|
||||
if is_hero || selected {
|
||||
quads.push(UiQuad::solid(ix, ry, iw, rh, s(8.0), sk.accent_soft));
|
||||
}
|
||||
if model.hover == Some(row) {
|
||||
quads.push(UiQuad::solid(ix, ry, iw, rh, s(8.0), sk.hover));
|
||||
if selected_row == Some(row) {
|
||||
quads.push(UiQuad::solid(ix, ry, iw, rh, corner, sk.hover_strong));
|
||||
} else if model.hover == Some(row) {
|
||||
quads.push(UiQuad::solid(ix, ry, iw, rh, corner, sk.hover));
|
||||
}
|
||||
if is_hero {
|
||||
let chip = s(28.0);
|
||||
@@ -1264,27 +1290,16 @@ pub(super) fn push_quads(
|
||||
quads.push(UiQuad::solid(bx, by, badge_w, bh, s(4.0), sk.surface));
|
||||
}
|
||||
|
||||
if let Some((_, fy, _, fh)) = layout.footer {
|
||||
// 图8 键帽规范:底栏键帽与 Ctrl+K/设置页共用同一 chip 配方。底栏
|
||||
// 背景横贯面板,但里面的文字与卡片列对齐(走 ix/iw,不是 panel)。
|
||||
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::ui::keycap::push_chip(quads, &sk, x, key_y, width, key_h, scale);
|
||||
};
|
||||
chip(quads, ix, 3);
|
||||
chip(quads, ix + iw * 0.42, 5);
|
||||
let esc_cols = text_width_cols(model.language.pick("Esc", "Esc"));
|
||||
chip(
|
||||
quads,
|
||||
ix + iw
|
||||
- s(GUTTER)
|
||||
- (esc_cols + text_width_cols(model.language.pick(" 关闭", " Close"))) as f32
|
||||
* cell_w,
|
||||
esc_cols,
|
||||
);
|
||||
}
|
||||
// 底栏**不画 chip 底**(2026-07-29 用户裁定)。
|
||||
//
|
||||
// chip 底在这套界面里的语义是「可点击」——命令行右侧的快捷键 chip、
|
||||
// Ctrl+K 徽标都对应一个能按下去的东西。底栏的 ↑↓/Enter/Esc 是纯键位
|
||||
// 说明,给它们戴上 chip 会把那个信号稀释成装饰,用户就不再能靠"有没有
|
||||
// chip 底"判断某处能不能点。
|
||||
//
|
||||
// 键位与说明的区分改由**墨色权重**承担(键名 ink_dim、说明 ink_faint,
|
||||
// 见文字 pass)。这也符合层级手段的成本排序:明度差比边框/填充贵,
|
||||
// 但效果更干净——底栏因此从三个灰块变回一行安静的文字。
|
||||
}
|
||||
|
||||
/// Draw the palette's text: the query line (with a caret) or a placeholder,
|
||||
@@ -1319,19 +1334,18 @@ pub(super) fn draw_text(
|
||||
let sk = theme.skin();
|
||||
|
||||
// Left edge for result text and the search icon.
|
||||
let text_x = ix + s(14.0);
|
||||
let text_x = ix + s(INPUT_PAD_X);
|
||||
|
||||
const ICON_SEARCH: &str = "\u{f0349}"; // mdi-magnify
|
||||
r.draw_chrome_text(size, text_x, iy + (ih - cell_h) / 2.0, sk.ink_faint, ICON_SEARCH, gc);
|
||||
|
||||
let query_x = text_x + s(28.0);
|
||||
// placeholder 与真实查询共用这一个起点。光标是 quad pass 画的细梁,
|
||||
// 不占列宽,所以打下第一个字符时文字不会跳位。
|
||||
let query_x = text_x + cell_w * SEARCH_SLOT_COLS;
|
||||
let text_y = iy + (ih - cell_h) / 2.0;
|
||||
let query = model.query();
|
||||
let cursor = if model.cursor_visible() { "▏" } else { "" };
|
||||
|
||||
if query.is_empty() {
|
||||
// 空态同时给出输入光标和用途提示,避免只有一个空白色块让用户猜测。
|
||||
r.draw_chrome_text(size, query_x, text_y, sk.ink_strong, cursor, gc);
|
||||
let placeholder = match model.mode {
|
||||
PaletteMode::Commands => model
|
||||
.language
|
||||
@@ -1343,11 +1357,9 @@ pub(super) fn draw_text(
|
||||
model.language.pick("搜索常用目录…", "Search frequent directories...")
|
||||
},
|
||||
};
|
||||
r.draw_chrome_text(size, query_x + s(10.0), text_y, sk.ink_dim, placeholder, gc);
|
||||
r.draw_chrome_text(size, query_x, text_y, sk.ink_faint, 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);
|
||||
r.draw_chrome_text(size, query_x, text_y, sk.ink_strong, query, gc);
|
||||
}
|
||||
|
||||
if model.is_empty() {
|
||||
@@ -1366,11 +1378,8 @@ pub(super) fn draw_text(
|
||||
},
|
||||
gc,
|
||||
);
|
||||
if let Some((fx, fy, fw, fh)) = layout.footer {
|
||||
let footer_y = fy + (fh - cell_h) * 0.5;
|
||||
r.draw_chrome_text(size, fx + s(16.0), footer_y, sk.ink_dim, model.language.pick("↑ ↓ 选择", "↑ ↓ Select"), gc);
|
||||
r.draw_chrome_text(size, fx + fw * 0.42, footer_y, sk.ink_dim, model.language.pick("Enter 打开", "Enter Open"), gc);
|
||||
r.draw_chrome_text(size, fx + fw - s(16.0) - text_width_cols(model.language.pick("Esc 关闭", "Esc Close")) as f32 * cell_w, footer_y, sk.ink_dim, model.language.pick("Esc 关闭", "Esc Close"), gc);
|
||||
if let Some((_, fy, _, fh)) = layout.footer {
|
||||
draw_footer_hints(r, gc, size, model, ix, iw, fy, fh, cell_w, cell_h, scale, &sk);
|
||||
}
|
||||
return icon_draws;
|
||||
}
|
||||
@@ -1411,6 +1420,37 @@ pub(super) fn draw_text(
|
||||
let cards = model.is_picker();
|
||||
let hero = model.hero_row();
|
||||
let (rows, selected_row) = model.visible(layout.max_rows);
|
||||
let badge = model.language.pick("默认", "Default");
|
||||
let badge_w = |present: bool| -> f32 {
|
||||
if present {
|
||||
text_width_cols(badge) as f32 * cell_w + s(12.0) + s(10.0)
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
};
|
||||
|
||||
// 路径列的左缘。picker 的每一行都从这**同一个 x** 起画路径,于是路径
|
||||
// 排成一条竖线。
|
||||
//
|
||||
// 此前路径是右对齐的:每行路径的起点随它自身的长度浮动(cmd.exe 起点
|
||||
// 在 1030、Nushell 的长路径起点在 730),眼睛沿列表往下扫时要不停地
|
||||
// 左右找落点——这就是"长的长短的短"读着乱的来源。右缘参差反而无所谓,
|
||||
// 因为扫视是沿着起点走的,不是沿着终点。
|
||||
//
|
||||
// 列位置取"最宽的标签 + 呼吸缝",并钳在面板 62% 处:某一行标签特别长
|
||||
// 时,不能把整列推到没有地方放路径。
|
||||
let path_col_x = cards.then(|| {
|
||||
let widest = rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(row, _)| !(hero && *row == 0))
|
||||
.map(|(_, entry)| {
|
||||
text_width_cols(&entry.label) as f32 * cell_w + badge_w(entry.is_default)
|
||||
})
|
||||
.fold(0.0f32, f32::max);
|
||||
path_column_x(text_x, s(26.0), widest, ix, iw, scale)
|
||||
});
|
||||
|
||||
for (row, entry) in rows.into_iter().enumerate() {
|
||||
let PaletteRow { icon, color_id, label, hint, is_default } = entry;
|
||||
let Some(&(row_y, row_hh)) = layout.rows.get(row) else { break };
|
||||
@@ -1444,22 +1484,16 @@ pub(super) fn draw_text(
|
||||
text_x + indent
|
||||
};
|
||||
r.draw_chrome_text(size, label_x, ry, fg, &label, gc);
|
||||
let badge = model.language.pick("默认", "Default");
|
||||
let badge_w = if is_default && !is_hero {
|
||||
let width = badge.chars().map(|c| c.width().unwrap_or(1)).sum::<usize>() as f32
|
||||
* cell_w
|
||||
+ s(12.0);
|
||||
let badge_span = if is_default && !is_hero {
|
||||
r.draw_chrome_text(
|
||||
size,
|
||||
label_x + label.chars().map(|c| c.width().unwrap_or(1)).sum::<usize>() as f32
|
||||
* cell_w
|
||||
+ s(10.0),
|
||||
label_x + text_width_cols(&label) as f32 * cell_w + s(10.0),
|
||||
ry,
|
||||
sk.ink_dim,
|
||||
badge,
|
||||
gc,
|
||||
);
|
||||
width + s(10.0)
|
||||
badge_w(true)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
@@ -1499,47 +1533,72 @@ pub(super) fn draw_text(
|
||||
if combo.bounds.0 > label_end + s(HINT_GAP) {
|
||||
draw_combo_text(r, gc, size, &combo, cell_w, cell_h, &sk);
|
||||
}
|
||||
} else if let Some((hint_x, shown)) =
|
||||
fit_hint(label_x, &label, badge_w, ix, iw, cell_w, scale, &hint)
|
||||
{
|
||||
r.draw_chrome_text(size, hint_x, ry, sk.ink_dim, &shown, gc);
|
||||
} else if let Some(col_x) = path_col_x {
|
||||
// 路径左对齐到共用的竖线。挤不下(标签太长压到列位)就整条
|
||||
// 让位,绝不叠在标签上——与命令列表的 hint 同一条规矩。
|
||||
let label_end = label_x + text_width_cols(&label) as f32 * cell_w + badge_span;
|
||||
let budget = ((ix + iw - s(GUTTER) - col_x) / cell_w).floor();
|
||||
if col_x >= label_end + s(HINT_GAP) && budget >= 3.0 {
|
||||
let shown = fit_head(&hint, budget as usize);
|
||||
r.draw_chrome_text(size, col_x, ry, sk.ink_dim, &shown, gc);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((_, fy, _, fh)) = layout.footer {
|
||||
let footer_y = fy + (fh - cell_h) * 0.5;
|
||||
r.draw_chrome_text(
|
||||
size,
|
||||
ix,
|
||||
footer_y,
|
||||
sk.ink_dim,
|
||||
model.language.pick("↑ ↓ 选择", "↑ ↓ Select"),
|
||||
gc,
|
||||
);
|
||||
r.draw_chrome_text(
|
||||
size,
|
||||
ix + iw * 0.42,
|
||||
footer_y,
|
||||
sk.ink_dim,
|
||||
model.language.pick("Enter 打开", "Enter Open"),
|
||||
gc,
|
||||
);
|
||||
r.draw_chrome_text(
|
||||
size,
|
||||
ix + iw - s(GUTTER) - text_width_cols(model.language.pick("Esc 关闭", "Esc Close"))
|
||||
as f32
|
||||
* cell_w,
|
||||
footer_y,
|
||||
sk.ink_dim,
|
||||
model.language.pick("Esc 关闭", "Esc Close"),
|
||||
gc,
|
||||
);
|
||||
draw_footer_hints(r, gc, size, model, ix, iw, fy, fh, cell_w, cell_h, scale, &sk);
|
||||
}
|
||||
icon_draws
|
||||
}
|
||||
|
||||
/// 图8 键帽文字:逐键 chip 内水平居中键名、弱墨 "+" 分隔;几何来自
|
||||
/// `keycap::layout_combo`,与 quad pass 同源。
|
||||
/// 底栏的三条键位提示。
|
||||
///
|
||||
/// 键名与释义分成两级墨(`ink_dim` / `ink_faint`)而不是给键名套 chip 底:
|
||||
/// chip 底在这套界面里的语义是「可点击」,底栏这三条按不下去。用墨色权重
|
||||
/// 区分同样能让键名跳出来,而且不占额外面积、不给画面增加三个灰块。
|
||||
///
|
||||
/// 三条的锚点分别是左缘、居中、右缘——与列表列同一套 `ix`/`iw`/`GUTTER`,
|
||||
/// 所以右缘和上面的快捷键 chip 连成一条竖线。
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn draw_footer_hints(
|
||||
r: &mut Renderer,
|
||||
gc: &mut GlyphCache,
|
||||
size: &SizeInfo,
|
||||
model: &CommandPalette,
|
||||
ix: f32,
|
||||
iw: f32,
|
||||
fy: f32,
|
||||
fh: f32,
|
||||
cell_w: f32,
|
||||
cell_h: f32,
|
||||
scale: f32,
|
||||
sk: &super::ui::theme::Skin,
|
||||
) {
|
||||
let y = fy + (fh - cell_h) * 0.5;
|
||||
// (键, 释义)。键名保持 ASCII/箭头,释义随语言切换。
|
||||
let hints = [
|
||||
("↑ ↓", model.language.pick("选择", "Select")),
|
||||
("Enter", model.language.pick("打开", "Open")),
|
||||
("Esc", model.language.pick("关闭", "Close")),
|
||||
];
|
||||
// 释义与键名之间空一整列:等宽网格上的半格会让两段文字看着没对齐。
|
||||
let span = |key: &str, label: &str| {
|
||||
(text_width_cols(key) + 1 + text_width_cols(label)) as f32 * cell_w
|
||||
};
|
||||
let xs = [
|
||||
ix,
|
||||
ix + (iw - span(hints[1].0, hints[1].1)) * 0.5,
|
||||
ix + iw - scale * GUTTER - span(hints[2].0, hints[2].1),
|
||||
];
|
||||
for (&x, (key, label)) in xs.iter().zip(hints) {
|
||||
r.draw_chrome_text(size, x, y, sk.ink_dim, key, gc);
|
||||
let label_x = x + (text_width_cols(key) + 1) as f32 * cell_w;
|
||||
r.draw_chrome_text(size, label_x, y, sk.ink_faint, label, gc);
|
||||
}
|
||||
}
|
||||
|
||||
/// 键帽文字:chip 内水平居中整串键位;几何来自 `keycap::layout_combo`,
|
||||
/// 与 quad pass 同源。
|
||||
fn draw_combo_text(
|
||||
r: &mut Renderer,
|
||||
gc: &mut GlyphCache,
|
||||
@@ -1562,47 +1621,29 @@ fn draw_combo_text(
|
||||
gc,
|
||||
);
|
||||
}
|
||||
for plus_x in &combo.pluses {
|
||||
r.draw_chrome_text(size, *plus_x, ty, sk.ink_faint, "+", gc);
|
||||
}
|
||||
}
|
||||
|
||||
fn text_width_cols(text: &str) -> usize {
|
||||
text.chars().map(|c| c.width().unwrap_or(0)).sum()
|
||||
}
|
||||
|
||||
/// Lay out a row's right-aligned hint next to its left-aligned label: returns
|
||||
/// the draw X and the (possibly truncated) text, or `None` when fewer than 3
|
||||
/// columns remain. Pure so the no-collision contract is unit-testable.
|
||||
/// picker 的路径列左缘:所有行共用一个 x,路径因此排成一条竖线。
|
||||
///
|
||||
/// 2026-07-27 用户反馈"字号放大后 powershell 路径叠进标签":旧预算
|
||||
/// `iw - label_x - s(28)` 把宽度 `iw` 和绝对坐标 `label_x` 混在一起减——
|
||||
/// 面板是居中的,窗口一宽 `label_x` 随之变大,差值变负后被 `as usize`
|
||||
/// 钳成 0,`> 2` 的守卫反而放行了完整路径,57 列的 powershell.exe 右对
|
||||
/// 齐后一头扎回标签里;字号越大 cell_w 越宽,扎得越深。现在从标签右缘
|
||||
/// + 呼吸缝起算,CJK 按双列计宽(目录 picker 的 hint 是可含中文的路径,
|
||||
/// 按 char 数右对齐会溢出面板)。2026-07-29 裁定:溢出时尾部省略号、保
|
||||
/// 留盘符/根上下文(图2/图4)——叶名辨识交给 label 和推荐卡片的完整路径。
|
||||
fn fit_hint(
|
||||
label_x: f32,
|
||||
label: &str,
|
||||
after_label: f32,
|
||||
ix: f32,
|
||||
iw: f32,
|
||||
cell_w: f32,
|
||||
scale: f32,
|
||||
hint: &str,
|
||||
) -> Option<(f32, String)> {
|
||||
let cols = |t: &str| -> usize { t.chars().map(|c| c.width().unwrap_or(0)).sum() };
|
||||
let label_end = label_x + cols(label) as f32 * cell_w + after_label;
|
||||
let right = ix + iw - GUTTER * scale;
|
||||
let budget = ((right - label_end - HINT_GAP * scale) / cell_w).floor();
|
||||
if budget < 3.0 {
|
||||
return None;
|
||||
}
|
||||
let shown = fit_head(hint, budget as usize);
|
||||
let x = right - cols(&shown) as f32 * cell_w;
|
||||
Some((x, shown))
|
||||
/// `widest_label` 是最宽一行的「标签 + 徽标」像素宽(不含前导图标缩进)。
|
||||
///
|
||||
/// 2026-07-29 用户反馈"长的长短的短的难看":路径此前是右对齐的,每行路径的
|
||||
/// **起点**随它自身长度浮动(cmd.exe 与 Nushell 的起点差了近 300px),眼睛
|
||||
/// 沿列表往下扫时要不停地左右找落点。左对齐让扫视只沿一条竖线走;右缘参差
|
||||
/// 无所谓,因为扫视是沿起点走的。
|
||||
///
|
||||
/// 列位置钳在面板 `COLUMN_MAX_FRACTION` 处:某一行标签特别长时,不能把整列
|
||||
/// 推到没有地方放路径。钳住之后那一行的路径会与标签冲突,由调用方让位。
|
||||
///
|
||||
/// 纯函数,因此"不与标签重叠"这条契约可以被单测覆盖——上一版的
|
||||
/// `fit_hint` 就是靠同类测试挡住过一个把宽度和绝对坐标混着减的 bug。
|
||||
fn path_column_x(text_x: f32, icon_indent: f32, widest_label: f32, ix: f32, iw: f32, scale: f32) -> f32 {
|
||||
const COLUMN_MAX_FRACTION: f32 = 0.62;
|
||||
(text_x + icon_indent + widest_label + HINT_GAP * scale).min(ix + iw * COLUMN_MAX_FRACTION)
|
||||
}
|
||||
|
||||
/// Paths keep their root context; overflow is cut at the tail with an
|
||||
@@ -1922,39 +1963,55 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_never_overlaps_label_on_a_wide_window() {
|
||||
// Maximized-wide geometry: the panel is centered, so the label's
|
||||
// absolute x exceeds the panel WIDTH. The old budget subtracted that
|
||||
// absolute x from the width, went negative, was clamped to 0 by
|
||||
// `as usize`, and the `> 2` guard then let the full 57-column path
|
||||
// through — right-aligned straight into the label.
|
||||
fn path_column_clears_the_widest_label() {
|
||||
// 列位置由最宽的一行决定,其余行因此不可能与自己的标签冲突。
|
||||
let (ix, iw, cell_w, scale) = (960.0, 770.0, 14.0, 1.25);
|
||||
let label = "Windows PowerShell";
|
||||
let label_x = ix + 50.0;
|
||||
let hint = r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe";
|
||||
let (hint_x, shown) = fit_hint(label_x, label, 0.0, ix, iw, cell_w, scale, hint).unwrap();
|
||||
let label_end = label_x + label.chars().count() as f32 * cell_w;
|
||||
assert!(hint_x >= label_end, "hint at {hint_x} starts inside the label (ends {label_end})");
|
||||
// 2026-07-29 裁定:溢出保头部(盘符/根上下文),尾部省略号。
|
||||
let text_x = ix + 14.0 * scale;
|
||||
let indent = 26.0 * scale;
|
||||
let widest = text_width_cols("Windows PowerShell") as f32 * cell_w;
|
||||
let col = path_column_x(text_x, indent, widest, ix, iw, scale);
|
||||
assert!(
|
||||
col >= text_x + indent + widest + HINT_GAP * scale - 0.01,
|
||||
"路径列必须落在最宽标签之后至少一个呼吸缝",
|
||||
);
|
||||
assert!(col < ix + iw, "列位置不能跑出面板");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_column_is_capped_so_a_long_label_cannot_squeeze_it_out() {
|
||||
// 单行标签极长时,列被钳住;调用方据此判定冲突并让整条路径让位,
|
||||
// 而不是把路径叠在标签上。
|
||||
let (ix, iw, cell_w, scale) = (0.0, 600.0, 12.0, 1.0);
|
||||
let absurd = text_width_cols("启动:一个足够长到吃掉整行宽度的 profile 名字标签") as f32 * cell_w;
|
||||
let col = path_column_x(ix + 14.0, 26.0, absurd, ix, iw, scale);
|
||||
assert!(col <= ix + iw * 0.62 + 0.01, "列必须被钳在面板 62% 内");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_column_is_identical_for_every_row() {
|
||||
// 「长的长短的短」的回归防线:列位置只依赖最宽标签,与某一行自身
|
||||
// 的路径长度无关,所以每行拿到的起点完全相同。
|
||||
let (ix, iw, scale) = (100.0, 800.0, 1.0);
|
||||
let col = path_column_x(ix + 14.0, 26.0, 200.0, ix, iw, scale);
|
||||
for _ in 0..5 {
|
||||
assert_eq!(path_column_x(ix + 14.0, 26.0, 200.0, ix, iw, scale), col);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_paths_keep_their_root_and_ellipsize_at_the_tail() {
|
||||
// 2026-07-29 裁定:溢出保头部(盘符/根上下文),尾部省略号——
|
||||
// 叶名辨识交给标签和推荐卡的完整路径。
|
||||
let shown = fit_head(r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe", 20);
|
||||
assert!(shown.starts_with("C:\\Windows") && shown.ends_with('…'), "got {shown}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_is_dropped_when_the_label_leaves_no_room() {
|
||||
let label = "启动:一个足够长到吃掉整行宽度的 profile 名字标签";
|
||||
assert_eq!(fit_hint(500.0, label, 0.0, 100.0, 600.0, 12.0, 1.0, r"C:\x"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cjk_hint_right_aligns_by_display_columns_not_chars() {
|
||||
// Directory-picker hints are paths that may contain CJK; counting
|
||||
// chars instead of columns pushed them past the panel's right edge.
|
||||
let (ix, iw, cell_w) = (0.0, 640.0, 10.0);
|
||||
let hint = "D:/项目/子目录";
|
||||
let (hint_x, shown) = fit_hint(ix + 40.0, "口", 0.0, ix, iw, cell_w, 1.0, hint).unwrap();
|
||||
assert_eq!(shown, hint, "plenty of room: no truncation");
|
||||
fn tail_ellipsis_counts_display_columns_not_chars() {
|
||||
// 目录 picker 的路径可含 CJK;按 char 数裁会溢出面板。
|
||||
let shown = fit_head("D:/项目/一个很长的子目录名字", 10);
|
||||
let cols: usize = shown.chars().map(|c| c.width().unwrap_or(0)).sum();
|
||||
assert_eq!(hint_x, ix + iw - 14.0 - cols as f32 * cell_w);
|
||||
assert!(cols <= 10, "按显示列宽裁剪,实测 {cols} 列");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -121,15 +121,14 @@ pub use ui::theme::NebulaTheme;
|
||||
pub(crate) use ui::theme::write_nebula_prompt_theme;
|
||||
|
||||
/// Shared caret blink phase for the chrome text editors (rename / filter /
|
||||
/// commit boxes): 500ms on / 500ms off wall-clock, same time source as the
|
||||
/// sidebar spinner. The fast tick (armed while an editor is focused) keeps
|
||||
/// frames coming so the phase is actually visible instead of looking frozen.
|
||||
/// commit boxes). 相位挂在**最后一次编辑活动**上而不是挂钟纪元:聚焦或打完
|
||||
/// 字的那一刻光标必定是亮的,连续打字期间不闪。节律取自系统的
|
||||
/// `GetCaretBlinkTime`。完整理由见 [`ui::caret`]。
|
||||
///
|
||||
/// 保留这层薄封装是因为已有十处调用点写作 `caret_blink_on()`;新代码直接用
|
||||
/// [`ui::caret::is_on`]。
|
||||
pub(crate) fn caret_blink_on() -> bool {
|
||||
let millis = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis())
|
||||
.unwrap_or(0);
|
||||
(millis / 500) % 2 == 0
|
||||
ui::caret::is_on()
|
||||
}
|
||||
pub use settings::{NebulaSettingsSection, SettingsDropdown, SettingsHit, settings_hit};
|
||||
pub(crate) use settings::SettingsOpacityTarget;
|
||||
@@ -2528,10 +2527,17 @@ impl Display {
|
||||
}
|
||||
|
||||
/// A chrome text editor (tab rename / drawer filter / commit message / SSH
|
||||
/// host editor) has keyboard focus — the window context bumps the redraw
|
||||
/// tick to the fast cadence so the insertion caret visibly blinks.
|
||||
/// host editor / command palette) has keyboard focus — the window context
|
||||
/// bumps the redraw tick to the fast cadence so the insertion caret
|
||||
/// visibly blinks.
|
||||
///
|
||||
/// 命令面板曾经漏在这个列表外:它的入场动画一结束,画面就静止了,
|
||||
/// 光标停在当时那一相里不再翻转。它自带的 `Pulse` 每帧照常累加,
|
||||
/// 但没有帧可累加——**动画状态推进和帧供给是两件事**,只做前者会得到
|
||||
/// 一个看起来"卡住"的光标。
|
||||
pub fn chrome_editor_active(&self) -> bool {
|
||||
self.nebula_tab_rename.is_some()
|
||||
|| self.nebula_palette.is_open()
|
||||
|| self.nebula_side_panel.search_focus
|
||||
|| self.nebula_side_panel.commit_focus
|
||||
|| self.nebula_sftp_panel.as_ref().is_some_and(sftp_panel::SftpPanel::editor_active)
|
||||
@@ -6215,19 +6221,21 @@ impl Display {
|
||||
let bx = ((size.width() - box_w) * 0.5).max(s(16.0));
|
||||
let by = ((size.height() - box_h) * 0.5).max(s(16.0));
|
||||
|
||||
// Veil dims the whole window so the modal reads as, well, modal
|
||||
// (white fog on light themes, black dim on dark — sk.veil).
|
||||
let veil = UiQuad::solid(0.0, 0.0, size.width(), size.height(), 0.0, sk.veil);
|
||||
// Hairline edge + flat themed card (no glow, no gradient).
|
||||
let edge = UiQuad::solid(
|
||||
bx - s(1.0),
|
||||
by - s(1.0),
|
||||
box_w + s(2.0),
|
||||
box_h + s(2.0),
|
||||
s(13.0),
|
||||
sk.hairline,
|
||||
// 确认框是 Modal:它要求一个决策、有后果、必须应答,所以画遮罩。
|
||||
// 面板底、遮罩、外阴影、同心描边、圆角全部来自同一个配方,与命令
|
||||
// 面板/右键菜单共用——此前这里是手写的「遮罩 + 描边 + 填充」三件套,
|
||||
// 圆角 12/13 与别处的 8 对不上,而且**根本没有外阴影**:一个要求
|
||||
// 用户停下来应答的东西,却比随手开关的命令面板浮得还低。
|
||||
let mut quads = Vec::new();
|
||||
ui::surface::push_surface(
|
||||
&mut quads,
|
||||
(bx, by, box_w, box_h),
|
||||
(size.width(), size.height()),
|
||||
scale,
|
||||
&sk,
|
||||
ui::surface::Elevation::Modal,
|
||||
1.0,
|
||||
);
|
||||
let card = UiQuad::solid(bx, by, box_w, box_h, s(12.0), sk.panel);
|
||||
|
||||
// Button geometry (kept for the mouse hit-test).
|
||||
let btn_y = by + box_h - pad * 0.75 - btn_h;
|
||||
@@ -6246,20 +6254,19 @@ impl Display {
|
||||
let cancel_cap_x = cancel_x + btn_pad + text_w(cancel_label) + key_gap;
|
||||
let primary_cap_x = primary_x + btn_pad + text_w(primary_label) + key_gap;
|
||||
|
||||
let mut quads = vec![veil, edge, card];
|
||||
// Cancel: quiet ghost button (hairline + faint fill).
|
||||
quads.push(UiQuad::solid(
|
||||
cancel_x - s(1.0),
|
||||
btn_y - s(1.0),
|
||||
cancel_w + s(2.0),
|
||||
btn_h + s(2.0),
|
||||
s(9.0),
|
||||
let control_r = s(ui::tokens::radius::CONTROL);
|
||||
ui::surface::push_stroke(
|
||||
&mut quads,
|
||||
cancel_rect,
|
||||
control_r,
|
||||
scale,
|
||||
sk.hairline,
|
||||
));
|
||||
quads.push(UiQuad::solid(cancel_x, btn_y, cancel_w, btn_h, s(8.0), sk.panel));
|
||||
quads.push(UiQuad::solid(cancel_x, btn_y, cancel_w, btn_h, s(8.0), sk.surface));
|
||||
);
|
||||
quads.push(UiQuad::solid(cancel_x, btn_y, cancel_w, btn_h, control_r, sk.panel));
|
||||
quads.push(UiQuad::solid(cancel_x, btn_y, cancel_w, btn_h, control_r, sk.surface));
|
||||
// Primary: the single loud element on the card.
|
||||
quads.push(UiQuad::solid(primary_x, btn_y, primary_w, btn_h, s(8.0), primary_fill));
|
||||
quads.push(UiQuad::solid(primary_x, btn_y, primary_w, btn_h, control_r, primary_fill));
|
||||
|
||||
// Keycaps: 图8 键帽规范(2026-07-29)——与 Ctrl+K/设置页共用
|
||||
// `keycap::push_chip` 配方(hairline 圈 + panel/surface 叠底),
|
||||
@@ -6401,24 +6408,19 @@ impl Display {
|
||||
let bar_x = (size.width() - bar_w) * 0.5;
|
||||
let bar_y = size.height() - bar_h - s(18.0);
|
||||
|
||||
let quads = vec![
|
||||
UiQuad::glow(
|
||||
bar_x - s(10.0),
|
||||
bar_y - s(8.0),
|
||||
bar_w + s(20.0),
|
||||
bar_h + s(16.0),
|
||||
Rgba::new(0, 0, 0, 72),
|
||||
),
|
||||
UiQuad::solid(
|
||||
bar_x - s(1.0),
|
||||
bar_y - s(1.0),
|
||||
bar_w + s(2.0),
|
||||
bar_h + s(2.0),
|
||||
s(11.0),
|
||||
sk.hairline,
|
||||
),
|
||||
UiQuad::solid(bar_x, bar_y, bar_w, bar_h, s(10.0), sk.panel),
|
||||
];
|
||||
// 通知条是 Menu 层级的浮层:贴着窗口底边、不阻断交互,靠真外阴影
|
||||
// 与内容分层。此前这里是 `UiQuad::glow` 冒充阴影——glow 向外扩散
|
||||
// 亮度而不是压暗,在浅色主题上只会让条子四周发灰。
|
||||
let mut quads = Vec::new();
|
||||
ui::surface::push_surface(
|
||||
&mut quads,
|
||||
(bar_x, bar_y, bar_w, bar_h),
|
||||
(size.width(), size.height()),
|
||||
scale,
|
||||
&sk,
|
||||
ui::surface::Elevation::Menu,
|
||||
1.0,
|
||||
);
|
||||
self.renderer.draw_ui(&size, &quads);
|
||||
|
||||
let text_y = bar_y + (bar_h - cell_h) * 0.5;
|
||||
@@ -6498,32 +6500,25 @@ impl Display {
|
||||
(bar_x + bar_w - pad - action_w, bar_y + (bar_h - s(34.0)) * 0.5, action_w, s(34.0));
|
||||
self.nebula_ssh_delete_undo_rect = Some(action_rect);
|
||||
|
||||
let mut quads = vec![
|
||||
UiQuad::glow(
|
||||
bar_x - s(10.0),
|
||||
bar_y - s(8.0),
|
||||
bar_w + s(20.0),
|
||||
bar_h + s(16.0),
|
||||
Rgba::new(0, 0, 0, 72),
|
||||
),
|
||||
UiQuad::solid(
|
||||
bar_x - s(1.0),
|
||||
bar_y - s(1.0),
|
||||
bar_w + s(2.0),
|
||||
bar_h + s(2.0),
|
||||
s(11.0),
|
||||
sk.hairline,
|
||||
),
|
||||
UiQuad::solid(bar_x, bar_y, bar_w, bar_h, s(10.0), sk.panel),
|
||||
UiQuad::solid(
|
||||
action_rect.0,
|
||||
action_rect.1,
|
||||
action_rect.2,
|
||||
action_rect.3,
|
||||
s(7.0),
|
||||
if self.nebula_ssh_delete_undo_hover { sk.hover_strong } else { sk.surface },
|
||||
),
|
||||
];
|
||||
// 撤销条同上:Menu 层级的浮层配方,真外阴影而不是 glow。
|
||||
let mut quads = Vec::new();
|
||||
ui::surface::push_surface(
|
||||
&mut quads,
|
||||
(bar_x, bar_y, bar_w, bar_h),
|
||||
(size.width(), size.height()),
|
||||
scale,
|
||||
&sk,
|
||||
ui::surface::Elevation::Menu,
|
||||
1.0,
|
||||
);
|
||||
quads.push(UiQuad::solid(
|
||||
action_rect.0,
|
||||
action_rect.1,
|
||||
action_rect.2,
|
||||
action_rect.3,
|
||||
s(ui::tokens::radius::CONTROL),
|
||||
if self.nebula_ssh_delete_undo_hover { sk.hover_strong } else { sk.surface },
|
||||
));
|
||||
if self.nebula_ssh_delete_undo_hover {
|
||||
quads.push(UiQuad::solid(
|
||||
action_rect.0,
|
||||
|
||||
@@ -3392,7 +3392,7 @@ pub(super) fn draw_text(
|
||||
let (cap_x, ..) = keymap_keycap_rect(rect, &value, cell_w, scale);
|
||||
r.draw_chrome_text(size, cap_x + s(12.0), ty, ink, &value, gc);
|
||||
} else {
|
||||
// 图8 键帽规范:逐键 chip 内居中键名,弱墨 "+" 分隔。
|
||||
// 键帽规范:一颗 chip 承载整串键位(Windows 心智)。
|
||||
let combo = super::ui::keycap::layout_combo(
|
||||
&value,
|
||||
rect.0 + rect.2 - s(16.0),
|
||||
@@ -3412,9 +3412,6 @@ pub(super) fn draw_text(
|
||||
gc,
|
||||
);
|
||||
}
|
||||
for plus_x in &combo.pluses {
|
||||
r.draw_chrome_text(size, *plus_x, ty, sk.ink_faint, "+", gc);
|
||||
}
|
||||
}
|
||||
}
|
||||
let (rx, ry, rw, rh) = geometry.keymap_readonly_row0;
|
||||
|
||||
@@ -273,27 +273,36 @@ impl Display {
|
||||
None
|
||||
};
|
||||
|
||||
let mut quads = vec![
|
||||
UiQuad::solid(
|
||||
0.0,
|
||||
0.0,
|
||||
size.width(),
|
||||
size.height(),
|
||||
0.0,
|
||||
Rgba::new(0, 0, 0, (170.0 * progress).round() as u8),
|
||||
),
|
||||
UiQuad::solid(
|
||||
bx - s(1.0),
|
||||
by - s(1.0),
|
||||
box_w + s(2.0),
|
||||
box_h + s(2.0),
|
||||
s(11.0),
|
||||
skin.hairline,
|
||||
),
|
||||
UiQuad::solid(bx, by, box_w, box_h, s(10.0), skin.panel),
|
||||
UiQuad::solid(bx, footer_top, box_w, box_h - (footer_top - by), 0.0, skin.surface),
|
||||
UiQuad::solid(bx, footer_top, box_w, s(1.0), 0.0, skin.hairline),
|
||||
];
|
||||
// SSH 主机编辑器是 Modal:遮罩、外阴影、同心描边、圆角全部走共享
|
||||
// 配方。此前这里的遮罩是写死的 `Rgba::new(0, 0, 0, 170)`——不读
|
||||
// `skin.veil`,于是浅色主题下也罩一层 67% 的纯黑,比深色主题还重;
|
||||
// 圆角 10/11 也和别处的 8 对不上。
|
||||
let mut quads = Vec::new();
|
||||
super::ui::surface::push_surface(
|
||||
&mut quads,
|
||||
(bx, by, box_w, box_h),
|
||||
(size.width(), size.height()),
|
||||
scale,
|
||||
&skin,
|
||||
super::ui::surface::Elevation::Modal,
|
||||
progress,
|
||||
);
|
||||
quads.push(UiQuad::solid(
|
||||
bx,
|
||||
footer_top,
|
||||
box_w,
|
||||
box_h - (footer_top - by),
|
||||
0.0,
|
||||
super::ui::surface::fade(skin.surface, progress),
|
||||
));
|
||||
quads.push(UiQuad::solid(
|
||||
bx,
|
||||
footer_top,
|
||||
box_w,
|
||||
s(1.0),
|
||||
0.0,
|
||||
super::ui::surface::fade(skin.hairline, progress),
|
||||
));
|
||||
if self.nebula_ssh_editor_hover == SshEditorHit::Close {
|
||||
quads.push(UiQuad::solid(
|
||||
close.0,
|
||||
|
||||
@@ -3,6 +3,13 @@
|
||||
//! These fields do not yet expose arbitrary drag selections, but they must
|
||||
//! still agree on the Windows muscle-memory baseline: Ctrl+A selects the whole
|
||||
//! buffer, Ctrl+C copies that selection, and typing/paste replaces it.
|
||||
//!
|
||||
//! 每个改动缓冲区的方法都会给 [`ui::caret`](super::ui::caret) 打一次活动点,
|
||||
//! 于是光标在打字期间常亮、停手后才开始闪。把打点放在这一层而不是各个调用
|
||||
//! 方,是因为**漏打点的表现("某个操作之后光标闪得不对劲")几乎不会有人
|
||||
//! 报告**,只能靠让它无法漏来保证。
|
||||
|
||||
use super::ui::caret;
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub(crate) struct SelectAllState {
|
||||
@@ -12,10 +19,12 @@ pub(crate) struct SelectAllState {
|
||||
impl SelectAllState {
|
||||
pub(crate) fn select(&mut self, text: &str) {
|
||||
self.selected = !text.is_empty();
|
||||
caret::note_activity();
|
||||
}
|
||||
|
||||
pub(crate) fn clear(&mut self) {
|
||||
self.selected = false;
|
||||
caret::note_activity();
|
||||
}
|
||||
|
||||
pub(crate) fn is_selected(&self) -> bool {
|
||||
@@ -32,6 +41,7 @@ impl SelectAllState {
|
||||
self.selected = false;
|
||||
}
|
||||
text.extend(incoming.chars().filter(|character| !character.is_control()));
|
||||
caret::note_activity();
|
||||
}
|
||||
|
||||
pub(crate) fn backspace(&mut self, text: &mut String) {
|
||||
@@ -41,6 +51,7 @@ impl SelectAllState {
|
||||
} else {
|
||||
text.pop();
|
||||
}
|
||||
caret::note_activity();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
//! chrome 内文本光标的节律。所有自绘输入框共用一套相位。
|
||||
//!
|
||||
//! # 为什么不是「每 500ms 翻一次」
|
||||
//!
|
||||
//! 上一版是 `(unix_millis / 500) % 2 == 0`——相位挂在 UNIX 纪元上。后果:
|
||||
//! 点进输入框的瞬间,光标有 **50% 概率正处在熄灭的半周期**,用户要等最多
|
||||
//! 半秒才看到它。刚点下去的那半秒里,输入框看起来像没获得焦点。
|
||||
//!
|
||||
//! 这里改成相位从**最后一次编辑活动**起算,于是:
|
||||
//!
|
||||
//! 1. **聚焦/打完字的瞬间必定是亮的**,而且立刻就亮——存在感来自这一条,
|
||||
//! 而不是来自闪得更快。
|
||||
//! 2. **连续打字时不闪**。所有成熟编辑器都这样:打字时用户不需要「找光标」,
|
||||
//! 此时闪烁纯粹是视觉干扰。停手之后才开始呼吸。
|
||||
//! 3. 明暗占空比偏向亮侧。等分的方波会让光标有一半时间不存在,读作"断续"
|
||||
//! 而不是"待命"。
|
||||
//!
|
||||
//! # 节律取自系统
|
||||
//!
|
||||
//! Windows 上读 `GetCaretBlinkTime()`(用户在「控制面板 → 键盘」里能调,
|
||||
//! 无障碍设置也会改它,还可能被设成"不闪")。自造一个固定值等于无视这份
|
||||
//! 系统级偏好——原生感正是由这类地方累积出来的。
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
/// 打字期间保持常亮的时长。略长于一个半周期,让正常速度的连续输入
|
||||
/// 完全不触发闪烁。
|
||||
const SOLID_AFTER_EDIT_MS: u64 = 620;
|
||||
|
||||
/// 系统默认半周期。Windows 的出厂值就是 530ms,其他平台沿用它——
|
||||
/// 这个节律本身是几十年的行业惯例,不是 Windows 特有的口味。
|
||||
const DEFAULT_HALF_CYCLE_MS: u64 = 530;
|
||||
|
||||
/// 亮相占一个完整周期的比例。0.5 是等分方波;偏向亮侧让光标读作
|
||||
/// 「待命」而不是「断续」。
|
||||
const ON_DUTY: f32 = 0.58;
|
||||
|
||||
/// 最后一次编辑活动的挂钟毫秒。0 表示"还没有过活动"。
|
||||
static LAST_ACTIVITY_MS: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
fn now_millis() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// 系统的闪烁半周期。返回 `None` 表示用户要求光标**不闪**——这是一个
|
||||
/// 真实的无障碍设置(闪烁会诱发部分人的偏头痛甚至光敏性癫痫),必须尊重。
|
||||
#[cfg(windows)]
|
||||
fn system_half_cycle_ms() -> Option<u64> {
|
||||
// Win32 用 INFINITE(u32::MAX)表示"关闭闪烁"。
|
||||
let raw = unsafe { windows_sys::Win32::UI::WindowsAndMessaging::GetCaretBlinkTime() };
|
||||
match raw {
|
||||
0 | u32::MAX => None,
|
||||
value => Some(u64::from(value)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn system_half_cycle_ms() -> Option<u64> {
|
||||
Some(DEFAULT_HALF_CYCLE_MS)
|
||||
}
|
||||
|
||||
/// 记录一次编辑活动(插入、删除、移动光标、获得焦点)。
|
||||
///
|
||||
/// 调用后光标立刻变亮,并从此刻重新起算相位。所有会改动缓冲区或光标位置
|
||||
/// 的路径都该调它——漏掉一处的表现是「那个操作之后光标闪得不对劲」。
|
||||
pub fn note_activity() {
|
||||
LAST_ACTIVITY_MS.store(now_millis(), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// 这一帧该不该画光标。
|
||||
pub fn is_on() -> bool {
|
||||
let Some(half_cycle) = system_half_cycle_ms() else {
|
||||
// 用户关掉了闪烁:光标常亮,位置仍然可见。
|
||||
return true;
|
||||
};
|
||||
let last = LAST_ACTIVITY_MS.load(Ordering::Relaxed);
|
||||
let since = now_millis().saturating_sub(last);
|
||||
phase_is_on(since, half_cycle)
|
||||
}
|
||||
|
||||
/// 纯函数形式的相位判定,便于在没有挂钟的情况下测试。
|
||||
fn phase_is_on(since_activity_ms: u64, half_cycle_ms: u64) -> bool {
|
||||
if since_activity_ms < SOLID_AFTER_EDIT_MS {
|
||||
return true;
|
||||
}
|
||||
let cycle = (half_cycle_ms * 2).max(1);
|
||||
let on_span = (cycle as f32 * ON_DUTY) as u64;
|
||||
(since_activity_ms - SOLID_AFTER_EDIT_MS) % cycle < on_span
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_moment_after_an_edit_is_always_lit() {
|
||||
// 这条是本模块存在的理由:旧实现下这一刻有一半概率是灭的。
|
||||
assert!(phase_is_on(0, DEFAULT_HALF_CYCLE_MS));
|
||||
assert!(phase_is_on(1, DEFAULT_HALF_CYCLE_MS));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_does_not_blink() {
|
||||
// 以 ~120ms 一个字符的正常手速连打,全程常亮。
|
||||
for keystroke in 0..40u64 {
|
||||
assert!(
|
||||
phase_is_on(keystroke * 120 % SOLID_AFTER_EDIT_MS, DEFAULT_HALF_CYCLE_MS),
|
||||
"打字过程中不该出现熄灭帧",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_starts_blinking_after_the_hand_stops() {
|
||||
// 静置足够久之后,明暗都必须出现过——否则就是"不闪"而非"呼吸"。
|
||||
let cycle = DEFAULT_HALF_CYCLE_MS * 2;
|
||||
let sampled: Vec<bool> = (0..cycle)
|
||||
.step_by(20)
|
||||
.map(|offset| phase_is_on(SOLID_AFTER_EDIT_MS + offset, DEFAULT_HALF_CYCLE_MS))
|
||||
.collect();
|
||||
assert!(sampled.contains(&true) && sampled.contains(&false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lit_span_outweighs_the_dark_span() {
|
||||
let cycle = DEFAULT_HALF_CYCLE_MS * 2;
|
||||
let lit = (0..cycle)
|
||||
.filter(|offset| phase_is_on(SOLID_AFTER_EDIT_MS + offset, DEFAULT_HALF_CYCLE_MS))
|
||||
.count();
|
||||
assert!(
|
||||
lit * 2 > cycle as usize,
|
||||
"亮相要多于一半,否则光标读作断续而不是待命(实测 {lit}/{cycle})",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zero_blink_time_never_divides_by_zero() {
|
||||
// GetCaretBlinkTime 理论上可以返回很小的值;周期钳到 >=1 防除零。
|
||||
assert!(phase_is_on(SOLID_AFTER_EDIT_MS, 0) || !phase_is_on(SOLID_AFTER_EDIT_MS, 0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
//! 把设计裁定从「文档里的建议」变成「测试里的红线」。
|
||||
//!
|
||||
//! # 为什么需要它
|
||||
//!
|
||||
//! 2026-07-29 用户反馈:*「这些细节我发现有时候每次加新功能都要说一次;
|
||||
//! 因此你需要给我们项目 ui 层强约束」*。
|
||||
//!
|
||||
//! [`super`] 的模块文档早就写着「层契约(新增代码必须遵守)……不写魔数」,
|
||||
//! 而 `widgets.rs` 自己就在写 `s(7.0)`、`s(9.0)`。这不是谁不认真——**只靠
|
||||
//! 注释表达的约束,等价于没有约束**。写新功能的人不会先去读隔壁模块的文档,
|
||||
//! 而复制粘贴一段能跑的旧代码是所有人的默认动作。
|
||||
//!
|
||||
//! 所以这里把约束下沉到 `cargo test`:违规会让构建变红,并且错误信息直接
|
||||
//! 告诉你正确写法是什么。
|
||||
//!
|
||||
//! # 违规数只能降,不能升
|
||||
//!
|
||||
//! 存量违规有几十处,一次清完既不现实也不安全(很多在难以回归验证的路径
|
||||
//! 上)。所以每条规则带一个**预算**=当前实际违规数:
|
||||
//!
|
||||
//! - 新代码引入违规 → 超预算 → 测试失败。**新债被立刻挡住。**
|
||||
//! - 顺手清理了存量 → 低于预算 → 测试也失败,要求把预算调低。
|
||||
//! 这一条同样重要:否则预算会随时间虚高,规则慢慢失去约束力。
|
||||
//!
|
||||
//! 净效果是违规数**单调下降**,且不需要谁去记得推进它。
|
||||
//!
|
||||
//! # 边界
|
||||
//!
|
||||
//! 这是文本扫描,不是类型系统,能被绕过(改个写法就躲开匹配)。它防的是
|
||||
//! 「无意识地复制了旧配方」,不是「蓄意规避」。真正的编译期强制要等
|
||||
//! `UiQuad` 的圆角参数换成枚举,那是 `nebula_ui` crate 抽出来之后的事。
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::fmt::Write as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// 圆角实参允许的字面量。`0.0` 是直角,其余走 [`super::super::tokens::radius`]。
|
||||
///
|
||||
/// 注意这里比对的是**源码文本**而不是数值:目的就是逼调用方写
|
||||
/// `radius::OVERLAY * scale` 而不是碰巧等值的 `s(8.0)`——后者在圆角阶梯
|
||||
/// 整体调整时不会跟着变,是"改不干净"的根源。
|
||||
const RADIUS_LITERAL_ALLOWLIST: &[&str] = &["0.0"];
|
||||
|
||||
fn source_files() -> Vec<PathBuf> {
|
||||
fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
walk(&path, out);
|
||||
} else if path.extension().is_some_and(|e| e == "rs") {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
walk(&Path::new(env!("CARGO_MANIFEST_DIR")).join("src/display"), &mut out);
|
||||
// 本文件必须排除:规则是用字符串字面量表达的(`"UiQuad::glow("`),
|
||||
// 扫描自己会把规则本身连同它的自测样本一起算成违规。
|
||||
out.retain(|path| label(path) != "guardrails.rs");
|
||||
out.sort();
|
||||
assert!(!out.is_empty(), "扫不到源文件,规则会静默放水——先修路径");
|
||||
out
|
||||
}
|
||||
|
||||
fn label(path: &Path) -> String {
|
||||
path.file_name().unwrap_or_default().to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
/// 顶层逗号分割一次函数调用的实参。嵌套调用(`Rgba::new(r, g, b, a)`)
|
||||
/// 里的逗号不算,否则参数序号会错位。
|
||||
fn split_args(src: &str, open_paren: usize) -> Option<Vec<String>> {
|
||||
let bytes = src.as_bytes();
|
||||
let mut depth = 0i32;
|
||||
let mut args = Vec::new();
|
||||
let mut start = open_paren + 1;
|
||||
let mut index = open_paren;
|
||||
while index < bytes.len() {
|
||||
match bytes[index] {
|
||||
b'(' | b'[' => depth += 1,
|
||||
b')' | b']' => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
args.push(src[start..index].trim().to_owned());
|
||||
return Some(args);
|
||||
}
|
||||
},
|
||||
b',' if depth == 1 => {
|
||||
args.push(src[start..index].trim().to_owned());
|
||||
start = index + 1;
|
||||
},
|
||||
_ => {},
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn line_of(src: &str, offset: usize) -> usize {
|
||||
src[..offset].bytes().filter(|&b| b == b'\n').count() + 1
|
||||
}
|
||||
|
||||
/// 每次 `UiQuad::solid(x, y, w, h, radius, color)` 的圆角实参。
|
||||
fn radius_arguments(src: &str) -> Vec<(usize, String)> {
|
||||
let mut out = Vec::new();
|
||||
let needle = "UiQuad::solid(";
|
||||
let mut from = 0;
|
||||
while let Some(found) = src[from..].find(needle) {
|
||||
let at = from + found;
|
||||
let open = at + needle.len() - 1;
|
||||
if let Some(mut args) = split_args(src, open) {
|
||||
// rustfmt 给多行调用加尾随逗号,切分后会多出一个空尾项。
|
||||
if args.last().is_some_and(String::is_empty) {
|
||||
args.pop();
|
||||
}
|
||||
if args.len() == 6 {
|
||||
out.push((line_of(src, at), args[4].replace(char::is_whitespace, "")));
|
||||
}
|
||||
}
|
||||
from = at + needle.len();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 圆角是否写死了一个不该写死的字面量。
|
||||
///
|
||||
/// 放行的形式:token 常量、以及从别处派生的表达式(`th * 0.5` 的胶囊、
|
||||
/// `knob / 2.0` 的圆形、上游传下来的 `corner`)。只揪 `s(常数)` 和裸常数。
|
||||
fn is_hardcoded_radius(arg: &str) -> bool {
|
||||
let inner = arg
|
||||
.strip_prefix("s(")
|
||||
.and_then(|rest| rest.strip_suffix(')'))
|
||||
.unwrap_or(arg);
|
||||
if !inner.bytes().all(|b| b.is_ascii_digit() || b == b'.') || inner.is_empty() {
|
||||
return false;
|
||||
}
|
||||
!RADIUS_LITERAL_ALLOWLIST.contains(&inner)
|
||||
}
|
||||
|
||||
/// 一条数量上限规则的检查。`budget` 是当前实际违规数。
|
||||
fn check_budget(rule: &str, why: &str, fix: &str, budget: usize, hits: Vec<(String, usize, String)>) {
|
||||
let found = hits.len();
|
||||
if found == budget {
|
||||
return;
|
||||
}
|
||||
let mut report = String::new();
|
||||
let _ = write!(report, "\n\n【UI 强约束】{rule}\n\n{why}\n\n正确写法:{fix}\n\n");
|
||||
if found > budget {
|
||||
let _ = write!(
|
||||
report,
|
||||
"违规数 {found} > 预算 {budget}——新增了 {} 处。\n\
|
||||
预算是给存量债留的口子,不接受新债;请改用上面的写法。\n\n\
|
||||
全部命中:\n",
|
||||
found - budget
|
||||
);
|
||||
} else {
|
||||
let _ = write!(
|
||||
report,
|
||||
"违规数 {found} < 预算 {budget}——存量清掉了 {} 处,很好。\n\
|
||||
现在请把本规则的预算改成 {found},把上限调低一格。\n\
|
||||
(不收紧的话预算会虚高,规则慢慢就管不住人了。)\n\n\
|
||||
剩余命中:\n",
|
||||
budget - found
|
||||
);
|
||||
}
|
||||
for (file, line, detail) in hits.iter().take(40) {
|
||||
let _ = write!(report, " {file}:{line} {detail}\n");
|
||||
}
|
||||
if found > 40 {
|
||||
let _ = write!(report, " …… 其余 {} 处省略\n", found - 40);
|
||||
}
|
||||
panic!("{report}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radius_comes_from_the_token_ladder() {
|
||||
let mut hits = Vec::new();
|
||||
for path in source_files() {
|
||||
let Ok(src) = std::fs::read_to_string(&path) else { continue };
|
||||
for (line, arg) in radius_arguments(&src) {
|
||||
if is_hardcoded_radius(&arg) {
|
||||
hits.push((label(&path), line, format!("圆角写死为 {arg}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
check_budget(
|
||||
"圆角必须来自 tokens::radius 阶梯",
|
||||
"2026-07-29 侦察:八个浮层用了六种圆角(14/12/12/10/9/8),\n\
|
||||
界面因此读不出体系。写死的数字在阶梯整体调整时不会跟着变——\n\
|
||||
这正是「配色/圆角改不干净」的结构性原因。",
|
||||
"radius::OVERLAY * scale(浮层)/ radius::CONTROL * scale(控件)\n\
|
||||
/ radius::CHIP * scale(chip 键帽),或直接用 surface::push_* 配方",
|
||||
94,
|
||||
hits,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stroke_rings_come_from_the_surface_recipe() {
|
||||
let mut hits = Vec::new();
|
||||
for path in source_files() {
|
||||
let Ok(src) = std::fs::read_to_string(&path) else { continue };
|
||||
if label(&path) == "surface.rs" {
|
||||
continue; // 配方的定义处,它就是那个唯一来源
|
||||
}
|
||||
for (index, line) in src.lines().enumerate() {
|
||||
if line.contains("- s(1.0)") {
|
||||
hits.push((label(&path), index + 1, "手写的 (x-1, y-1, w+2, h+2) 描边".to_owned()));
|
||||
}
|
||||
}
|
||||
}
|
||||
check_budget(
|
||||
"发丝描边必须走 surface::push_stroke",
|
||||
"手写描边普遍内外用同一个半径,圆角处描边会变粗——「边缘发毛」的\n\
|
||||
来源之一。push_stroke 让外半径 = 内半径 + 描边宽,内外弧同心。",
|
||||
"surface::push_stroke(quads, rect, corner, scale, sk.hairline)",
|
||||
56,
|
||||
hits,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn depth_comes_from_shadow_not_glow() {
|
||||
let mut hits = Vec::new();
|
||||
for path in source_files() {
|
||||
let Ok(src) = std::fs::read_to_string(&path) else { continue };
|
||||
for (index, line) in src.lines().enumerate() {
|
||||
if line.contains("UiQuad::glow(") {
|
||||
hits.push((label(&path), index + 1, "用 glow 冒充阴影".to_owned()));
|
||||
}
|
||||
}
|
||||
}
|
||||
check_budget(
|
||||
"Z 轴层级用 shadow,不用 glow",
|
||||
"glow 是「发光」——它向外扩散亮度,不建立高度关系,和外阴影叠加\n\
|
||||
还会让边缘浑浊。浮层浮不起来时加 glow 是在解决错误的问题。",
|
||||
"surface::push_surface(..., Elevation::Popover, ...),它内部用 UiQuad::shadow",
|
||||
9,
|
||||
hits,
|
||||
);
|
||||
}
|
||||
|
||||
// ---- 扫描器自身的正确性 ----
|
||||
//
|
||||
// 这套检查的价值完全建立在「数得准」上:漏数会静默放水,多数会挡住无辜的
|
||||
// 提交。所以解析器本身要有测试。
|
||||
|
||||
#[test]
|
||||
fn nested_calls_do_not_shift_the_argument_index() {
|
||||
let src = "UiQuad::solid(x, y, w, h, s(12.0), Rgba::new(1, 2, 3, 4))";
|
||||
let args = radius_arguments(src);
|
||||
assert_eq!(args.len(), 1);
|
||||
assert_eq!(args[0].1, "s(12.0)", "Rgba::new 里的逗号不能被当成顶层分隔");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_line_calls_are_counted_at_their_opening_line() {
|
||||
let src = "line one\nUiQuad::solid(\n x,\n y,\n w,\n h,\n s(9.0),\n color,\n)";
|
||||
let args = radius_arguments(src);
|
||||
assert_eq!(args, vec![(2, "s(9.0)".to_owned())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derived_radii_are_not_flagged() {
|
||||
// 胶囊、圆形、上游传下来的 corner 都是合法的——只揪写死的常数。
|
||||
for ok in ["0.0", "corner", "th*0.5", "knob/2.0", "radius::OVERLAY*scale", "thumb_r"] {
|
||||
assert!(!is_hardcoded_radius(ok), "{ok} 不该被判违规");
|
||||
}
|
||||
for bad in ["s(12.0)", "s(8.0)", "14.0"] {
|
||||
assert!(is_hardcoded_radius(bad), "{bad} 应该被判违规");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,17 @@
|
||||
//! 图8 键帽规范(2026-07-29 用户裁定):所有展示键位的地方统一为逐键
|
||||
//! chip——hairline 圈边 + panel/surface 叠底、小圆角、弱墨 "+" 分隔;
|
||||
//! 不再各处自造键帽样式,也不叠加额外装饰。
|
||||
//! 键帽规范:所有展示键位的地方共用一颗 chip——hairline 圈边 +
|
||||
//! panel/surface 叠底、小圆角;不再各处自造键帽样式,也不叠加额外装饰。
|
||||
//!
|
||||
//! # 为什么是**一整串**而不是逐键拆分
|
||||
//!
|
||||
//! 2026-07-29 用户裁定,推翻了本文件的上一版。逐键 chip 是 macOS 的形式:
|
||||
//! 那里修饰键是 `⌘ ⇧ ⌥` 单字符图形,一符号一方块天然成立、宽度还整齐。
|
||||
//! Windows 上修饰键是 `Ctrl` `Shift` `Alt` 这种**单词**,拆开会得到一排
|
||||
//! 宽度参差的方块,画面反而更碎。
|
||||
//!
|
||||
//! 更重要的是心智迁移:Windows 用户的既有参照系(VS Code、Windows
|
||||
//! Terminal、Office)一律把组合键显示成 `Ctrl+Shift+P` 一整串。跟随它,
|
||||
//! 用户把已有的识别习惯整体搬过来,学习成本为零;自造形式则要他们先解析
|
||||
//! 一遍"这几个方块是一个组合还是三个选项"。
|
||||
//!
|
||||
//! `layout_combo` 是几何唯一来源:quad pass 与 text pass 各调一次拿到
|
||||
//! 相同矩形(同 settings 键位行 quad/text 共用几何的既有约定)。
|
||||
@@ -8,25 +19,31 @@
|
||||
use unicode_width::UnicodeWidthChar;
|
||||
|
||||
use super::theme::Skin;
|
||||
use super::tokens::radius;
|
||||
use crate::renderer::ui::UiQuad;
|
||||
|
||||
/// Chip height in logical px(与 palette 底栏既有键帽一致)。
|
||||
pub const KEY_H: f32 = 20.0;
|
||||
|
||||
/// chip 内的左右呼吸缝。整串比单键长,边距太小会显得字挤在框上。
|
||||
const PAD_X: f32 = 7.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。
|
||||
/// One rendered key chip: `(x, w, text)`。
|
||||
///
|
||||
/// 仍是 `Vec` 而不是单个字段:调用方按序列消费,未来若某个平台要回到逐键
|
||||
/// 形式(例如 macOS 版),只需换掉本文件的布局函数,绘制端不动。
|
||||
pub struct ComboChips {
|
||||
pub chips: Vec<(f32, f32, String)>,
|
||||
pub pluses: Vec<f32>,
|
||||
/// Bounding box `(x, y, w, h)` across every chip.
|
||||
pub bounds: (f32, f32, f32, f32),
|
||||
}
|
||||
|
||||
/// 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`.
|
||||
/// Lay out a `Ctrl+Shift+P` style combo as one chip, right-aligned so it ends
|
||||
/// at `right_x`, vertically centered on `center_y`.
|
||||
pub fn layout_combo(
|
||||
combo: &str,
|
||||
right_x: f32,
|
||||
@@ -37,26 +54,15 @@ pub fn layout_combo(
|
||||
let s = |v: f32| v * scale;
|
||||
let key_h = s(KEY_H);
|
||||
let key_y = center_y - key_h / 2.0;
|
||||
let keys: Vec<&str> = combo.split('+').filter(|k| !k.is_empty()).collect();
|
||||
let chip_w = |key: &str| cols(key) as f32 * cell_w + s(10.0);
|
||||
let total: f32 = keys.iter().map(|k| chip_w(k)).sum::<f32>()
|
||||
+ keys.len().saturating_sub(1) as f32 * (cell_w + s(8.0));
|
||||
let mut x = right_x - total;
|
||||
let mut chips = Vec::with_capacity(keys.len());
|
||||
let mut pluses = Vec::new();
|
||||
for (index, key) in keys.iter().enumerate() {
|
||||
let w = chip_w(key);
|
||||
chips.push((x, w, (*key).to_owned()));
|
||||
x += w;
|
||||
if index + 1 < keys.len() {
|
||||
pluses.push(x + s(4.0));
|
||||
x += cell_w + s(8.0);
|
||||
}
|
||||
}
|
||||
ComboChips { chips, pluses, bounds: (right_x - total, key_y, total, key_h) }
|
||||
// 规范化:丢掉空段后用 "+" 重新连接。畸形输入(末尾多一个 "+"、
|
||||
// 连续 "++")因此得到稳定结果,与上一版逐键实现的取舍保持一致。
|
||||
let label = combo.split('+').filter(|k| !k.is_empty()).collect::<Vec<_>>().join("+");
|
||||
let width = cols(&label) as f32 * cell_w + s(PAD_X) * 2.0;
|
||||
let x = right_x - width;
|
||||
ComboChips { chips: vec![(x, width, label)], bounds: (x, key_y, width, key_h) }
|
||||
}
|
||||
|
||||
/// The one chip recipe: hairline ring + panel + surface, radius 4.
|
||||
/// The one chip recipe: hairline ring + panel + surface, chip radius.
|
||||
pub fn push_chip(
|
||||
quads: &mut Vec<UiQuad>,
|
||||
sk: &Skin,
|
||||
@@ -66,10 +72,10 @@ pub fn push_chip(
|
||||
h: f32,
|
||||
scale: f32,
|
||||
) {
|
||||
let s = |v: f32| v * scale;
|
||||
quads.push(UiQuad::solid(x - s(1.0), y - s(1.0), w + s(2.0), h + s(2.0), s(5.0), sk.hairline));
|
||||
quads.push(UiQuad::solid(x, y, w, h, s(4.0), sk.panel));
|
||||
quads.push(UiQuad::solid(x, y, w, h, s(4.0), sk.surface));
|
||||
let corner = radius::CHIP * scale;
|
||||
super::surface::push_stroke(quads, (x, y, w, h), corner, scale, sk.hairline);
|
||||
quads.push(UiQuad::solid(x, y, w, h, corner, sk.panel));
|
||||
quads.push(UiQuad::solid(x, y, w, h, corner, sk.surface));
|
||||
}
|
||||
|
||||
/// Push every chip of a laid-out combo.
|
||||
@@ -85,25 +91,41 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn combo_right_aligns_and_separates_keys() {
|
||||
fn combo_is_one_chip_carrying_the_whole_string() {
|
||||
// Windows 心智:`Ctrl+K` 是一颗键帽,不是两颗方块加一个加号。
|
||||
let combo = layout_combo("Ctrl+K", 500.0, 100.0, 10.0, 1.0);
|
||||
assert_eq!(combo.chips.len(), 2);
|
||||
assert_eq!(combo.pluses.len(), 1);
|
||||
let (last_x, last_w, ref last_key) = combo.chips[1];
|
||||
assert_eq!(last_key, "K");
|
||||
assert!((last_x + last_w - 500.0).abs() < 0.01, "last chip ends at right_x");
|
||||
assert_eq!(combo.chips.len(), 1);
|
||||
let (x, w, ref label) = combo.chips[0];
|
||||
assert_eq!(label, "Ctrl+K");
|
||||
assert!((x + w - 500.0).abs() < 0.01, "chip 右缘落在 right_x");
|
||||
assert_eq!(w, 6.0 * 10.0 + PAD_X * 2.0, "宽 = 整串列宽 + 左右呼吸缝");
|
||||
let (bx, _, bw, bh) = combo.bounds;
|
||||
assert!((bx + bw - 500.0).abs() < 0.01);
|
||||
assert_eq!(bh, KEY_H);
|
||||
// chips never overlap the plus separator
|
||||
let (first_x, first_w, _) = combo.chips[0];
|
||||
assert!(combo.pluses[0] >= first_x + first_w);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_key_has_no_plus() {
|
||||
fn three_key_combo_stays_a_single_chip() {
|
||||
let combo = layout_combo("Ctrl+Shift+P", 400.0, 50.0, 8.0, 1.0);
|
||||
assert_eq!(combo.chips.len(), 1);
|
||||
assert_eq!(combo.chips[0].2, "Ctrl+Shift+P");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_input_normalizes_instead_of_drawing_empty_keys() {
|
||||
// 末尾多一个 "+"、连续 "++" 都不该画出空键帽。
|
||||
for raw in ["Ctrl+", "+Ctrl", "Ctrl++K"] {
|
||||
let combo = layout_combo(raw, 300.0, 50.0, 8.0, 1.0);
|
||||
assert_eq!(combo.chips.len(), 1);
|
||||
assert!(!combo.chips[0].2.is_empty());
|
||||
assert!(!combo.chips[0].2.ends_with('+'));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_key_needs_no_separator() {
|
||||
let combo = layout_combo("Esc", 300.0, 50.0, 8.0, 2.0);
|
||||
assert_eq!(combo.chips.len(), 1);
|
||||
assert!(combo.pluses.is_empty());
|
||||
assert_eq!(combo.chips[0].2, "Esc");
|
||||
}
|
||||
}
|
||||
@@ -1,48 +1,57 @@
|
||||
//! 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;
|
||||
//! 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`] —— 图标墨迹
|
||||
//! - [`guardrails`] —— 把上面这些契约变成会失败的测试
|
||||
//!
|
||||
//! # 契约怎么被强制
|
||||
//!
|
||||
//! 第 4 条(不写魔数)曾经只写在这段注释里,然后 `widgets.rs` 自己就违反了
|
||||
//! 它——只靠注释表达的约束等于没有约束。现在 [`guardrails`] 用数量上限测试盯着:
|
||||
//! 新代码引入违规会让 `cargo test` 变红,且错误信息直接给出正确写法。
|
||||
//!
|
||||
//! # 未来
|
||||
//!
|
||||
//! 本目录的内容就是将来 `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 caret;
|
||||
pub mod guardrails;
|
||||
pub mod icons;
|
||||
pub mod keycap;
|
||||
pub mod surface;
|
||||
pub mod theme;
|
||||
pub mod tokens;
|
||||
pub mod widgets;
|
||||
@@ -56,6 +56,17 @@ impl Elevation {
|
||||
pub fn dims_background(self) -> bool {
|
||||
matches!(self, Self::Modal)
|
||||
}
|
||||
|
||||
/// 面板底是否必须完全不透明。
|
||||
///
|
||||
/// 菜单和下拉紧贴着它们遮住的那一行内容,`Skin::panel` 那 4% 的透明度
|
||||
/// 会让底下的文字隐隐透上来——在选项文字背后叠一层错位的鬼影,正是
|
||||
/// 界面读起来"脏"的那种脏。命令面板和模态离内容远、且四周有阴影过渡,
|
||||
/// 留一点透视反而自然。
|
||||
#[inline]
|
||||
fn needs_opaque_fill(self) -> bool {
|
||||
matches!(self, Self::Menu)
|
||||
}
|
||||
}
|
||||
|
||||
/// 浮层内容块的状态。
|
||||
@@ -136,7 +147,12 @@ pub fn push_surface(
|
||||
));
|
||||
|
||||
push_stroke(quads, rect, corner, scale, fade(sk.hairline, progress));
|
||||
quads.push(UiQuad::solid(x, y, w, h, corner, fade(sk.panel, progress)));
|
||||
let fill = if level.needs_opaque_fill() {
|
||||
Rgba::new(sk.panel.r, sk.panel.g, sk.panel.b, 255)
|
||||
} else {
|
||||
sk.panel
|
||||
};
|
||||
quads.push(UiQuad::solid(x, y, w, h, corner, fade(fill, progress)));
|
||||
}
|
||||
|
||||
/// 浮层内部的内容块。
|
||||
|
||||
@@ -426,8 +426,11 @@ impl NebulaTheme {
|
||||
pub(crate) fn skin(self) -> Skin {
|
||||
let p = self.palette();
|
||||
let a = self.accent();
|
||||
let b = p.panel;
|
||||
let t = p.term_bg;
|
||||
// 浮层相对**内容层**(终端)提亮 14 级。用 term_bg 而不是 p.panel:
|
||||
// p.panel 是窗口外壳色,比终端还暗(Steel 外壳 22,24,30 vs 终端
|
||||
// 26,28,36),拿它当浮层底会让弹窗陷进背景里,方向正好是反的。
|
||||
let lift = |c: u8| c.saturating_add(14);
|
||||
if p.is_light {
|
||||
Skin {
|
||||
// 明度阶梯(2026-07-29 裁定,见 docs/nebula-visual-design-language.md
|
||||
@@ -480,13 +483,21 @@ impl NebulaTheme {
|
||||
}
|
||||
} else {
|
||||
Skin {
|
||||
panel: Rgba::new(b.r, b.g, b.b, 250),
|
||||
// 深色下浮层的方向与浅色**相反**:比内容层更亮。
|
||||
// 统一表述是「越靠前,与内容层的对比越强」,方向由主题决定
|
||||
// (otty 深色命令面板比背景亮,浅色命令面板比纯白终端暗)。
|
||||
//
|
||||
// 阶梯:终端 26 → panel 40 → card 54,每层差 14。
|
||||
panel: Rgba::new(lift(t.r), lift(t.g), lift(t.b), 250),
|
||||
// Derive the inset/input surface from the terminal background
|
||||
// so it stays in-family on every dark theme (blue-black on
|
||||
// Nebula, pure gray on Coal) instead of one fixed navy.
|
||||
input: Rgba::new(t.r, t.g, t.b, 220),
|
||||
card: Rgba::new(255, 255, 255, 16),
|
||||
veil: Rgba::new(0, 0, 0, 150),
|
||||
// modal 专用(popover 不画遮罩)。原值 alpha 150(59%)在本就
|
||||
// 很暗的底上接近全黑,把上下文糊没了;36% 足够传达"被阻断",
|
||||
// 背景仍可读。冷调 slate-950 而不是纯黑,与整套灰同色温。
|
||||
veil: Rgba::new(2, 6, 23, 92),
|
||||
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
|
||||
|
||||
@@ -62,14 +62,17 @@ pub mod elevation {
|
||||
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;
|
||||
pub const POPOVER_BLUR: f32 = 14.0;
|
||||
pub const POPOVER_OFFSET_Y: f32 = 5.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;
|
||||
pub const MODAL_BLUR: f32 = 20.0;
|
||||
pub const MODAL_OFFSET_Y: f32 = 7.0;
|
||||
/// 阴影浓度。2026-07-29 用户裁定:此前 54/86(21%/34%)太重,浮层像
|
||||
/// 被垫了一块黑布。成熟终端的浮层阴影普遍在 10% 上下——它只需要**暗示**
|
||||
/// 高度,一旦看得清"这是一片阴影"就已经过头了。浅色底上尤其要淡,
|
||||
/// 黑色扩散在浅灰上很容易显脏。
|
||||
pub const SHADOW_ALPHA_LIGHT: u8 = 26;
|
||||
pub const SHADOW_ALPHA_DARK: u8 = 56;
|
||||
}
|
||||
|
||||
/// Terminal-grid feedback colors are rendered through the cell/rect alpha
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
//! geometry (`*_rect`, [`combobox_text_x`]) and the text pass reuses it.
|
||||
|
||||
use super::icons;
|
||||
use super::surface;
|
||||
use super::theme::Skin;
|
||||
use super::tokens::radius;
|
||||
use crate::renderer::ui::{Rgba, UiQuad};
|
||||
|
||||
pub(crate) type Rect = (f32, f32, f32, f32);
|
||||
@@ -147,11 +149,12 @@ pub(crate) fn push_combobox(
|
||||
) {
|
||||
let s = |v: f32| v * scale;
|
||||
let (cx, cy, cw, ch) = rect;
|
||||
quads.push(UiQuad::solid(cx - s(1.0), cy - s(1.0), cw + s(2.0), ch + s(2.0), s(7.0), sk.hairline));
|
||||
quads.push(UiQuad::solid(cx, cy, cw, ch, s(6.0), opaque_panel(sk)));
|
||||
quads.push(UiQuad::solid(cx, cy, cw, ch, s(6.0), sk.surface));
|
||||
let corner = radius::CONTROL * scale;
|
||||
surface::push_stroke(quads, rect, corner, scale, sk.hairline);
|
||||
quads.push(UiQuad::solid(cx, cy, cw, ch, corner, opaque_panel(sk)));
|
||||
quads.push(UiQuad::solid(cx, cy, cw, ch, corner, sk.surface));
|
||||
if hot || open {
|
||||
quads.push(UiQuad::solid(cx, cy, cw, ch, s(6.0), sk.hover));
|
||||
quads.push(UiQuad::solid(cx, cy, cw, ch, corner, sk.hover));
|
||||
}
|
||||
let ink = Rgba::new(sk.ink_dim.r, sk.ink_dim.g, sk.ink_dim.b, 235);
|
||||
icons::push_chevron(quads, cx + cw - s(15.0), cy + ch * 0.5, scale, ink, open);
|
||||
@@ -202,18 +205,35 @@ pub(crate) fn push_combobox_popup(
|
||||
sk: &Skin,
|
||||
) {
|
||||
let s = |v: f32| v * scale;
|
||||
let (px, py, pw, ph) = popup;
|
||||
quads.push(UiQuad::glow(px - s(14.0), py - s(10.0), pw + s(28.0), ph + s(26.0), Rgba::new(0, 0, 0, 70)));
|
||||
quads.push(UiQuad::solid(px - s(1.0), py - s(1.0), pw + s(2.0), ph + s(2.0), s(9.0), sk.hairline));
|
||||
quads.push(UiQuad::solid(px, py, pw, ph, s(8.0), opaque_panel(sk)));
|
||||
quads.push(UiQuad::solid(px, py, pw, ph, s(8.0), sk.surface));
|
||||
// 层级走 Menu:真外阴影 + 同心描边 + 不透明底。此前这里是
|
||||
// `UiQuad::glow` —— glow 向外扩散亮度,不建立高度关系,在浅色主题上
|
||||
// 只会让下拉四周发灰,看着像脏了一圈而不是浮起来。
|
||||
surface::push_surface(quads, popup, (0.0, 0.0), scale, sk, surface::Elevation::Menu, 1.0);
|
||||
quads.push(UiQuad::solid(
|
||||
popup.0,
|
||||
popup.1,
|
||||
popup.2,
|
||||
popup.3,
|
||||
radius::OVERLAY * scale,
|
||||
sk.surface,
|
||||
));
|
||||
for index in 0..count {
|
||||
let (rx, ry, rw, rh) = popup_row_rect(popup, index, scale);
|
||||
if selected == Some(index) {
|
||||
quads.push(UiQuad::solid(rx, ry + s(2.0), rw, rh - s(4.0), s(6.0), sk.accent_soft));
|
||||
let fill = if selected == Some(index) {
|
||||
sk.accent_soft
|
||||
} else if hover == Some(index) {
|
||||
quads.push(UiQuad::solid(rx, ry + s(2.0), rw, rh - s(4.0), s(6.0), sk.hover));
|
||||
}
|
||||
sk.hover
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
quads.push(UiQuad::solid(
|
||||
rx,
|
||||
ry + s(2.0),
|
||||
rw,
|
||||
rh - s(4.0),
|
||||
radius::CONTROL * scale,
|
||||
fill,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,17 +263,17 @@ pub(crate) fn push_spinner(
|
||||
hot_up: bool,
|
||||
hot_down: bool,
|
||||
) {
|
||||
let s = |v: f32| v * scale;
|
||||
let (value, up, down) = spinner_rects(row, scale);
|
||||
let corner = radius::CONTROL * scale;
|
||||
for (rect, hot, chevron_up) in
|
||||
[(value, false, None), (up, hot_up, Some(true)), (down, hot_down, Some(false))]
|
||||
{
|
||||
let (cx, cy, cw, ch) = rect;
|
||||
quads.push(UiQuad::solid(cx - s(1.0), cy - s(1.0), cw + s(2.0), ch + s(2.0), s(7.0), sk.hairline));
|
||||
quads.push(UiQuad::solid(cx, cy, cw, ch, s(6.0), opaque_panel(sk)));
|
||||
quads.push(UiQuad::solid(cx, cy, cw, ch, s(6.0), sk.surface));
|
||||
surface::push_stroke(quads, rect, corner, scale, sk.hairline);
|
||||
quads.push(UiQuad::solid(cx, cy, cw, ch, corner, opaque_panel(sk)));
|
||||
quads.push(UiQuad::solid(cx, cy, cw, ch, corner, sk.surface));
|
||||
if hot {
|
||||
quads.push(UiQuad::solid(cx, cy, cw, ch, s(6.0), sk.hover));
|
||||
quads.push(UiQuad::solid(cx, cy, cw, ch, corner, sk.hover));
|
||||
}
|
||||
if let Some(up_arrow) = chevron_up {
|
||||
let ink = Rgba::new(sk.ink_dim.r, sk.ink_dim.g, sk.ink_dim.b, 235);
|
||||
|
||||
@@ -1,6 +1,49 @@
|
||||
# Launch a debug nebula instance for ui_probe and print its PID.
|
||||
param([string]$ExePath = "D:\temp_build\nebula\target\debug\nebula.exe")
|
||||
$p = Start-Process $ExePath -ArgumentList '--working-directory','D:\temp_build' -PassThru
|
||||
Start-Sleep -Seconds 7
|
||||
#
|
||||
# It runs a COPY, never target/debug/nebula.exe itself. Two reasons, both hit:
|
||||
#
|
||||
# 1. Windows will not let a running exe be overwritten. Probing the build
|
||||
# output directly makes the next `cargo build` die with
|
||||
# "linking with x86_64-w64-mingw32-gcc failed", while the real cause
|
||||
# (file in use) is buried under a wall of mingw .drectve warnings.
|
||||
#
|
||||
# 2. The probe instance must be disposable, and the user's own Nebula window
|
||||
# must never be collateral. Different exe paths remove any temptation to
|
||||
# clean up by image name. NEVER run `taskkill /IM nebula.exe` here: the
|
||||
# active Claude Code session may itself be hosted in a Nebula window.
|
||||
#
|
||||
# conpty.dll and OpenConsole.exe are copied alongside: the Windows PTY backend
|
||||
# looks for them next to the exe, and the instance cannot open a shell without
|
||||
# them.
|
||||
|
||||
param(
|
||||
[string]$SourceDir = "D:\temp_build\nebula\target\debug",
|
||||
[string]$ProbeDir = "D:\temp_build\nebula\.probe",
|
||||
[string]$WorkingDir = "D:\temp_build",
|
||||
[int]$WarmupSeconds = 7
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $ProbeDir | Out-Null
|
||||
|
||||
foreach ($name in @("nebula.exe", "conpty.dll", "OpenConsole.exe")) {
|
||||
$src = Join-Path $SourceDir $name
|
||||
if (-not (Test-Path $src)) {
|
||||
Write-Error "missing build artifact: $src (run cargo build -p nebula --bin nebula first)"
|
||||
}
|
||||
# A previous probe may still hold the same file. A failed copy means it is
|
||||
# alive; say so, and point at killing it BY PID rather than by image name.
|
||||
try {
|
||||
Copy-Item $src (Join-Path $ProbeDir $name) -Force
|
||||
} catch {
|
||||
Write-Error "cannot overwrite $name - a previous probe instance is probably still running. Close it with ui_probe.ps1 -ProcId <pid> -Kill."
|
||||
}
|
||||
}
|
||||
|
||||
$exe = Join-Path $ProbeDir "nebula.exe"
|
||||
$p = Start-Process $exe -ArgumentList '--working-directory', $WorkingDir -PassThru
|
||||
Start-Sleep -Seconds $WarmupSeconds
|
||||
$p.Refresh()
|
||||
Write-Output ("pid=" + $p.Id)
|
||||
Write-Output ("exe=" + $exe)
|
||||
Reference in new issue
Block a user