From b795ebbc854e66f695ee9efe5fba9270f3175de5 Mon Sep 17 00:00:00 2001 From: kuddev Date: Wed, 29 Jul 2026 20:39:37 +0800 Subject: [PATCH] feat: live carets, one-chip keycaps, aligned path column, enforced UI contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 26 +- nebula_app/src/display/chrome.rs | 4 - nebula_app/src/display/command_palette.rs | 447 +++++++++++--------- nebula_app/src/display/mod.rs | 149 ++++--- nebula_app/src/display/settings.rs | 5 +- nebula_app/src/display/ssh_editor_render.rs | 51 ++- nebula_app/src/display/text_input.rs | 11 + nebula_app/src/display/ui/caret.rs | 144 +++++++ nebula_app/src/display/ui/guardrails.rs | 274 ++++++++++++ nebula_app/src/display/ui/keycap.rs | 102 +++-- nebula_app/src/display/ui/mod.rs | 105 ++--- nebula_app/src/display/ui/surface.rs | 18 +- nebula_app/src/display/ui/theme.rs | 17 +- nebula_app/src/display/ui/tokens.rs | 17 +- nebula_app/src/display/ui/widgets.rs | 56 ++- scripts/launch_probe_instance.ps1 | 49 ++- 16 files changed, 1048 insertions(+), 427 deletions(-) create mode 100644 nebula_app/src/display/ui/caret.rs create mode 100644 nebula_app/src/display/ui/guardrails.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 77aad37..4f61be1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/nebula_app/src/display/chrome.rs b/nebula_app/src/display/chrome.rs index cc26ec4..a00ff68 100644 --- a/nebula_app/src/display/chrome.rs +++ b/nebula_app/src/display/chrome.rs @@ -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. diff --git a/nebula_app/src/display/command_palette.rs b/nebula_app/src/display/command_palette.rs index c9d5143..4b94d9f 100644 --- a/nebula_app/src/display/command_palette.rs +++ b/nebula_app/src/display/command_palette.rs @@ -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, 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::() 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::() 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] diff --git a/nebula_app/src/display/mod.rs b/nebula_app/src/display/mod.rs index 49554d7..151cd4b 100644 --- a/nebula_app/src/display/mod.rs +++ b/nebula_app/src/display/mod.rs @@ -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, diff --git a/nebula_app/src/display/settings.rs b/nebula_app/src/display/settings.rs index e5af099..4be30cc 100644 --- a/nebula_app/src/display/settings.rs +++ b/nebula_app/src/display/settings.rs @@ -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; diff --git a/nebula_app/src/display/ssh_editor_render.rs b/nebula_app/src/display/ssh_editor_render.rs index 11ee962..b5ecfd3 100644 --- a/nebula_app/src/display/ssh_editor_render.rs +++ b/nebula_app/src/display/ssh_editor_render.rs @@ -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, diff --git a/nebula_app/src/display/text_input.rs b/nebula_app/src/display/text_input.rs index 787b0ab..65fde26 100644 --- a/nebula_app/src/display/text_input.rs +++ b/nebula_app/src/display/text_input.rs @@ -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(); } } diff --git a/nebula_app/src/display/ui/caret.rs b/nebula_app/src/display/ui/caret.rs new file mode 100644 index 0000000..a1b9fc0 --- /dev/null +++ b/nebula_app/src/display/ui/caret.rs @@ -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 { + // 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 { + 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 = (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)); + } +} diff --git a/nebula_app/src/display/ui/guardrails.rs b/nebula_app/src/display/ui/guardrails.rs new file mode 100644 index 0000000..a80fe52 --- /dev/null +++ b/nebula_app/src/display/ui/guardrails.rs @@ -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 { + fn walk(dir: &Path, out: &mut Vec) { + 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> { + 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} 应该被判违规"); + } + } +} diff --git a/nebula_app/src/display/ui/keycap.rs b/nebula_app/src/display/ui/keycap.rs index 471ee2d..2597777 100644 --- a/nebula_app/src/display/ui/keycap.rs +++ b/nebula_app/src/display/ui/keycap.rs @@ -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, /// 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::() - + 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::>().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, 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"); } } diff --git a/nebula_app/src/display/ui/mod.rs b/nebula_app/src/display/ui/mod.rs index 2794e00..80c29bc 100644 --- a/nebula_app/src/display/ui/mod.rs +++ b/nebula_app/src/display/ui/mod.rs @@ -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; diff --git a/nebula_app/src/display/ui/surface.rs b/nebula_app/src/display/ui/surface.rs index 1d270e2..518e2b2 100644 --- a/nebula_app/src/display/ui/surface.rs +++ b/nebula_app/src/display/ui/surface.rs @@ -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))); } /// 浮层内部的内容块。 diff --git a/nebula_app/src/display/ui/theme.rs b/nebula_app/src/display/ui/theme.rs index cc286b8..b1e2296 100644 --- a/nebula_app/src/display/ui/theme.rs +++ b/nebula_app/src/display/ui/theme.rs @@ -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 diff --git a/nebula_app/src/display/ui/tokens.rs b/nebula_app/src/display/ui/tokens.rs index 6d1e32b..1858331 100644 --- a/nebula_app/src/display/ui/tokens.rs +++ b/nebula_app/src/display/ui/tokens.rs @@ -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 diff --git a/nebula_app/src/display/ui/widgets.rs b/nebula_app/src/display/ui/widgets.rs index ce2c3e8..289d80b 100644 --- a/nebula_app/src/display/ui/widgets.rs +++ b/nebula_app/src/display/ui/widgets.rs @@ -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); diff --git a/scripts/launch_probe_instance.ps1 b/scripts/launch_probe_instance.ps1 index 3a35787..ba99738 100644 --- a/scripts/launch_probe_instance.ps1 +++ b/scripts/launch_probe_instance.ps1 @@ -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 -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)