diff --git a/CHANGELOG.md b/CHANGELOG.md index 08b7d5a..170b471 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,76 @@ Every release entry is provided in English and Simplified Chinese. 每个版本条目均同时提供英文和简体中文说明。 +## 0.9.0 - 2026-08-02 + +### English + +#### Added + +- **Idle tabs show their shell** — a quiet tab row (no badge, not hovered) shows its shell as a small dim tag at the right edge: `pwsh`, `cmd`, `bash`, `ubuntu` (WSL distributions by name), and `ssh` for SSH tabs. Any badge (spinner, dot, attention…) outranks it, and the tag steps aside when a long name reaches the right edge. +- **WSL distributions in the folder picker** — "Browse folder" dialogs now pin every registered WSL distribution (`\\wsl.localhost\`) to the top of their sidebar, so picking a Linux directory no longer depends on the system's "Linux" navigation node being present. (#12) +- **Drag to resize the panels** — Settings → Interaction gains "Drag to resize panel widths". With it on, the sidebar's right edge and the file/git drawer's left edge become drag handles (the pointer turns into a resize cursor over the ±4 px grip), and dragging either panel all the way to its window edge closes it rather than stopping at a minimum width. Off by default, and turning it on asks for confirmation first, because a width drag reflows the terminal live. The drag itself is double-throttled — the panel edge tracks the pointer every frame, but the grid is only re-laid when the width has crossed a whole cell and at most once every 80 ms, so a fast drag cannot turn into a reflow storm. The SSH HOSTS divider is always draggable regardless of the switch — it only redistributes height inside the sidebar and never touches the grid, and the height you drag it to is honoured even when you have fewer hosts than fit. All three sizes persist. +- **Restore last tabs on launch is now a setting** — Settings → Advanced → Sessions gains "Restore last tabs on launch (also after a crash)", on by default. Turning it off always starts clean; the snapshot keeps being written either way, so workspace export and crash diagnostics still work. +- **Window background blur (Windows 11)** — Settings → Appearance gains "Background blur". With it on, whatever is behind the window is blurred through the terminal background (Acrylic), and the window-opacity slider becomes the strength of the tint laid over that blur, so one control still governs "how much shows through". Mica was tried and dropped: it samples the desktop wallpaper rather than the actual window behind, which reads as a flat colour wash instead of a blur. Note that a translucent background forces text anti-aliasing from subpixel to grayscale — that is a system-level constraint of transparent windows, not a rendering regression. +- **The command palette is grouped** — rows now sit under quiet section headers with a hairline rule running to the panel edge: Working directory, Tabs, Jump, View, Workspace, Appearance, Settings, and the shell/profile list. The **Working directory** group carries the focused pane's path on the header itself — written once instead of repeated on every row — and holds "Copy path" and "Reveal in file manager"; "New tab" joins it only when a new tab would actually open there (a configured startup directory takes precedence, and then the row stays under Tabs rather than sitting under a heading that lies). Toggle commands ("Show tab sidebar", "Drag to resize panels") carry a check mark for their current state, so a switch that is already on reads differently from an action that would turn it on. Grouping is applied after ranking, so a group's internal order is still most-recently-used first (or best fuzzy match while typing), and the headers track the scrolled window rather than the top of the list. +- **SSH connection progress** — connecting to a saved host now shows the four real stages (resolve, TCP, authenticate, open session) instead of a blank pane. The stages are call sites in Nebula's own SSH implementation rather than guesses parsed from a client's output, so the progress is truthful. The page waits 350 ms before appearing: connections that finish faster than that never flash a progress screen, and hovering a host in the sidebar shows the state inline instead of taking over the pane. + +#### Fixed + +- **A crash is now told apart from a clean exit** — the session snapshot records whether the process reached its teardown. After an unclean exit (crash, force-kill, power loss) the restored window says so in the message bar instead of restoring silently; and when the crash-loop breaker trips after three failed launches, the offending session is moved to `session.crashed.json` instead of being overwritten by the next autosave a second later — previously the only evidence of a restore-crash loop destroyed itself. +- **Sharp text and icons in the SSH host editor** — the identity strip's name, the avatar shape and the icon list previously scaled up bitmaps rasterized at the terminal font size, visibly blurry next to the design; they now re-rasterize at their true size. Caret placement in the enlarged name field follows the true glyph advance, so clicking the tenth character no longer lands the caret a column off. +- **The icon picker no longer shows form text through its panel** — overlay backgrounds were submitted in the same batch as the form's fills, which all paint beneath text; overlays (the icon list, the test-status tooltip) now paint after the form's text, so nothing bleeds through. +- **Test-connection failures show the whole reason** — the status line used to flatten the error into one truncated line with the full text hidden behind a hover tooltip. Failures now wrap up to four lines right in the dialog; the tooltip remains only for over-long tails. +- **Spinner rings render as smooth rings** — the beaded look came from translucent dots doubling their alpha wherever they overlap, and the active tab's ring composited against the sidebar background instead of the light pill it actually sits on, which read as a dark circle. Ring colors now pre-compose against the row's real background. +- **The "needs your answer" badge shows up for codex** — codex's notify pipe only reports "turn complete", so interactive question prompts could only ever show the unread dot. On turn completion Nebula now checks the visible tail of the screen for prompt markers ("enter to submit", "(y/N)"…) and upgrades the badge to the attention marker. +- **Inline math renders for grok too** — the rule that a rendered display formula unlocks inline `$…$` in that pane was keyed to a list of AI CLIs that had not been updated when grok was recognised elsewhere. grok sessions now get inline formulas like claude and codex do. + +#### Improved + +- **The icon picker's search box is a real input field** — click to place the caret, drag to select, Shift+arrows / Home / End, Ctrl+A/C/V, with the same caret-blink rhythm as every other field; the hint still shows while it is empty. +- **New SSH hosts default to password authentication** — "Auto" needs a configured key to succeed, which made the old default a guaranteed first failure for most people. Existing hosts keep whatever they saved. +- **The raised-hand badge is withdrawn for now** — even with wider fingers and tighter gaps the shape did not read as a hand at badge size. "Needs your answer" is an amber dot until the icon is redrawn; it still outranks the blue unread dot, and the two are told apart by colour rather than by shape. +- **Sidebar rows breathe wider** — the row inset narrowed from 8 px to 4 px, giving the width to names, badges and shell tags; the sidebar's own width is unchanged. +- **Every text field shares one caret** — caret position, selection, click-to-place and the blink rhythm moved out of the individual dialogs and into the input component itself. Previously each field carried its own copy, which is why the SSH editor's enlarged name field and the icon search box each drifted from the others in small ways; they now behave identically, and new fields inherit the behaviour instead of re-implementing it. + +#### Queued beyond 0.9 + +- **Resume AI session palette** — the command palette gains "Resume AI session…": the 30 most recent local claude / codex sessions, searchable, with Enter typing the matching `claude --resume` / `codex resume` command into the focused terminal. Each row carries the agent's own brand mark at its head and a `claude` / `codex` chip at its right edge, so the source reads at a glance instead of competing for the text column; the right column shows where the session lived plus its age. Titles come from the first thing the human actually said — CLI-injected blocks (`AGENTS.md` / `CLAUDE.md` instruction dumps, `/clear` echoes, anything the CLI marks `isMeta`) are skipped rather than becoming the session's name, and a long title is truncated instead of running through the column beside it. Ships in a later release, not 0.9. + +### 简体中文 + +#### 新增 + +- **静默标签页显示 shell 类型** — 没有任何徽章、未悬浮的标签行,右侧以最淡的小字显示该标签的 shell 短标:`pwsh`、`cmd`、`bash`、`ubuntu`(WSL 按发行版名),SSH 标签显示 `ssh`。任何徽章(转圈、圆点、「等你批准」…)都比它优先;名字长到顶着右缘时短标自动让位。 +- **文件夹选择器可直达 WSL** — 「浏览文件夹」对话框把每个已注册的 WSL 发行版(`\\wsl.localhost\<发行版>`)钉进侧栏顶部,不再依赖系统资源管理器是否显示「Linux」节点。(#12) +- **侧栏可拖拽调节** — 设置→交互新增「拖拽调节侧栏宽度」。开启后左侧栏右缘与文件/Git 抽屉左缘成为拖拽把手(±4px 热区上光标变为调整宽度形态);把左侧栏一路拖到最左、或把抽屉一路拖到最右,就直接收起该面板,而不是卡在最小宽度上。默认关闭,且开启前会先确认——宽度拖动会实时重排终端内容。拖动本身走双重节流:面板边缘每帧跟手,但网格只在宽度跨过一整个单元格时才重排,且最快 80ms 一次,快速拖动不会演变成 resize 风暴。SSH HOSTS 分界线不受该开关约束、始终可拖:它只在侧栏内部重新分配高度,碰不到终端网格;主机数量撑不满时,拖出来的高度也照样生效。三处尺寸都会保存。 +- **启动恢复会话可开关** — 设置→高级→会话新增「启动时恢复上次的标签(异常退出后同样恢复)」,默认开启。关掉就永远干净启动;快照照写不误,工作区导出与崩溃诊断仍然可用。 +- **窗口背景模糊(Windows 11)** — 设置→外观新增「背景模糊」。开启后窗口背后的内容透过终端底色被模糊(Acrylic),窗口不透明度滑杆随之变成盖在模糊层上的着色强度——「透出多少」仍然只由一个控件管。Mica 试过后放弃:它取的是桌面壁纸而不是窗口背后的真实内容,看起来是一层平涂色调而非模糊。另外,半透明背景会把文字抗锯齿从次像素降为灰度,这是透明窗口的系统级约束,不是渲染退化。 +- **命令面板分组了** — 行归到安静的分组标题下,标题右侧一条发丝线拉到面板右缘:工作目录、标签页、跳转、视图、工作区、外观、设置,以及 shell / profile 列表。**工作目录**组把聚焦终端的路径挂在标题上——写一次,而不是每行重复一遍——组里是「复制路径」和「在资源管理器中显示」;「新建标签页」只在它确实会开在那个目录时才并入(配了启动目录时启动目录优先,那时这行留在标签页组,而不是挂在一个说谎的标题下)。开关类命令(「显示标签侧栏」「拖拽调节侧栏宽度」)带勾选态,已经开着的开关与「点了会开」的动作因此读起来不一样。分组是在排序**之后**做的稳定划分,组内仍然是最近用过的优先(打字时是模糊得分优先);表头跟着滚动窗口走,而不是钉在列表开头。 +- **SSH 连接进度** — 连接已保存的主机时不再是一片空白,而是显示四个真实阶段(解析、TCP、认证、建立会话)。这四个阶段是 Nebula 自己的 SSH 实现里的调用点,不是从某个客户端的输出里猜出来的,所以进度是可信的。连接页有 350ms 门槛:比这更快连上的连接不会闪一下进度屏;在侧栏悬浮某台主机时状态就地显示,不接管整个终端面板。 + +#### 修复 + +- **崩溃与正常退出现在分得清了** — 会话快照记录进程有没有走完收尾。上次是异常退出(崩溃、强杀、断电)时,恢复后会在消息栏明说,而不是悄悄恢复;连续三次启动失败触发断路器时,那份会话被挪到 `session.crashed.json` 而不是被一秒后的自动保存盖掉——此前「一恢复就崩」的唯一现场会自己销毁。 +- **SSH 主机编辑器的大字与图标不再发糊** — 身份条的名字、头像形状与图标列表此前是把按终端字号栅格化的位图硬放大,对着原型一眼可见的糊;现在按真实字号重新栅格化。放大后名字框的光标换算按真实字形步进,点第十个字不再偏一格。 +- **图标选择器不再透出表单文字** — 浮层的底与表单同批提交时全部沉在文字之下;现在浮层(图标列表、测试状态提示)在表单文字之后单独提交,什么都透不上来。 +- **「测试连接」失败显示完整原因** — 此前被压成单行截断,全文藏在悬浮层里;现在失败原因直接折行铺开(最多四行),只有超长的尾巴才收进悬浮层。 +- **转圈指示器是平滑的圆环** — 珠链感来自半透明圆点在重叠处 alpha 翻倍;活动标签上的环还拿侧栏深底做合成,画在浅色药丸上就成了一圈黑。环的颜色现在按所在行的真实底色预合成。 +- **codex 的「等你回答」徽章能亮了** — codex 的 notify 只报「回合完成」,交互式提问也只能落成未读圆点。现在回合结束时检查屏幕尾部的提问特征("enter to submit"、"(y/N)"…),命中即把徽章升级为「等你批准」。 +- **grok 也能渲染行内公式** — 「本窗格出现过一次整块公式就解锁行内 `$…$`」这条规则挂在一份 AI CLI 名单上,而别处认出 grok 时这份名单没跟着更新。grok 会话现在和 claude / codex 一样能渲染行内公式。 + +#### 改进 + +- **图标搜索框是一个正经输入框** — 点击定位光标、拖选、Shift+方向键 / Home / End、Ctrl+A/C/V,光标闪烁节律与其它输入框一致;空着时仍显示提示语。 +- **新建 SSH 主机默认密码认证** — 「自动」要先配好私钥才走得通,拿它当默认等于让多数新手先撞一次失败。已保存的主机保持原样。 +- **手掌徽章暂时下线** — 指再粗、缝再窄,徽章尺寸下还是读不成一只手。「等你批准」改用琥珀色圆点,等图标重画后再回来;它依然压过蓝色未读点,两者靠颜色而不是形状区分。 +- **侧栏行更宽** — 行的左右内缩从 8px 收到 4px,宽度让给名字、徽章和 shell 短标;侧栏总宽不变。 +- **所有输入框共用同一套光标** — 光标位置、选区、点击定位与闪烁节律从各个对话框里下沉到输入组件本身。此前每个输入框各存一份,SSH 编辑器放大后的名字框、图标搜索框于是各自在细节上跑偏;现在行为完全一致,新加的输入框直接继承,不必再实现一遍。 + +#### 排期在 0.9 之后 + +- **恢复 AI 会话面板** — 命令面板新增「恢复 AI 会话…」:列出本机最近 30 条 claude / codex 会话,可搜索,回车把对应的 `claude --resume` / `codex resume` 命令敲进当前终端。每行行首是该 agent 的品牌标、右缘是 `claude` / `codex` 小药丸,来源一眼可辨而不必去挤文字列;右列显示会话所在位置与相对时间。标题取自用户真正说的第一句话——CLI 自动注入的内容(`AGENTS.md` / `CLAUDE.md` 指令块、`/clear` 回显、以及一切被 CLI 打上 `isMeta` 的行)会被跳过,不再拿来当会话名;标题过长时截断,不再横穿到旁边的列上。随后续版本发布,不进 0.9。 + ## 0.8.0 - 2026-07-29 ### English diff --git a/Cargo.lock b/Cargo.lock index 0e05a19..86e9610 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2378,7 +2378,7 @@ dependencies = [ [[package]] name = "nebula" -version = "0.8.0" +version = "0.9.0" dependencies = [ "ab_glyph_rasterizer", "aes-gcm", @@ -2444,7 +2444,7 @@ dependencies = [ [[package]] name = "nebula-completions" -version = "0.8.0" +version = "0.9.0" dependencies = [ "lscolors", "nu-ansi-term", @@ -2455,7 +2455,7 @@ dependencies = [ [[package]] name = "nebula_config" -version = "0.8.0" +version = "0.9.0" dependencies = [ "log", "nebula_config_derive", @@ -2465,7 +2465,7 @@ dependencies = [ [[package]] name = "nebula_config_derive" -version = "0.8.0" +version = "0.9.0" dependencies = [ "log", "nebula_config", @@ -2482,7 +2482,7 @@ version = "0.6.0" [[package]] name = "nebula_terminal" -version = "0.8.0" +version = "0.9.0" dependencies = [ "base64", "bitflags 2.13.0", diff --git a/nebula-completions/Cargo.toml b/nebula-completions/Cargo.toml index 418eabe..406eda3 100644 --- a/nebula-completions/Cargo.toml +++ b/nebula-completions/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nebula-completions" -version = "0.8.0" +version = "0.9.0" edition.workspace = true [features] diff --git a/nebula_app/Cargo.toml b/nebula_app/Cargo.toml index ea472ef..d429360 100644 --- a/nebula_app/Cargo.toml +++ b/nebula_app/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nebula" -version = "0.8.0" +version = "0.9.0" license = "GPL-3.0-or-later" description = "Nebula Terminal — a premium GPU-accelerated terminal emulator" readme = "README.md" @@ -15,19 +15,19 @@ path = "src/main.rs" [dependencies.nebula_terminal] path = "../nebula_terminal" -version = "0.8.0" +version = "0.9.0" [dependencies.nebula_config_derive] path = "../nebula_config_derive" -version = "0.8.0" +version = "0.9.0" [dependencies.nebula_config] path = "../nebula_config" -version = "0.8.0" +version = "0.9.0" [dependencies.nebula-completions] path = "../nebula-completions" -version = "0.8.0" +version = "0.9.0" [dependencies] ahash = { version = "0.8.6", features = ["no-rng"] } diff --git a/nebula_app/build.rs b/nebula_app/build.rs index 2235ad1..466c8a2 100644 --- a/nebula_app/build.rs +++ b/nebula_app/build.rs @@ -15,11 +15,13 @@ fn main() { let dest = env::var("OUT_DIR").unwrap(); let mut file = File::create(Path::new(&dest).join("gl_bindings.rs")).unwrap(); - Registry::new(Api::Gl, (3, 3), Profile::Core, Fallbacks::All, [ - "GL_ARB_blend_func_extended", - "GL_KHR_robustness", - "GL_KHR_debug", - ]) + Registry::new( + Api::Gl, + (3, 3), + Profile::Core, + Fallbacks::All, + ["GL_ARB_blend_func_extended", "GL_KHR_robustness", "GL_KHR_debug"], + ) .write_bindings(GlobalGenerator, &mut file) .unwrap(); diff --git a/nebula_app/src/ai_assistant.rs b/nebula_app/src/ai_assistant.rs index 73bf3fb..6a1e31f 100644 --- a/nebula_app/src/ai_assistant.rs +++ b/nebula_app/src/ai_assistant.rs @@ -73,9 +73,7 @@ impl AssistantConfig { /// Read the config file. Called per failed command, not per frame — a /// sub-millisecond read beats cache-invalidation plumbing here. pub fn load() -> Self { - Self::parse( - &std::fs::read_to_string(config_path()).unwrap_or_default(), - ) + Self::parse(&std::fs::read_to_string(config_path()).unwrap_or_default()) } fn parse(text: &str) -> Self { @@ -212,7 +210,13 @@ pub fn redact_secrets(text: &str) -> String { run.clear(); }; for ch in text.chars() { - if ch.is_ascii_alphanumeric() || ch == '+' || ch == '/' || ch == '=' || ch == '-' || ch == '_' { + if ch.is_ascii_alphanumeric() + || ch == '+' + || ch == '/' + || ch == '=' + || ch == '-' + || ch == '_' + { run.push(ch); } else { flush(&mut run, &mut out); @@ -283,22 +287,18 @@ fn request_fix(cfg: &AssistantConfig, req: &FixRequest) -> Option { {"role": "user", "content": user}, ], }); - let config = ureq::config::Config::builder() - .timeout_global(Some(Duration::from_secs(30))) - .build(); + let config = + ureq::config::Config::builder().timeout_global(Some(Duration::from_secs(30))).build(); let agent: ureq::Agent = config.new_agent(); let url = format!("{}/chat/completions", cfg.base_url.trim_end_matches('/')); - let mut response = match agent - .post(&url) - .header("Authorization", &format!("Bearer {key}")) - .send_json(&body) - { - Ok(response) => response, - Err(err) => { - warn!("assistant: request failed: {err}"); - return None; - }, - }; + let mut response = + match agent.post(&url).header("Authorization", &format!("Bearer {key}")).send_json(&body) { + Ok(response) => response, + Err(err) => { + warn!("assistant: request failed: {err}"); + return None; + }, + }; let value: serde_json::Value = match response.body_mut().read_json() { Ok(value) => value, Err(err) => { @@ -370,7 +370,8 @@ mod tests { assert_eq!(fix.explain, "拼写"); assert!(!fix.danger); // Local danger regex overrides a lying model. - let fix = parse_fix("{\"command\":\"rm -rf /\",\"explain\":\"x\",\"danger\":false}").unwrap(); + let fix = + parse_fix("{\"command\":\"rm -rf /\",\"explain\":\"x\",\"danger\":false}").unwrap(); assert!(fix.danger); // Empty command = deliberate silence. assert!(parse_fix("{\"command\":\"\",\"explain\":\"no fix\",\"danger\":false}").is_none()); diff --git a/nebula_app/src/ai_hook.rs b/nebula_app/src/ai_hook.rs index 460fc39..cf74e98 100644 --- a/nebula_app/src/ai_hook.rs +++ b/nebula_app/src/ai_hook.rs @@ -170,6 +170,59 @@ pub(crate) fn parse_remote_envelope(bytes: &[u8], pane: Option) -> Option bool { + let lower = tail.to_ascii_lowercase(); + [ + // codex 的问题框底栏。 + "enter to submit", + "tab to add notes", + // claude code 的选择框与权限确认。 + "enter to confirm", + "do you want to proceed", + // 通用 CLI 确认式。 + "(y/n)", + "[y/n]", + ] + .iter() + .any(|mark| lower.contains(mark)) +} + +#[cfg(test)] +mod question_tail_tests { + use super::tail_looks_like_question; + + #[test] + fn codex_question_footer_reads_as_asking() { + let tail = "Question 1/2 (2 unanswered)\n> 1. 深色主题 (Recommended)\n\ + tab to add notes | enter to submit answer | esc to interrupt"; + assert!(tail_looks_like_question(tail)); + } + + #[test] + fn yes_no_prompts_read_as_asking() { + assert!(tail_looks_like_question("Overwrite existing file? (y/N)")); + assert!(tail_looks_like_question("Do you want to proceed?\n> 1. Yes")); + } + + #[test] + fn a_running_status_bar_does_not_read_as_asking() { + // "esc to interrupt" 单独出现是**运行中**的状态栏;把它当特征收进 + // 去,每次正常完成都会误报成提问。 + assert!(!tail_looks_like_question("Working on it… esc to interrupt")); + assert!(!tail_looks_like_question("$ cargo build\n Compiling nebula v0.8.0")); + } +} + #[cfg(test)] mod remote_tests { use super::{AiHookKind, parse_remote_envelope}; @@ -192,11 +245,9 @@ mod remote_tests { .unwrap(); assert_eq!(prompt.kind, AiHookKind::PromptSubmit); - let done = parse_remote_envelope( - b"nebula-hook/1 source=pi pane=3\n{\"kind\":\"done\"}", - Some(3), - ) - .unwrap(); + let done = + parse_remote_envelope(b"nebula-hook/1 source=pi pane=3\n{\"kind\":\"done\"}", Some(3)) + .unwrap(); assert_eq!(done.kind, AiHookKind::TurnDone); } } @@ -861,7 +912,10 @@ export default function (pi: ExtensionAPI) { match pi_agent_dir() { Some(agent) if agent.exists() => { if ensure_pi_extension() { - println!("pi: 已安装 {}。", agent.join("extensions").join("nebula.ts").display()); + println!( + "pi: 已安装 {}。", + agent.join("extensions").join("nebula.ts").display() + ); } else { println!("pi: 扩展已是最新。"); } @@ -971,8 +1025,9 @@ export default function (pi: ExtensionAPI) { fn remove_pi_extension() -> std::io::Result { let Some(agent) = pi_agent_dir() else { return Ok(false) }; let path = agent.join("extensions").join("nebula.ts"); - let ours = std::fs::read_to_string(&path) - .is_ok_and(|content| content.contains("NEBULA_HOOK_EXE") && content.contains("Pi bridge")); + let ours = std::fs::read_to_string(&path).is_ok_and(|content| { + content.contains("NEBULA_HOOK_EXE") && content.contains("Pi bridge") + }); if !ours { return Ok(false); } diff --git a/nebula_app/src/cli.rs b/nebula_app/src/cli.rs index b205d48..cfb2f49 100644 --- a/nebula_app/src/cli.rs +++ b/nebula_app/src/cli.rs @@ -4,9 +4,9 @@ use std::ops::{Deref, DerefMut}; use std::path::PathBuf; use std::rc::Rc; -use nebula_config::SerdeReplace; use clap::{ArgAction, Args, Parser, Subcommand, ValueEnum, ValueHint}; use log::{LevelFilter, error}; +use nebula_config::SerdeReplace; use serde::{Deserialize, Serialize}; use toml::Value; @@ -653,11 +653,9 @@ mod tests { fn completions() { let mut clap = Options::command(); - for (shell, file) in &[ - (Shell::Bash, "nebula.bash"), - (Shell::Fish, "nebula.fish"), - (Shell::Zsh, "_nebula"), - ] { + for (shell, file) in + &[(Shell::Bash, "nebula.bash"), (Shell::Fish, "nebula.fish"), (Shell::Zsh, "_nebula")] + { let mut generated = Vec::new(); clap_complete::generate(*shell, &mut clap, "nebula", &mut generated); let generated = String::from_utf8_lossy(&generated); diff --git a/nebula_app/src/config/lua/mod.rs b/nebula_app/src/config/lua/mod.rs index a1f6c69..4075987 100644 --- a/nebula_app/src/config/lua/mod.rs +++ b/nebula_app/src/config/lua/mod.rs @@ -89,11 +89,7 @@ pub fn load_lua_file(path: &Path, reload: ReloadSignal) -> Result Result { // Convert YAML to TOML as a transitionary fallback mechanism. let extension = path.extension().unwrap_or_default(); if (extension == "yaml" || extension == "yml") && !contents.trim().is_empty() { - warn!( - "YAML config {path:?} is deprecated, please migrate to TOML using `nebula migrate`" - ); + warn!("YAML config {path:?} is deprecated, please migrate to TOML using `nebula migrate`"); let mut value: serde_yaml::Value = serde_yaml::from_str(&contents)?; prune_yaml_nulls(&mut value, warn_pruned); diff --git a/nebula_app/src/display/chrome.rs b/nebula_app/src/display/chrome.rs index 006f50f..5e0bf49 100644 --- a/nebula_app/src/display/chrome.rs +++ b/nebula_app/src/display/chrome.rs @@ -10,8 +10,8 @@ #![allow(clippy::wildcard_imports)] -use super::*; use super::ui::{icons, tokens}; +use super::*; /// Result of hit-testing a pixel against the Nebula top chrome bar. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -119,6 +119,9 @@ pub(super) struct ChromeTabLayout { /// Scroll clamps: the largest valid row offset for each section. pub(super) tabs_max_scroll: usize, pub(super) hosts_max_scroll: usize, + /// 停靠区可用内容的底缘 y(物理 px)——HOSTS 分界拖拽用它把指针 y 换算 + /// 成停靠区高度;与布局同源,两头不会漂。折叠时为 0。 + pub(super) dock_content_bottom: f32, } /// Sidebar content model handed to the layout: everything that changes the @@ -132,6 +135,10 @@ pub(crate) struct SidebarModel { pub(super) hosts_open: bool, pub(super) tabs_scroll: usize, pub(super) hosts_scroll: usize, + /// 侧栏逻辑宽(拖拽调节的实时值;默认 [`super::SIDEBAR_W_LOGICAL`])。 + pub(super) sidebar_w: f32, + /// SSH HOSTS 停靠区高度覆盖(逻辑 px),0 = 自动(46% 弹性规则)。 + pub(super) hosts_band: f32, } pub(super) fn contains_rect((rx, ry, rw, rh): (f32, f32, f32, f32), x: f32, y: f32) -> bool { @@ -166,6 +173,66 @@ pub(super) fn truncate_tab_label(label: &str, max_cols: usize) -> String { text } +/// 分组标题文本的 y——chip、数字、标题共用的**唯一事实源**。 +/// +/// TABS 和 SSH HOSTS 的标题各有自己的落点(TABS 为躲开与顶栏的接缝特意往下 +/// 压了几像素,HOSTS 在自己的带里居中)。之前 chip/数字用"header 居中"另算 +/// 一份,TABS 那组就差出 6px——第三次栽在"两边各算一次"上了。现在谁要跟标题 +/// 对齐,谁就从这里拿 y。 +pub(super) fn section_title_y( + header: (f32, f32, f32, f32), + is_tabs: bool, + text_h: f32, + scale: f32, +) -> f32 { + if is_tabs { + // 与 draw_chrome 里 TABS 标题的 `pnl_y + s(22)` 同源(tabs_header.1 + // 就是 panel_top)。 + header.1 + 22.0 * scale + } else { + header.1 + (header.3 - text_h) / 2.0 + } +} + +/// 分组标题里那颗数量 chip 的矩形(背景 + 数字共用)。 +/// +/// 背景在 quads 那一趟画、数字在 text 那一趟画,中间隔着 `draw_ui`。两边各 +/// 算一次必然漂移——这正是本模块存在的理由,所以位置只在这里算一次。 +/// +/// UI 字体不是等宽,标题宽度只能按平均步进估。chip 是块独立的色片、不需要 +/// 贴着标题的最后一个字形,估算的几像素误差落在那 6px 间距里看不出来。 +pub(super) fn section_count_chip( + header: (f32, f32, f32, f32), + is_tabs: bool, + title_chars: usize, + digits: usize, + advance: f32, + tracking: f32, + text_h: f32, + scale: f32, +) -> (f32, f32, f32, f32) { + let s = |v: f32| v * scale; + let (hx, _, _, _) = header; + // 标题的实际推进 = 字形步进 + **字距**,再乘一点粗体的富余。第一版漏了 + // tracking(0.65px/字),"SSH HOSTS" 十二个字符就差出近 8px——chip 于是 + // 正好压在标题最后一个字母上。BOLD 比常规再宽一档,用 1.06 兜住。 + let advance_per_char = (advance + tracking) * 1.06; + let x = hx + s(16.0) + advance_per_char * title_chars as f32 + s(7.0); + // 高度跟着**文本**走而不是写死 16px:字号或 DPI 一变,固定高度的 chip 要么 + // 箍不住数字、要么在小字号下胖成一坨。 + // + // 1.45 那版把 chip 撑成了一颗比标题还抢眼的圆球——数量是**附注**,它只在 + // 折叠时替你数一下里面有几条,不该盖过它所标注的那个标题。原型里这颗片是 + // `padding: 0 5px` 的窄胶囊(高度就是行盒),所以这里也只留一线余量;下限 + // 同时把它压成横向的胶囊而不是正圆——正圆在这个尺寸上读起来就是个徽章。 + let h = (text_h * 1.18).max(s(11.0)); + let w = (advance * digits as f32 + s(8.0)).max(h * 1.25); + // y 锚在标题文本上(chip 比文本高出的部分上下平分),不再自己在 header + // 里居中——标题不是居中画的,居中的 chip 必然漂。 + let title_y = section_title_y(header, is_tabs, text_h, scale); + (x, title_y - (h - text_h) / 2.0, w, h) +} + /// Shared geometry for the custom Windows-style titlebar controls. /// /// The hit targets are intentionally wider than the visible glyphs: the design @@ -242,7 +309,9 @@ pub(crate) fn chrome_settings_button_rect( let margin = s(8.0); let top = margin; let bar_h = s(40.0); - let inner_pad = s(6.0); + // 顶栏按钮的命中/背景边长 = bar_h - 2*inner_pad。6 那版给出 28px,比 + // tokens 的 `hit`(32) 小一圈——按钮看着紧贴图标,按下去也不跟手。 + let inner_pad = s(4.0); let pill_h = bar_h - 2.0 * inner_pad; let toggle_x = margin + inner_pad; let x = toggle_x + pill_h + s(8.0); @@ -264,7 +333,9 @@ pub(super) fn chrome_tab_layout( let h = size_info.height(); let margin = s(8.0); let bar_h = s(40.0); - let inner_pad = s(6.0); + // 顶栏按钮的命中/背景边长 = bar_h - 2*inner_pad。6 那版给出 28px,比 + // tokens 的 `hit`(32) 小一圈——按钮看着紧贴图标,按下去也不跟手。 + let inner_pad = s(4.0); let pill_h = bar_h - 2.0 * inner_pad; let top = margin; let count = model.tab_count.max(1); @@ -304,6 +375,7 @@ pub(super) fn chrome_tab_layout( hosts_band: (0.0, 0.0), tabs_max_scroll: 0, hosts_max_scroll: 0, + dock_content_bottom: 0.0, }; } @@ -313,7 +385,7 @@ pub(super) fn chrome_tab_layout( // are squared off in `draw_chrome`. It runs down to the window's bottom // margin (symmetric with the top bar's top at `margin`), leaving only a // breathing gap before the terminal grid on its right. - let sw = SIDEBAR_W_LOGICAL * scale_factor; + let sw = model.sidebar_w * scale_factor; let slide = (1.0 - expand.clamp(0.0, 1.0)) * sw; let panel_x = margin - slide; let panel_w = (sw - margin - s(12.0)).max(s(120.0)); @@ -325,7 +397,11 @@ pub(super) fn chrome_tab_layout( // Vertical tab rows: full panel width minus inner padding, stacked below // the "TABS" header. The new-tab affordance is a small square at the right // end of that header row (revealed on sidebar hover), not a trailing row. - let tab_pad = s(14.0); + // 行的左右内缩。窄内缩 = 更宽的行 = 名称与徽章之间拉得开——侧栏总宽 + // 一点没变,变的只是行在里面占多少。14 → 8 → 4 一路收:14 那版把 28px + // 让给了两侧空白;otty 的行几乎贴满侧栏,呼吸感来自行**内部**的留白, + // 不是行外的边框。 + let tab_pad = s(4.0); let tab_x = panel_x + tab_pad; let tab_w = panel_w - 2.0 * tab_pad; let row_h = s(34.0); @@ -358,28 +434,66 @@ pub(super) fn chrome_tab_layout( let tabs_header = (panel_x, panel_top, panel_w, header); - // ---- Elastic accordion split ---- - // Each open section wants `count` rows. If both fit, both get what they - // want; if not, the panel's row budget is split so neither section can - // starve the other below half of the budget, and every overflowing - // section scrolls behind its own scrollbar. - let avail_rows = - (((panel_h - queue_reserved - header - hosts_header_h - empty_hosts_hint_h - bottom_pad - + gap) - / pitch) - .floor() - .max(0.0)) as usize; + // ---- 两个分区:标签吃满弹性高度,主机沉到底 ---- + // + // 原来两段排在同一条流里、互相削减。问题是它们不是同一类东西:标签是 + // **活的状态**(哪个在跑、谁在等你批准),那套徽章的全部价值就在于它一 + // 直在余光里;主机是**目录**,只在开新会话那一刻用一次。排在一条流里, + // 主机一多就会把标签顶出视口——用得少的那类挤掉了必须常驻的那类。 + // + // 所以主机整块沉到面板底部:自己的地盘、自己的滚动、封顶。标签拿走上面 + // 剩下的全部,永远不会被挤没。 + // + // 主机行是**双行**的(名字在上、地址在下),比标签行高一档;两段行高不 + // 同,所以到处都按**高度**算,不能再除同一个 pitch。 + let host_row_h = row_h + s(13.0); + let host_pitch = host_row_h + gap; + // n 行占的高度:行间距只在行**之间**,末行后面不留。 + let rows_h = |n: usize, pitch: f32| if n == 0 { 0.0 } else { n as f32 * pitch - gap }; + + let content_top = panel_top + header; + let content_bottom = (panel_bottom - queue_reserved - bottom_pad).max(content_top); + let content_h = content_bottom - content_top; + let tabs_want = if model.tabs_open { count } else { 0 }; let hosts_want = if model.hosts_open { host_count } else { 0 }; - let (tabs_show, hosts_show) = if tabs_want + hosts_want <= avail_rows { - (tabs_want, hosts_want) + + // 停靠区的地盘:**至少** 46%,但标签用不完的话它可以更多。只写死 46% + // 的话,两个标签配四十台主机会在中间空出一大片——封顶是为了保护标签, + // 标签不需要保护的时候就不该继续封着。 + const DOCK_MIN_SHARE: f32 = 0.46; + // 标题(和空态提示)是停靠区的固定开销,先扣掉再让行去分剩下的。 + let dock_fixed = hosts_header_h + empty_hosts_hint_h; + // 用户拖过 HOSTS 分界(设置开启拖拽调节)后,停靠区高度以覆盖值为准, + // 钳在「只剩标题」与「至少给标签区留一行」之间;没拖过走弹性规则。 + let dock_cap = if model.hosts_band > 0.0 { + (model.hosts_band * scale_factor).clamp(dock_fixed, (content_h - pitch - gap).max(dock_fixed)) } else { - let half = avail_rows / 2; - let tabs_show = tabs_want.min(avail_rows.saturating_sub(hosts_want).max(half)); - let hosts_show = hosts_want.min(avail_rows - tabs_show); - // Hand any slack back to the tabs section (hosts already capped). - (tabs_want.min(avail_rows - hosts_show), hosts_show) + (content_h - rows_h(tabs_want, pitch) - gap).max(content_h * DOCK_MIN_SHARE).min(content_h) }; + let mut hosts_show = hosts_want; + while hosts_show > 0 && dock_fixed + gap + rows_h(hosts_show, host_pitch) > dock_cap { + hosts_show -= 1; + } + // 内容需要的高度。没拖过分界时它**就是**停靠区的高度——贴着内容走。 + let content_dock_h = + dock_fixed + if hosts_show > 0 { gap + rows_h(hosts_show, host_pitch) } else { 0.0 }; + // 拖过分界之后改以拖出来的高度为准:手动分界的语义是「手动覆盖弹性」。 + // 只拿它当上限是不够的——主机数撑不满上限时(一台主机配一屏高度就是 + // 这样),上限抬得再高内容也长不出来,分界线纹丝不动,手感上就是 + // 「往上拖不动、被限制死了」(用户 08-02 报的正是这个)。多出来的空白 + // 是用户自己要的:他把地盘划给了 HOSTS。 + let dock_h = if model.hosts_band > 0.0 { dock_cap } else { content_dock_h }; + // 贴底:停靠区的**下**沿固定,上沿随内容升降。折起来(hosts_open=false) + // 就只剩一条标题贴在最下面。 + let dock_top = (content_bottom - dock_h).max(content_top); + + // 标签区 = 停靠区上方剩下的全部。 + let tabs_avail = (dock_top - gap - content_top).max(0.0); + let mut tabs_show = tabs_want; + while tabs_show > 0 && rows_h(tabs_show, pitch) > tabs_avail { + tabs_show -= 1; + } let tabs_max_scroll = tabs_want.saturating_sub(tabs_show); let hosts_max_scroll = hosts_want.saturating_sub(hosts_show); let tabs_scroll = model.tabs_scroll.min(tabs_max_scroll); @@ -411,8 +525,8 @@ pub(super) fn chrome_tab_layout( let tabs_band_h = if tabs_show > 0 { tabs_show as f32 * pitch - gap } else { 0.0 }; let tabs_band = (tabs_top, tabs_top + tabs_band_h); - // Hosts section stacks right below the tabs band. - let hosts_header_y = tabs_band.1 + if tabs_show > 0 { gap } else { 0.0 }; + // 停靠区:位置由 `dock_top` 定死(贴着面板底),不再跟着标签区的末尾走。 + let hosts_header_y = dock_top; let hosts_header = (panel_x, hosts_header_y, panel_w, hosts_header_h); let hosts_top = hosts_header_y + hosts_header_h; let mut hosts = Vec::with_capacity(host_count); @@ -421,10 +535,10 @@ pub(super) fn chrome_tab_layout( hosts.push(zero); continue; } - let y = hosts_top + (i - hosts_scroll) as f32 * pitch; - hosts.push((tab_x, y, tab_w, row_h)); + let y = hosts_top + (i - hosts_scroll) as f32 * host_pitch; + hosts.push((tab_x, y, tab_w, host_row_h)); } - let hosts_band_h = if hosts_show > 0 { hosts_show as f32 * pitch - gap } else { 0.0 }; + let hosts_band_h = if hosts_show > 0 { hosts_show as f32 * host_pitch - gap } else { 0.0 }; let hosts_band = (hosts_top, hosts_top + hosts_band_h); // Overlay scrollbar thumbs, one per overflowing section: pinned to the @@ -463,6 +577,7 @@ pub(super) fn chrome_tab_layout( hosts_band, tabs_max_scroll, hosts_max_scroll, + dock_content_bottom: content_bottom, } } @@ -685,7 +800,9 @@ pub(super) fn draw_chrome(d: &mut Display) { let margin = s(8.0); let bar_h = s(40.0); - let inner_pad = s(6.0); + // 顶栏按钮的命中/背景边长 = bar_h - 2*inner_pad。6 那版给出 28px,比 + // tokens 的 `hit`(32) 小一圈——按钮看着紧贴图标,按下去也不跟手。 + let inner_pad = s(4.0); let radius = s(UI_CORNER_RADIUS_LOGICAL); let pill_h = bar_h - 2.0 * inner_pad; let pill_r = s(UI_CORNER_RADIUS_LOGICAL); @@ -797,8 +914,12 @@ pub(super) fn draw_chrome(d: &mut Display) { // away and back; it's the one tab affordance that survives collapse. let (tog_x, tog_y, tog_w, tog_h) = tab_layout.toggle; let toggle_hovered = d.nebula_chrome_hover == ChromeHit::SidebarToggle; - if toggle_hovered { - quads.push(UiQuad::solid(tog_x, tog_y, tog_w, tog_h, pill_r, HOVER_FILL_STRONG)); + // 展开时常驻一层底:这个按钮是**开关**不是动作,而开关必须能看出自己 + // 处在哪一档。此前只有 hover 才有背景,展开和收起长得一模一样,点下去 + // 除了侧栏动一下没有任何确认。 + if toggle_hovered || d.left_sidebar_visible() { + let fill = if toggle_hovered { HOVER_FILL_STRONG } else { sk.surface }; + quads.push(UiQuad::solid(tog_x, tog_y, tog_w, tog_h, pill_r, fill)); } let icon_c = if toggle_hovered { sk.icon_hover } else { sk.icon }; icons::push_sidebar_toggle( @@ -902,40 +1023,17 @@ pub(super) fn draw_chrome(d: &mut Display) { let tab_draw_x = tab_x; let tab_draw_y = tab_y; if index == d.nebula_active_tab { - // Floating-pill active tab: a soft accent wash over the pill plus - // a hairline border. The narrow identity light below carries the - // optional user-picked color. - let accent = palette.edge_r; - quads.push(UiQuad::solid( - tab_draw_x - s(1.0), - tab_draw_y - s(1.0), - tab_w + s(2.0), - tab_h + s(2.0), - pill_r + s(1.0), - Rgba::new(accent.r, accent.g, accent.b, 40), - )); - quads.push(UiQuad::solid( - tab_draw_x, - tab_draw_y, - tab_w, - tab_h, - pill_r, - palette.tab_bg_l, - )); - // The accent wash on top is a DARK-theme depth cue: the white pill - // (`tab_bg_l`) is the state on the light themes, so tinting it just - // grays the white the user asked to keep pure. Only the dark themes - // layer the wash over their (dark) active pill. - if !palette.is_light { - quads.push(UiQuad::solid( - tab_draw_x, - tab_draw_y, - tab_w, - tab_h, - pill_r, - Rgba::new(accent.r, accent.g, accent.b, 26), - )); - } + // 选中态是**中性**的一档提亮,不带品牌色。 + // + // 此前这里叠了三层:accent 外环 + 主题 tab_bg_l + 深色再补一层 + // accent。而标签左边本来就有程序 logo,claude / codex / grok 全是 + // 彩标——背景再染色就是两个彩色在同一行里抢。中性底反而让那颗 + // logo 的颜色立得住,这也是原型看起来"融合"的原因。 + // + // 浅色用纯白 panel(从灰底上浮起来),深色用 hover_strong(白叠加 + // 提亮一档)。方向相反,都是"比周围亮"。 + let fill = if palette.is_light { sk.panel } else { sk.hover_strong }; + quads.push(UiQuad::solid(tab_draw_x, tab_draw_y, tab_w, tab_h, pill_r, fill)); } // Inactive tabs carry no standalone fill — they sit flush on the // sidebar surface and only light up on hover (below). State is the @@ -992,20 +1090,65 @@ pub(super) fn draw_chrome(d: &mut Display) { if !tab_hovered { let has_dot = d.nebula_tab_bells.get(index).copied().unwrap_or(false); let running = d.nebula_tab_running.get(index).copied().unwrap_or(false); - if has_dot { + let attention = d.nebula_tab_attention.get(index).copied().unwrap_or(false); + let failed = d.nebula_tab_failed.get(index).copied().unwrap_or(false); + let flashing = d.nebula_tab_flashing.get(index).copied().unwrap_or(false); + let cx = close_x + close_w / 2.0; + let cy = close_y + close_h / 2.0; + // 挖空/合成色取**这一行当时的真实底**:非活动行是侧栏底,活动行 + // 是那块提亮的药丸——拿侧栏深底去合成画在浅药丸上的环,出来就是 + // 一个黑圈(活动 tab 的 spinner 曾因此显黑)。三角与对勾的挖空 + // 同理:填错底,挖空处就是一块颜色不对的补丁。 + let panel_under = { + let shell = + Rgba::new(palette.shell_bg.r, palette.shell_bg.g, palette.shell_bg.b, 255); + if index == d.nebula_active_tab { + let fill = if palette.is_light { sk.panel } else { sk.hover_strong }; + super::ui::surface::over(fill, shell) + } else { + shell + } + }; + // 徽章按「不处理就没有进展」排序:等你批准 > 跑挂了 > 刚完成 > + // 有未读结果 > 还在跑。一个标签同一时刻只说一件事,而且说最要紧 + // 的那件。 + // + // 全部走 skin 的语义色:warn / danger / ok / accent。此前这里写死 + // 了 rgb(82,168,255)——那是 Nebula 一个主题的 accent,换到 Moss 或 + // Coal 上就是一颗与整屏无关的蓝点。 + if attention { + // 手掌图形暂时下线(用户 08-02:现在这版不满意——五指靠挖空 + // 撑形,小尺寸下指缝糊成一团,读不出"手")。在重画之前, + // 「等你批准」退回一颗 warn 色圆点:与 accent 的"有未读结果" + // 靠颜色区分,不靠形状。恢复时把下面这行换回 push_hand。 + // super::ui::icons::push_hand(&mut quads, cx, cy, scale, sk.warn); + let dot_d = s(7.0); + quads.push(UiQuad::solid( + cx - dot_d / 2.0, + cy - dot_d / 2.0, + dot_d, + dot_d, + dot_d / 2.0, + sk.warn, + )); + } else if failed { + super::ui::icons::push_alert(&mut quads, cx, cy, scale, sk.danger, panel_under); + } else if flashing { + super::ui::icons::push_check_badge(&mut quads, cx, cy, scale, sk.ok, panel_under); + } else if has_dot { // The one state that earns a dot: an unseen result (bell // in a background tab / long command finished unseen). - // Design-spec blue with a soft glow halo. let dot_d = s(6.0); let dot_x = close_x + (close_w - dot_d) / 2.0; let dot_y = close_y + (close_h - dot_d) / 2.0; let halo = dot_d * 3.0; + let accent = sk.accent; quads.push(UiQuad::glow( dot_x + dot_d / 2.0 - halo / 2.0, dot_y + dot_d / 2.0 - halo / 2.0, halo, halo, - Rgba::new(82, 168, 255, 80), + Rgba::new(accent.r, accent.g, accent.b, 80), )); quads.push(UiQuad::solid( dot_x, @@ -1013,29 +1156,23 @@ pub(super) fn draw_chrome(d: &mut Display) { dot_d, dot_d, dot_d / 2.0, - Rgba::new(82, 168, 255, 230), + Rgba::new(accent.r, accent.g, accent.b, 230), )); } else if running { - // Spinner: three orbiting dots, head bright / tail dim — - // phase advances continuously from the shared monotonic frame - // clock, so the cycle boundary has no duplicated or skipped - // angular step. - let cx = close_x + close_w / 2.0; - let cy = close_y + close_h / 2.0; - let radius = s(4.5); - for k in 0..3u32 { - let (dot_x, dot_y) = spinner_dot_center(spinner_phase, k, cx, cy, radius); - let alpha = [225u8, 140, 70][k as usize]; - let d = s(2.4); - quads.push(UiQuad::solid( - dot_x - d / 2.0, - dot_y - d / 2.0, - d, - d, - d / 2.0, - Rgba::new(palette.edge_r.r, palette.edge_r.g, palette.edge_r.b, alpha), - )); - } + // 一圈暗轨道 + 一段绕行的亮弧(组件层配方)。亮弧用中性灰而 + // 不是品牌色:spinner 表达的是"还在跑",是这些状态里最不需 + // 要人动手的一个,不该最艳;活动/非活动行如今合成底不同、观 + // 感一致(见 panel_under)。 + icons::push_spinner( + &mut quads, + cx, + cy, + s(5.5), + spinner_phase, + sk.hairline, + Rgba::new(sk.ink_dim.r, sk.ink_dim.g, sk.ink_dim.b, 255), + panel_under, + ); } // Idle tab → nothing: the row stays clean by default. } @@ -1168,8 +1305,7 @@ pub(super) fn draw_chrome(d: &mut Display) { let _ = cy; let hovered = d.nebula_chrome_hover == hit; let visual = window_control_visual_rect(cx, top_y, bar_h, scale); - let hover_fill = - if hit == ChromeHit::Close { CLOSE_HOVER_FILL } else { HOVER_FILL_STRONG }; + let hover_fill = if hit == ChromeHit::Close { CLOSE_HOVER_FILL } else { HOVER_FILL_STRONG }; if hovered { quads.push(UiQuad::solid(visual.0, visual.1, visual.2, visual.3, 0.0, hover_fill)); } @@ -1192,9 +1328,7 @@ pub(super) fn draw_chrome(d: &mut Display) { let cutout = if hovered { icons::blend_over(base, hover_fill) } else { base }; let kind = match hit { ChromeHit::Minimize => icons::WindowControlIcon::Minimize, - ChromeHit::Maximize => { - icons::WindowControlIcon::Maximize { restore: restore_window } - }, + ChromeHit::Maximize => icons::WindowControlIcon::Maximize { restore: restore_window }, _ => icons::WindowControlIcon::Close, }; icons::push_window_control( @@ -1272,6 +1406,77 @@ pub(super) fn draw_chrome(d: &mut Display) { settings::push_quads(&d.settings_view(), &mut quads, &size, scale); } + // 分组标题的数量 chip(背景片)。文字在下面那趟画,位置同源于 + // `section_count_chip`。 + { + const TITLE_SCALE: f32 = 0.82; + let adv = d.renderer.ui_text_advance(&d.glyph_cache, TITLE_SCALE); + let trk = s(0.65); + let title_h = size.cell_height() * TITLE_SCALE; + // 底色回到 `surface`(原型里的 `var(--surface)`)。上一版提到 `hover` 是 + // 为了"看得见",但那是行悬浮的亮度——一个静态附注顶着交互态的底色,会 + // 让人以为鼠标正停在标题上。附注就该只比背景高一线。 + let chip_fill = sk.surface; + if tab_layout.tabs_header.2 > 0.0 { + let n = d.nebula_tab_labels.len(); + // 标题串是「箭头 + 两个空格 + TABS」,chip 跟在它后面。 + let chip = section_count_chip( + tab_layout.tabs_header, + true, + 7, + n.to_string().len(), + adv, + trk, + title_h, + scale, + ); + quads.push(UiQuad::solid(chip.0, chip.1, chip.2, chip.3, chip.3 * 0.5, chip_fill)); + } + if tab_layout.hosts_header.2 > 0.0 { + let n = d.nebula_ssh_hosts.len(); + let chip = section_count_chip( + tab_layout.hosts_header, + false, + 12, + n.to_string().len(), + adv, + trk, + title_h, + scale, + ); + quads.push(UiQuad::solid(chip.0, chip.1, chip.2, chip.3, chip.3 * 0.5, chip_fill)); + } + } + + // 停靠区的分界线。 + // + // 只有一条 hairline,没有另一层底色:独立滚动、贴底、封顶这三条行为已经 + // 让主机区是另一块地盘了。再刷一层底,侧栏就从"一块面板"变成"两块拼起来 + // 的面板"——可它们本来属于同一个外壳,一体感就是这么丢的。 + // + // 上面那列真滚起来(也就是边界这时才开始有意义),才在分界线上方叠一道 + // 极浅的落影,让被滚走的行读作"从这条线下面穿过去了"。没滚动时它完全不 + // 存在——静止的界面不该带着一条解释不了的阴影。 + if tab_layout.hosts_header.2 > 0.0 && tab_layout.tabs_band.1 > tab_layout.tabs_band.0 { + let (hx, hy, hw, _) = tab_layout.hosts_header; + let line_y = (hy - s(5.0)).round(); + if d.nebula_tabs_scroll > 0 { + // 三层递减的薄片就是一道渐变落影:越靠近线越实。 + for step in 1..=3 { + let alpha = (26 / step) as u8; + quads.push(UiQuad::solid( + hx, + line_y - s(3.0) * step as f32, + hw, + s(3.0), + 0.0, + Rgba::new(0, 0, 0, alpha), + )); + } + } + quads.push(UiQuad::solid(hx + s(8.0), line_y, hw - s(16.0), s(1.0), 0.0, sk.hairline)); + } + // Paint the panels and pills first. d.renderer.draw_ui(&size, &quads); @@ -1382,7 +1587,7 @@ pub(super) fn draw_chrome(d: &mut Display) { // "TABS" caption at the panel head, with an accordion chevron. The // panel abuts the top bar with no gap, so the caption is pushed down // inside the header band to keep clearance from the join. - let (pnl_x, pnl_y, _, _) = tab_layout.panel; + let (pnl_x, _, _, _) = tab_layout.panel; let tabs_chevron = if d.nebula_tabs_section_open { "\u{eab4}" } else { "\u{eab6}" }; const SECTION_TITLE_SCALE: f32 = 0.82; let section_title_tracking = s(0.65); @@ -1390,14 +1595,49 @@ pub(super) fn draw_chrome(d: &mut Display) { d.renderer.draw_ui_text_tracked( &size, pnl_x + s(16.0), - pnl_y + s(22.0), + section_title_y(tab_layout.tabs_header, true, cell_h * SECTION_TITLE_SCALE, scale), SECTION_TITLE_SCALE, section_title_tracking, TXT_DIM, section_title_flags, - &format!("TABS {tabs_chevron}"), + // 箭头前置、数量后置。 + // + // 前置不是排版偏好,是**符号语义**:后置的 chevron 在 UI 语言里 + // 属于下拉菜单(combobox),前置的三角才是折叠(Finder / VS Code / + // 所有 IDE 的 disclosure triangle)。用后置箭头表达折叠等于借错了 + // 符号。前置还让多个分组的箭头对齐成一条竖线,层级一眼可扫;后置 + // 的位置得跟着标题长度飘。 + // + // 数量常驻:折叠之后它是这一段仅剩的信息量,而 SSH 那边"42 台" + // 与"3 台"直接决定人是滚动找还是直接搜。 + &format!("{tabs_chevron} TABS"), &mut d.glyph_cache, ); + { + let n = d.nebula_tab_labels.len().to_string(); + let adv = d.renderer.ui_text_advance(&d.glyph_cache, SECTION_TITLE_SCALE); + let chip = section_count_chip( + tab_layout.tabs_header, + true, + 7, + n.len(), + adv, + section_title_tracking, + cell_h * SECTION_TITLE_SCALE, + scale, + ); + d.renderer.draw_ui_text_tracked( + &size, + chip.0 + (chip.2 - adv * n.len() as f32) * 0.5, + section_title_y(tab_layout.tabs_header, true, cell_h * SECTION_TITLE_SCALE, scale), + SECTION_TITLE_SCALE, + 0.0, + sk.ink_faint, + nebula_terminal::term::cell::Flags::empty(), + &n, + &mut d.glyph_cache, + ); + } for (index, (tab_x, row_y, tab_w, tab_h)) in tab_layout.tabs.iter().copied().enumerate() { // Scrolled-out / folded rows: zero rect, nothing to draw. if tab_w <= 0.0 { @@ -1581,6 +1821,39 @@ pub(super) fn draw_chrome(d: &mut Display) { &label, &mut d.glyph_cache, ); + // 静默行的右侧亮出 shell 短标(otty 的 "zsh" 位):徽章位空着时 + // 它回答"这个 tab 是什么环境"。任何徽章都比它要紧,来了就让; + // 文字压 SUPPORTING 档、用最淡的墨——它是环境注脚,不是第二个 + // 标签名,和名字抢眼就输了。 + let badge_busy = d.nebula_tab_bells.get(index).copied().unwrap_or(false) + || d.nebula_tab_running.get(index).copied().unwrap_or(false) + || d.nebula_tab_attention.get(index).copied().unwrap_or(false) + || d.nebula_tab_failed.get(index).copied().unwrap_or(false) + || d.nebula_tab_flashing.get(index).copied().unwrap_or(false); + if !tab_hovered && !renaming_this && !badge_busy { + if let Some(tag) = d.nebula_tab_shells.get(index).filter(|tag| !tag.is_empty()) { + const TAG_SCALE: f32 = tokens::type_scale::SUPPORTING; + let tag_cols: usize = tag.chars().map(|c| c.width().unwrap_or(0).max(1)).sum(); + let tag_w = tag_cols as f32 * cell_w * TAG_SCALE; + let tag_x = tab_x + tab_w - s(10.0) - tag_w; + let label_cols: usize = + label.chars().map(|c| c.width().unwrap_or(0).max(1)).sum(); + let label_end = text_x + label_cols as f32 * (cell_w + row_tracking); + // 名字长到贴着右缘时不画:一半压在字上的注脚比没有更糟。 + if tag_x > label_end + s(12.0) { + d.renderer.draw_ui_text( + &size, + tag_x, + draw_row_y + (tab_h - cell_h * TAG_SCALE) / 2.0, + TAG_SCALE, + sk.ink_faint, + nebula_terminal::term::cell::Flags::empty(), + tag, + &mut d.glyph_cache, + ); + } + } + } if tab_hovered { let (close_x, _, close_w, close_h) = tab_layout.closes[index]; let close_y = draw_row_y + (tab_h - close_h) / 2.0; @@ -1615,14 +1888,49 @@ pub(super) fn draw_chrome(d: &mut Display) { d.renderer.draw_ui_text_tracked( &size, hh_x + s(16.0), - hh_y + (hh_h - cell_h * SECTION_TITLE_SCALE) / 2.0, + section_title_y( + tab_layout.hosts_header, + false, + cell_h * SECTION_TITLE_SCALE, + scale, + ), SECTION_TITLE_SCALE, section_title_tracking, TXT_DIM, section_title_flags, - &format!("SSH HOSTS {hosts_chevron}"), + &format!("{hosts_chevron} SSH HOSTS"), &mut d.glyph_cache, ); + { + let n = d.nebula_ssh_hosts.len().to_string(); + let adv = d.renderer.ui_text_advance(&d.glyph_cache, SECTION_TITLE_SCALE); + let chip = section_count_chip( + tab_layout.hosts_header, + false, + 12, + n.len(), + adv, + section_title_tracking, + cell_h * SECTION_TITLE_SCALE, + scale, + ); + d.renderer.draw_ui_text_tracked( + &size, + chip.0 + (chip.2 - adv * n.len() as f32) * 0.5, + section_title_y( + tab_layout.hosts_header, + false, + cell_h * SECTION_TITLE_SCALE, + scale, + ), + SECTION_TITLE_SCALE, + 0.0, + sk.ink_faint, + nebula_terminal::term::cell::Flags::empty(), + &n, + &mut d.glyph_cache, + ); + } // Empty state: the section stays visible with a hint teaching the // zero-config path — typing `ssh host` in any pane auto-saves the // destination here once the connection confirms (`~/.ssh/config` @@ -1705,23 +2013,61 @@ pub(super) fn draw_chrome(d: &mut Display) { // reserve under-counted at larger font sizes and let long // aliases run past the hover pill. let text_x = hx + s(14.0); - let right = hx + hw - s(42.0); + // 右缘预算:置顶行给 pin 记号留槽,普通行只留呼吸气口。此前 + // 无条件扣 42px——未置顶的行右边一片空白,地址却被截成 + // 「192.168.…」。 + let right = hx + hw - if pinned { s(42.0) } else { s(10.0) }; let row_tracking = s(0.35); let max_cols = (((right - text_x) / (cell_w + row_tracking)).floor() as usize) .saturating_sub(2); let label = truncate_tab_label(shown, max_cols.max(1)); - d.renderer.draw_chrome_text( + // 双行:名字在上、地址在下。地址是这一行的**身份**(置顶、删除、 + // 连接都按它走),但人认的是名字——所以名字占主行,地址退成一行 + // 更小更淡的副文本。两者都完整可见,不必再把名字截成「生…」。 + // + // 没起过名字的旧条目退回单行:那时 `shown` 就是地址本身,画成 + // 两行等于把同一串字重复一遍,比留白更糟。 + const SUB_SCALE: f32 = tokens::type_scale::SUPPORTING; + let two_line = shown != name; + let sub_h = cell_h * SUB_SCALE; + let title_y = if two_line { + hy + (hh - (cell_h + sub_h)) * 0.5 + } else { + hy + (hh - cell_h) * 0.5 + }; + // 图标跨着两行居中,而不是贴着名字那一行:它标注的是**整条** + // 主机(名字和地址是同一台机器的两种写法),跟第一行对齐会让 + // 副行看起来像悬在图标外面的另一条目。 + // + // 图标有**自己的槽**,宽度就是标签行品牌 logo 的那一格 + // (`cell_h * 0.72`),后面留 6px 气口再排文字——和标签行完全 + // 同一套节奏,两种行的图标因此同宽同列、文字也在同一竖线上。 + // + // 之前是借用等宽网格(图标第 0 列、文字第 2 列,"\u{eb51} 设置" + // 那类标签就是这么拼的)。但 Nerd Font 的图标墨迹比步进宽得多 + // 且各不相同(1.27~2.00 格,见 ui::os_icons),宽的那些右缘直 + // 接顶到文字上——这就是"贴得太紧"的来源。槽里再按墨迹缩到 0.82 + // 档,比品牌 logo 略收一点:logo 是彩色实心的,单色线条图标画到 + // 同样大就显得重。 + let icon = + super::ui::os_icons::resolve(d.nebula_ssh_icons.get(name).map(String::as_str)); + let icon_slot = (cell_h * 0.72).round(); + let icon_px = icon_slot * 0.82; + let icon_mult = super::ui::os_icons::scale_for(icon, cell_w, icon_px); + d.renderer.draw_chrome_text_scaled( &size, - text_x, - cy, + text_x + (icon_slot - icon_px) * 0.5, + hy + hh / 2.0 - cell_h * icon_mult / 2.0, + icon_mult, color, - "\u{f489}", + icon.glyph.encode_utf8(&mut [0u8; 4]), &mut d.glyph_cache, ); + let label_x = text_x + icon_slot + s(6.0); d.renderer.draw_ui_text_tracked( &size, - text_x + cell_w * 2.0, - cy, + label_x, + title_y, 1.0, row_tracking, color, @@ -1729,6 +2075,25 @@ pub(super) fn draw_chrome(d: &mut Display) { &label, &mut d.glyph_cache, ); + if two_line { + // 地址保持等宽:它是技术值,要被逐字符比对(哪台机器、哪个 + // 端口),等宽下数字对齐读起来才快。名字是自然语言,走上面 + // 那条路径。 + let sub_cols = + (((hx + hw - s(42.0)) - (label_x)) / (cell_w * SUB_SCALE)).floor() as usize; + let sub = truncate_tab_label(name, sub_cols.max(1)); + d.renderer.draw_ui_text_tracked( + &size, + label_x, + title_y + cell_h, + SUB_SCALE, + row_tracking, + sk.ink_faint, + nebula_terminal::term::cell::Flags::empty(), + &sub, + &mut d.glyph_cache, + ); + } if pinned { // Pin marker pinned to the row's right edge (mirrors the // × slot on tab rows). @@ -1803,13 +2168,8 @@ pub(super) fn draw_chrome(d: &mut Display) { settings::push_popup_quads(&view, &mut popup_quads, &size, scale); if !popup_quads.is_empty() { d.renderer.draw_ui(&size, &popup_quads); - let popup_icons = settings::draw_popup_text( - &view, - &mut d.renderer, - &mut d.glyph_cache, - &size, - scale, - ); + let popup_icons = + settings::draw_popup_text(&view, &mut d.renderer, &mut d.glyph_cache, &size, scale); for (shell_id, rect) in popup_icons { if let Some((id, rgba, px)) = d.shell_icon_pixels(&shell_id) { d.nebula_chrome_logo_draws.push((id, rgba, px, rect)); @@ -1894,9 +2254,10 @@ pub(super) fn draw_chrome(d: &mut Display) { ); // Palette's full-color shell icons (textured quads) staged after all chrome - // text, like AI brand logos. Decode + cache each PNG once per id. - for (shell_id, rect) in shell_icon_draws { - if let Some((id, rgba, px)) = d.shell_icon_pixels(&shell_id) { + // text. AI-session rows no longer come through here — they draw the + // shared double-star glyph in skin ink, which follows the theme for free. + for (icon_id, rect) in shell_icon_draws { + if let Some((id, rgba, px)) = d.shell_icon_pixels(&icon_id) { d.nebula_chrome_logo_draws.push((id, rgba, px, rect)); } } @@ -1923,6 +2284,185 @@ fn tab_reveal_motion_action( } } +#[cfg(test)] +mod sidebar_dock_tests { + use super::{SidebarModel, chrome_tab_layout}; + use crate::display::SizeInfo; + + fn layout(tabs: usize, hosts: usize, hosts_open: bool) -> super::ChromeTabLayout { + let size = SizeInfo::new(1400.0, 900.0, 10.0, 20.0, 0.0, 0.0, false); + chrome_tab_layout( + &size, + 1.0, + SidebarModel { + tab_count: tabs, + host_count: hosts, + tabs_open: true, + hosts_open, + tabs_scroll: 0, + hosts_scroll: 0, + sidebar_w: crate::display::SIDEBAR_W_LOGICAL, + hosts_band: 0.0, + }, + 1.0, + ) + } + + /// 停靠区的**下**沿贴着面板底,上沿随内容升降——这就是"沉底"。折起来时 + /// 只剩那条标题,它照样贴在最下面而不是跟着标签跑。 + #[test] + fn hosts_dock_to_the_panel_bottom_whatever_they_contain() { + for (hosts, open) in [(0, true), (4, true), (40, true), (12, false)] { + let l = layout(3, hosts, open); + let panel_bottom = l.panel.1 + l.panel.3; + let dock_bottom = if open && hosts > 0 { + l.hosts_band.1 + } else { + l.hosts_header.1 + l.hosts_header.3 + }; + assert!( + dock_bottom <= panel_bottom && panel_bottom - dock_bottom < 40.0, + "hosts={hosts} open={open}:停靠区底 {dock_bottom} 离面板底 {panel_bottom} 太远", + ); + } + } + + /// 沉底之所以值得做:主机再多也吃不掉标签的地盘。这是旧的同流布局做不到 + /// 的——那时四十台主机会把标签一路顶出视口。 + #[test] + fn a_long_host_list_never_starves_the_tab_list() { + let few = layout(6, 2, true); + let many = layout(6, 60, true); + let visible = |l: &super::ChromeTabLayout| l.tabs.iter().filter(|r| r.2 > 0.0).count(); + + assert_eq!(visible(&few), 6, "六个标签本来就该全放得下"); + assert_eq!(visible(&many), 6, "主机涨到 60 台,标签一行都不该少"); + assert!(many.hosts_max_scroll > 0, "放不下的主机应该退给滚动,而不是去挤标签"); + } + + /// 拖拽调节:HOSTS 分界的覆盖值取代弹性规则,且两端有钳制——往下拖到 + /// 只剩标题、往上拖也抢不走标签的最后一行。 + #[test] + fn a_dragged_hosts_band_overrides_the_elastic_cap_within_clamps() { + let with_band = |band: f32| { + let size = SizeInfo::new(1400.0, 900.0, 10.0, 20.0, 0.0, 0.0, false); + chrome_tab_layout( + &size, + 1.0, + SidebarModel { + tab_count: 3, + host_count: 20, + tabs_open: true, + hosts_open: true, + tabs_scroll: 0, + hosts_scroll: 0, + sidebar_w: crate::display::SIDEBAR_W_LOGICAL, + hosts_band: band, + }, + 1.0, + ) + }; + let rows = |l: &super::ChromeTabLayout| l.hosts.iter().filter(|r| r.2 > 0.0).count(); + let auto = with_band(0.0); + let small = with_band(140.0); + assert!(rows(&small) < rows(&auto), "把停靠区拖矮后可见主机行必须变少"); + let huge = with_band(5000.0); + assert!(huge.tabs[0].3 > 0.0, "覆盖值再大,标签区也要保住至少一行"); + assert!(rows(&huge) >= rows(&auto), "拖高后主机行不应少于弹性规则"); + } + + /// 停靠区高度必须真的跟着分界走,哪怕主机根本撑不满:只有一台主机时, + /// 把分界往上拖,标题条要真的上移。此前 `hosts_band` 只当上限用,内容 + /// 长不到那么高,分界线就纹丝不动——手感上就是「拖不动」(用户 08-02)。 + #[test] + fn dragging_the_divider_up_moves_it_even_with_a_single_host() { + let layout = |band: f32| { + let size = SizeInfo::new(1400.0, 900.0, 10.0, 20.0, 0.0, 0.0, false); + chrome_tab_layout( + &size, + 1.0, + SidebarModel { + tab_count: 3, + host_count: 1, + tabs_open: true, + hosts_open: true, + tabs_scroll: 0, + hosts_scroll: 0, + sidebar_w: crate::display::SIDEBAR_W_LOGICAL, + hosts_band: band, + }, + 1.0, + ) + }; + let hugging = layout(0.0); + let dragged = layout(360.0); + assert!( + dragged.hosts_header.1 < hugging.hosts_header.1 - 100.0, + "拖高 360px 后 HOSTS 标题条必须明显上移,而不是贴着那一台主机不动" + ); + // 那一台主机仍然画在标题条下面(多出来的地盘是空白,不是把行推走)。 + assert!(dragged.hosts[0].3 > 0.0); + assert!(dragged.hosts[0].1 > dragged.hosts_header.1); + } + + + #[test] + fn a_dragged_sidebar_width_widens_the_panel_one_to_one() { + let with_width = |w: f32| { + let size = SizeInfo::new(1400.0, 900.0, 10.0, 20.0, 0.0, 0.0, false); + chrome_tab_layout( + &size, + 1.0, + SidebarModel { + tab_count: 3, + host_count: 2, + tabs_open: true, + hosts_open: true, + tabs_scroll: 0, + hosts_scroll: 0, + sidebar_w: w, + hosts_band: 0.0, + }, + 1.0, + ) + }; + let narrow = with_width(230.0); + let wide = with_width(360.0); + assert_eq!(wide.panel.2 - narrow.panel.2, 130.0); + assert!(wide.tabs[0].2 > narrow.tabs[0].2, "行宽要跟着面板一起变宽"); + } + + /// 封顶是为了保护标签;标签不需要保护的时候就不该继续封着,否则两个标签 + /// 配四十台主机会在中间空出一大片。 + #[test] + fn the_dock_grows_past_its_cap_when_tabs_leave_room() { + let crowded = layout(20, 40, true); + let roomy = layout(2, 40, true); + let rows = |l: &super::ChromeTabLayout| l.hosts.iter().filter(|r| r.2 > 0.0).count(); + + assert!( + rows(&roomy) > rows(&crowded), + "标签只有两个时主机该多显示几行({} vs {})", + rows(&roomy), + rows(&crowded), + ); + } + + /// 两个区不能重叠:标签的最后一行必须停在停靠区标题上面。 + #[test] + fn the_two_zones_never_overlap() { + for tabs in [1, 5, 12, 30] { + let l = layout(tabs, 8, true); + assert!( + l.tabs_band.1 <= l.hosts_header.1, + "tabs={tabs}:标签带底 {} 压到了停靠区标题 {}", + l.tabs_band.1, + l.hosts_header.1, + ); + } + } +} + #[cfg(test)] mod resize_edge_tests { use super::{ diff --git a/nebula_app/src/display/command_palette.rs b/nebula_app/src/display/command_palette.rs index 4b94d9f..085d8be 100644 --- a/nebula_app/src/display/command_palette.rs +++ b/nebula_app/src/display/command_palette.rs @@ -1,2028 +1,2746 @@ -//! Command palette (`Ctrl+Shift+P`): a fuzzy-searchable launcher for every -//! Nebula action — the discoverable entry point for features whose shortcuts -//! are hard to remember. -//! -//! This module owns only the *model*: the action list, the query/selection -//! state, fuzzy filtering, and the popup layout maths. Rendering lives in -//! `display::mod` (it mirrors the settings modal), and execution is dispatched -//! by the input layer, which is the only place that can reach both the display -//! and the window context. Keeping the model here makes it self-contained and -//! keeps the giant `mod.rs` free of the item table. - -use std::path::PathBuf; - -use super::{NebulaTheme, SizeInfo}; -use crate::shell_detect::DetectedShell; -use unicode_width::UnicodeWidthChar; - -/// A dynamic quick-launch row: a config profile (launched by index) or a -/// detected shell (spec carried inline). Built fresh on every menu open. -#[derive(Debug, Clone)] -enum ProfileRow { - /// Config profile at this index — routed through `TabRequest::NewProfile`. - Config { label: String, search: String, index: usize }, - /// Detected shell — routed through `TabRequest::NewShell`. `hint` is the - /// program path, shown dimmed (Windows Terminal's profile menu layout). - Shell { label: String, hint: String, search: String, shell: DetectedShell }, -} - -impl ProfileRow { - fn label(&self) -> &str { - match self { - Self::Config { label, .. } | Self::Shell { label, .. } => label, - } - } - - fn hint(&self) -> &str { - match self { - Self::Config { .. } => "", - Self::Shell { hint, .. } => hint, - } - } - - fn search(&self) -> &str { - match self { - Self::Config { search, .. } | Self::Shell { search, .. } => search, - } - } - - /// Leading Nerd Font glyph. Detected shells carry their own; config - /// profiles get a generic launch mark. - fn icon(&self) -> &'static str { - match self { - Self::Shell { shell, .. } => shell.icon(), - Self::Config { .. } => "\u{ea60}", - } - } - - /// Stable shell id for the full-color brand icon lookup, or `""` for - /// config profiles (which have no brand asset and keep their glyph). - fn color_id(&self) -> &str { - match self { - Self::Shell { shell, .. } => &shell.id, - Self::Config { .. } => "", - } - } -} - -/// 命令面板只负责展示目录;候选的匹配与 frecency 排序由共享目录服务完成, -/// 避免 UI 再维护一套会逐渐分叉的目录搜索规则。 -#[derive(Debug, Clone)] -struct DirectoryRow { - path: PathBuf, - label: String, - hint: String, -} - -impl DirectoryRow { - fn new(path: PathBuf) -> Self { - let label = path - .file_name() - .filter(|name| !name.is_empty()) - .unwrap_or_else(|| path.as_os_str()) - .to_string_lossy() - .into_owned(); - let hint = path.display().to_string(); - Self { path, label, hint } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -enum PaletteCandidate { - Item(usize), - Profile(usize), - Directory(usize), -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum PaletteMode { - Commands, - Profiles, - DefaultShell, - Directories, -} - -/// A single executable action reachable from the command palette. -/// -/// Deliberately flat so the input layer can match on it after the palette -/// closes, without holding any borrow. Each variant maps onto either a -/// `TabRequest` (tab / split / window operations) or a `Display` method -/// (theme / settings / appearance) — see `keyboard.rs::run_palette_action`. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum PaletteAction { - NewTab, - /// Open the frecency-ranked directory picker; this is a UI workflow, not a - /// shell-specific command or alias. - OpenDirectoryPicker, - CloseTab, - NextTab, - PrevTab, - NewWindow, - SplitRight, - SplitDown, - OpenSettings, - OpenSettingsFile, - ToggleGhost, - CycleAccept, - PickBackgroundImage, - CycleBackground, - ResetAppearance, - SelectTheme(NebulaTheme), - /// Launch the quick-launch profile at this config index in a new tab. - LaunchProfile(usize), - /// Launch a detected shell (the new-tab dropdown) in a new tab. - LaunchShell(DetectedShell), - /// Set a detected shell as the default (the settings "默认 Shell" picker). - SetDefaultShell(DetectedShell), - /// Open a local terminal whose PTY starts directly in this directory. - NewAtDirectory(PathBuf), - ToggleFilesPanel, - ToggleGitPanel, - /// Save every terminal tab as a workspace file. - ExportWorkspace, - /// Pick a workspace file and append its tabs to this window. - ImportWorkspace, - /// WebDAV 同步(spec 003):推送本机设置到远端。 - SyncPush, - /// WebDAV 同步:拉取远端设置并合并到本机。 - SyncPull, -} - -/// One palette row. -/// -/// * `label` — localized text shown on the left. -/// * `hint` — optional shortcut / aux text, dimmed and right-aligned (ASCII, -/// so its on-screen width equals its `char` count). -/// * `search` — the haystack matched against the query. Includes the label plus -/// latin aliases (pinyin / English) so the palette is reachable even when the -/// IME can't feed CJK into it. -/// * `action` — what to run on confirm. -struct PaletteItem { - label: &'static str, - hint: &'static str, - search: &'static str, - action: PaletteAction, -} - -/// The full action table, in declaration order (also the tie-break order when -/// fuzzy scores are equal, and the order shown for an empty query). -const ITEMS: &[PaletteItem] = &[ - PaletteItem { - label: "新建标签页", - hint: "Ctrl+Shift+T", - search: "新建标签页 new tab xinjian biaoqianye", - action: PaletteAction::NewTab, - }, - PaletteItem { - label: "在常用目录中新建终端…", - hint: "", - search: "在常用目录中新建终端 new terminal in frequent directory changyong mulu", - action: PaletteAction::OpenDirectoryPicker, - }, - PaletteItem { - label: "关闭标签页", - hint: "Ctrl+Shift+W", - search: "关闭标签页 close tab guanbi", - action: PaletteAction::CloseTab, - }, - PaletteItem { - label: "下一个标签页", - hint: "Ctrl+Tab", - search: "下一个标签页 next tab xiayige", - action: PaletteAction::NextTab, - }, - PaletteItem { - label: "上一个标签页", - hint: "Ctrl+Shift+Tab", - search: "上一个标签页 previous prev tab shangyige", - action: PaletteAction::PrevTab, - }, - PaletteItem { - label: "新建窗口", - hint: "Ctrl+Shift+E", - search: "新建窗口 new window xinjian chuangkou", - action: PaletteAction::NewWindow, - }, - PaletteItem { - label: "左右分屏", - hint: "Ctrl+Shift+D", - search: "左右分屏 split right vertical zuoyou fenping", - action: PaletteAction::SplitRight, - }, - PaletteItem { - label: "上下分屏", - hint: "Ctrl+Shift+S", - search: "上下分屏 split down horizontal shangxia fenping", - action: PaletteAction::SplitDown, - }, - PaletteItem { - label: "导出工作区…", - hint: "", - search: "导出工作区 export workspace save session daochu gongzuoqu", - action: PaletteAction::ExportWorkspace, - }, - PaletteItem { - label: "打开工作区…", - hint: "", - search: "打开工作区 open import workspace load session dakai daoru gongzuoqu", - action: PaletteAction::ImportWorkspace, - }, - PaletteItem { - label: "目录树面板", - hint: "Ctrl+Shift+O", - search: "目录树面板 files tree explorer panel mulushu wenjian", - action: PaletteAction::ToggleFilesPanel, - }, - PaletteItem { - label: "Git 面板", - hint: "Ctrl+Shift+G", - search: "git 面板 status branch panel mianban", - action: PaletteAction::ToggleGitPanel, - }, - PaletteItem { - label: "打开设置", - hint: "", - search: "打开设置 open settings preferences dakai shezhi", - action: PaletteAction::OpenSettings, - }, - PaletteItem { - label: "打开配置文件", - hint: "", - search: "打开配置文件 open config file dakai peizhi wenjian", - action: PaletteAction::OpenSettingsFile, - }, - PaletteItem { - label: "同步:推送设置到云端", - hint: "", - search: "同步推送设置到云端 webdav sync push upload settings tongbu tuisong", - action: PaletteAction::SyncPush, - }, - PaletteItem { - label: "同步:从云端拉取设置", - hint: "", - search: "同步从云端拉取设置 webdav sync pull download settings tongbu laqu", - action: PaletteAction::SyncPull, - }, - PaletteItem { - label: "切换行内补全 (Ghost)", - hint: "", - search: "切换行内补全 toggle ghost completion qiehuan buquan", - action: PaletteAction::ToggleGhost, - }, - PaletteItem { - label: "切换补全接受键", - hint: "", - search: "切换补全接受键 cycle accept key completion jieshou", - action: PaletteAction::CycleAccept, - }, - PaletteItem { - label: "选择背景图片…", - hint: "", - search: "选择背景图片 background image picture xuanze beijing tupian", - action: PaletteAction::PickBackgroundImage, - }, - PaletteItem { - label: "切换背景色", - hint: "", - search: "切换背景色 cycle background color qiehuan beijingse", - action: PaletteAction::CycleBackground, - }, - PaletteItem { - label: "恢复外观默认", - hint: "", - search: "恢复外观默认 reset appearance default huifu waiguan moren", - action: PaletteAction::ResetAppearance, - }, - PaletteItem { - label: "主题:Nebula", - hint: "", - search: "主题 nebula theme zhuti", - action: PaletteAction::SelectTheme(NebulaTheme::Nebula), - }, - PaletteItem { - label: "主题:Silver Light", - hint: "", - search: "主题 silver light theme zhuti", - action: PaletteAction::SelectTheme(NebulaTheme::SilverLight), - }, - PaletteItem { - label: "主题:Steel Dark", - hint: "", - search: "主题 steel dark theme zhuti", - action: PaletteAction::SelectTheme(NebulaTheme::SteelDark), - }, - PaletteItem { - label: "主题:Limestone", - hint: "", - search: "主题 limestone light theme zhuti", - action: PaletteAction::SelectTheme(NebulaTheme::LimestoneLight), - }, - PaletteItem { - label: "主题:Coal Dark", - hint: "", - search: "主题 coal dark theme zhuti", - action: PaletteAction::SelectTheme(NebulaTheme::CoalDark), - }, - PaletteItem { - label: "主题:Linen Light", - hint: "", - search: "主题 linen light theme zhuti", - action: PaletteAction::SelectTheme(NebulaTheme::LinenLight), - }, - PaletteItem { - label: "主题:Moss Dark", - hint: "", - search: "主题 moss dark theme zhuti", - action: PaletteAction::SelectTheme(NebulaTheme::MossDark), - }, -]; - -/// How many recently-run actions are remembered for the empty-query ordering. -const RECENT_MAX: usize = 6; - -/// Command palette UI + filtering state, embedded in `Display`. -pub struct CommandPalette { - language: super::UiLanguage, - open: bool, - query: String, - query_selection: super::text_input::SelectAllState, - /// 已排序的可见候选。显式区分类型,避免动态列表长度变化后用“偏移量索引” - /// 把目录误解释成 Profile 或静态命令。 - filtered: Vec, - /// Selected row *within `filtered`*. `None` when nothing is selected yet - /// (initial state — keyboard nav or hover will activate selection). - selected: Option, - /// Recently-run `ITEMS` indices, most-recent first (deduped, capped at - /// `RECENT_MAX`). Lifts frequent actions to the top of an empty query. - /// Static items only: profile indices shift whenever the config changes. - recent: Vec, - /// Dynamic quick-launch rows, refreshed on every open so live config - /// reloads and shell (re)detection are picked up. In profiles-only (the - /// new-tab dropdown) these are detected shells + config profiles; in the - /// full palette they're the config profiles appended after the actions. - profiles: Vec, - /// Stable id of the default shell, used by the shell/profile picker badge. - default_shell_id: Option, - /// Frecency-ranked directory rows supplied by `DirectoryHistory`. - directories: Vec, - mode: PaletteMode, - /// Mouse-hovered row within the visible window (`None` when not hovering). - hover: Option, - /// 打开时武装:指针从首次上报位置真正移动(>2px)前不点亮 hover。 - /// 「+」的下拉紧贴按钮弹出,首行往往恰在指针正下方——立即点亮会被 - /// 读成「PowerShell 被默认选中」(2026-07-28 用户反馈:全部待选)。 - hover_armed: bool, - /// 武装期间首次上报的指针位置(解除武装的位移基准)。 - pointer_baseline: Option<(f32, f32)>, -} - -impl CommandPalette { - pub fn new() -> Self { - let mut palette = Self { - language: super::UiLanguage::ZhCn, - open: false, - query: String::new(), - query_selection: Default::default(), - filtered: Vec::new(), - selected: None, // No selection until user navigates - recent: Vec::new(), - profiles: Vec::new(), - default_shell_id: None, - directories: Vec::new(), - mode: PaletteMode::Commands, - hover: None, - hover_armed: false, - pointer_baseline: None, - }; - palette.refilter(); - palette - } - - pub fn is_open(&self) -> bool { - self.open - } - - pub fn set_language(&mut self, language: super::UiLanguage) { - self.language = language; - self.refilter(); - } - - /// Whether the palette is in default-shell picking mode. - pub fn is_picking_default(&self) -> bool { - self.mode == PaletteMode::DefaultShell - } - - /// Shell/profile selector mode. This identity is used only for natural - /// dismissal on window focus loss; the selector remains searchable because - /// a long shell/profile list otherwise becomes needlessly hard to scan. - pub fn is_picker(&self) -> bool { - self.open && self.mode != PaletteMode::Commands - } - - pub fn is_picking_directory(&self) -> bool { - self.open && self.mode == PaletteMode::Directories - } - - /// 图4 版式:shell picker 空查询时,首行(默认 shell)渲染为"推荐" - /// 大卡片,其余行归入"所有选项"。搜索中或无默认时退回平铺卡片列表。 - pub fn hero_row(&self) -> bool { - self.mode == PaletteMode::Profiles - && self.query.is_empty() - && matches!( - self.filtered.first(), - Some(&PaletteCandidate::Profile(index)) if matches!( - &self.profiles[index], - ProfileRow::Shell { shell, .. } - if self.default_shell_id.as_deref() == Some(shell.id.as_str()) - ) - ) - } - - /// Refresh the dynamic quick-launch rows from the config's profile names. - /// Called by the full-palette open path so a reloaded config is reflected. - pub fn set_profiles(&mut self, names: &[String]) { - self.profiles = names - .iter() - .enumerate() - .map(|(index, name)| ProfileRow::Config { - // The label carries a glyph-free prefix so profile rows read - // distinctly from built-in actions; the haystack adds latin - // aliases (matching the static items' convention). - label: format!("{}:{name}", self.language.pick("启动", "Launch")), - search: format!("启动 {name} profile launch connect qidong"), - index, - }) - .collect(); - } - - /// Populate the new-tab dropdown: detected shells first (installed-shell - /// order), then config profiles. The label carries no verb prefix here — - /// this menu IS the shell picker, so bare names read cleaner. - pub fn set_shell_menu( - &mut self, - shells: &[DetectedShell], - profiles: &[String], - default_shell_id: &str, - ) { - let mut rows: Vec = shells - .iter() - .map(|shell| ProfileRow::Shell { - label: shell.name.clone(), - hint: shell.program.clone(), - search: format!("{} {} shell profile", shell.name, shell.id), - shell: shell.clone(), - }) - .collect(); - rows.extend(profiles.iter().enumerate().map(|(index, name)| ProfileRow::Config { - label: name.clone(), - search: format!("{name} profile launch connect qidong"), - index, - })); - // 图4 版式:默认 shell 是"推荐"大卡片,必须占首行 —— Enter 直接 - // 打开推荐项,检测顺序不再决定谁排第一。 - if let Some(position) = rows.iter().position(|row| { - matches!(row, ProfileRow::Shell { shell, .. } if shell.id == default_shell_id) - }) { - let default_row = rows.remove(position); - rows.insert(0, default_row); - } - self.profiles = rows; - self.default_shell_id = Some(default_shell_id.to_owned()); - } - - /// Populate the settings "默认 Shell" picker: detected shells only (no - /// config profiles — you can't default to an ssh jump), and confirming - /// sets the default instead of launching. - pub fn set_default_shell_menu(&mut self, shells: &[DetectedShell]) { - self.profiles = shells - .iter() - .map(|shell| ProfileRow::Shell { - label: shell.name.clone(), - hint: shell.program.clone(), - search: format!("{} {} shell profile", shell.name, shell.id), - shell: shell.clone(), - }) - .collect(); - self.default_shell_id = None; - } - - pub fn set_directories(&mut self, paths: Vec) { - self.directories = paths.into_iter().map(DirectoryRow::new).collect(); - if self.mode == PaletteMode::Directories { - self.refilter(); - } - } - - /// Open (or re-open) the palette with a cleared query and the full list. - pub fn open(&mut self) { - self.arm_pointer_hover(); - self.open = true; - self.mode = PaletteMode::Commands; - self.query.clear(); - self.query_selection.clear(); - self.refilter(); - } - - /// Open showing only the quick-launch profiles (the "+" dropdown). - pub fn open_profiles(&mut self) { - self.arm_pointer_hover(); - self.open = true; - self.mode = PaletteMode::Profiles; - self.query.clear(); - self.query_selection.clear(); - self.refilter(); - } - - /// Open the default-shell picker (settings row): profile rows only, and - /// confirm SETS the default rather than launching. - pub fn open_default_picker(&mut self) { - self.arm_pointer_hover(); - self.open = true; - self.mode = PaletteMode::DefaultShell; - self.query.clear(); - self.query_selection.clear(); - self.refilter(); - } - - /// Open the generic directory picker. Search results are refreshed by - /// `Display` after every query edit so ranking remains owned by one service. - pub fn open_directories(&mut self) { - self.arm_pointer_hover(); - self.open = true; - self.mode = PaletteMode::Directories; - self.query.clear(); - self.query_selection.clear(); - self.refilter(); - } - - pub fn close(&mut self) { - self.open = false; - self.mode = PaletteMode::Commands; - self.hover = None; - self.query_selection.clear(); - self.default_shell_id = None; - } - - /// 指针驱动的 hover 更新(武装门在此):打开后指针必须从首次上报 - /// 位置移动超过 2px 才开始点亮;解除一次后恢复普通 hover 跟随。 - pub fn pointer_hover(&mut self, pos: (f32, f32), row: Option) -> bool { - if self.hover_armed { - match self.pointer_baseline { - None => { - self.pointer_baseline = Some(pos); - return self.set_hover(None); - }, - Some(base) - if (base.0 - pos.0).abs() < 2.0 && (base.1 - pos.1).abs() < 2.0 => - { - return self.set_hover(None); - }, - Some(_) => { - self.hover_armed = false; - self.pointer_baseline = None; - }, - } - } - self.set_hover(row) - } - - /// 每次打开(任何模式)都重新武装 hover。 - fn arm_pointer_hover(&mut self) { - self.hover_armed = true; - self.pointer_baseline = None; - self.hover = None; - } - - /// Update hover based on mouse position. `row` is the index within the - /// visible window (`0..max_rows`), or `None` when the mouse left. Returns - /// whether the hover changed, so the caller only redraws on transitions. - pub fn set_hover(&mut self, row: Option) -> bool { - if self.hover == row { - return false; - } - self.hover = row; - true - } - - /// 光标该不该画。相位来自 [`ui::caret`](super::ui::caret) 的共享节律, - /// 与标签重命名框、SSH 编辑器、侧栏搜索框完全一致——打字时常亮、 - /// 停手后按系统的 `GetCaretBlinkTime` 呼吸。 - /// - /// 这里曾经自带一个 1060ms、占空比 0.5 的 `Pulse`。它的问题不在参数, - /// 而在于**每个输入框各造一套节律**:同一个窗口里两个光标以不同相位 - /// 闪烁,读起来像界面有两个焦点。 - pub fn cursor_visible(&self) -> bool { - super::ui::caret::is_on() - } - - pub fn toggle(&mut self) { - if self.open { - self.close(); - } else { - self.open(); - } - } - - /// Append a typed character (control chars ignored) and re-filter. - pub fn input_char(&mut self, c: char) { - if c.is_control() { - return; - } - let mut encoded = [0u8; 4]; - self.query_selection.insert(&mut self.query, c.encode_utf8(&mut encoded)); - self.refilter(); - } - - pub fn input_text(&mut self, text: &str) { - self.query_selection.insert(&mut self.query, text); - self.refilter(); - } - - pub fn backspace(&mut self) { - self.query_selection.backspace(&mut self.query); - self.refilter(); - } - - pub fn select_all(&mut self) { - self.query_selection.select(&self.query); - } - - pub fn selected_text(&self) -> Option { - self.query_selection.selected_text(&self.query) - } - - pub fn query_all_selected(&self) -> bool { - self.query_selection.is_selected() - } - - /// Move the selection by `delta` rows, wrapping at both ends. - pub fn move_selection(&mut self, delta: i32) { - if self.filtered.is_empty() { - return; - } - let len = self.filtered.len() as i32; - // Initialize selection on first navigation - let current = self.selected.unwrap_or(0) as i32; - self.selected = Some(((current + delta).rem_euclid(len)) as usize); - } - - /// Confirm the current selection: records it as recent, closes the palette, - /// and returns the action to run, or `None` when nothing matches. - pub fn confirm(&mut self) -> Option { - // Enter executes the first row before arrow navigation. The full - // command palette also paints that row selected; lightweight pickers - // start visually neutral per稿二 but keep this efficient keyboard path. - let selected = self.selected.unwrap_or(0); - let candidate = *self.filtered.get(selected)?; - // 必须在 close() 清空模式之前计算动作;旧实现先关闭再判断 - // picking_default,导致“设置默认 Shell”被错误执行成“启动 Shell”。 - let action = match candidate { - PaletteCandidate::Item(index) => ITEMS[index].action.clone(), - PaletteCandidate::Profile(profile) => match &self.profiles[profile] { - ProfileRow::Config { index, .. } => PaletteAction::LaunchProfile(*index), - ProfileRow::Shell { shell, .. } if self.mode == PaletteMode::DefaultShell => { - PaletteAction::SetDefaultShell(shell.clone()) - }, - ProfileRow::Shell { shell, .. } => PaletteAction::LaunchShell(shell.clone()), - }, - PaletteCandidate::Directory(directory) => { - PaletteAction::NewAtDirectory(self.directories[directory].path.clone()) - }, - }; - self.close(); - if let PaletteCandidate::Item(index) = candidate { - self.record_recent(index); - } - Some(action) - } - - /// Confirm the visible row at `row` (0 = topmost visible line, mirroring - /// [`Self::visible`]'s scroll window) — the mouse-click path. - pub fn click(&mut self, row: usize, max_rows: usize) -> Option { - if self.filtered.is_empty() || max_rows == 0 { - return None; - } - let start = self.selected.unwrap_or(0).saturating_sub(max_rows - 1); - let filtered_index = start + row; - if filtered_index >= self.filtered.len() { - return None; - } - self.selected = Some(filtered_index); - self.confirm() - } - - /// Remember `idx` as the most-recently run command (deduped, capped), so a - /// freshly-opened (empty-query) palette lists frequent actions first. - fn record_recent(&mut self, idx: usize) { - self.recent.retain(|&i| i != idx); - self.recent.insert(0, idx); - self.recent.truncate(RECENT_MAX); - } - - /// Re-score every item against the query and rebuild `filtered`. With a - /// query: fuzzy score, best first, ties in declaration order. Empty query: - /// recently-run first, then declaration order (a stable sort keeps the - /// declared order for the un-recent tail), then profiles. Resets the - /// selection to the top. - fn refilter(&mut self) { - let candidates: Vec = match self.mode { - PaletteMode::Commands => (0..ITEMS.len()) - .map(PaletteCandidate::Item) - .chain((0..self.profiles.len()).map(PaletteCandidate::Profile)) - .collect(), - PaletteMode::Profiles | PaletteMode::DefaultShell => { - (0..self.profiles.len()).map(PaletteCandidate::Profile).collect() - }, - PaletteMode::Directories => { - (0..self.directories.len()).map(PaletteCandidate::Directory).collect() - }, - }; - let combined_search = |candidate: PaletteCandidate| -> &str { - match candidate { - PaletteCandidate::Item(index) => ITEMS[index].search, - PaletteCandidate::Profile(index) => self.profiles[index].search(), - // 目录模式已经由 DirectoryHistory 完成匹配和排序;这里 - // 不再二次模糊排序,以免破坏 frecency 的确定性。 - PaletteCandidate::Directory(_) => "", - } - }; - let query = self.query.trim(); - if self.mode == PaletteMode::Directories { - self.filtered = candidates; - } else if query.is_empty() { - let mut order = candidates; - order.sort_by_key(|candidate| match candidate { - PaletteCandidate::Item(index) => { - self.recent.iter().position(|recent| recent == index).unwrap_or(usize::MAX) - }, - PaletteCandidate::Profile(_) | PaletteCandidate::Directory(_) => usize::MAX, - }); - self.filtered = order; - } else { - let mut scored: Vec<(i32, PaletteCandidate)> = candidates - .into_iter() - .filter_map(|candidate| { - fuzzy_score(query, combined_search(candidate)).map(|score| (score, candidate)) - }) - .collect(); - scored.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1))); - self.filtered = scored.into_iter().map(|(_, candidate)| candidate).collect(); - } - self.selected = None; // Reset selection on refilter - } - - pub fn query(&self) -> &str { - &self.query - } - - pub fn is_empty(&self) -> bool { - self.filtered.is_empty() - } - - /// The at-most `max_rows` visible rows, scrolled so the selection stays in - /// view, plus the selected row's index *within that window* (`None` when the - /// list is empty OR nothing is selected yet). Collected so the result borrows nothing. - pub fn visible(&self, max_rows: usize) -> (Vec, Option) { - if self.filtered.is_empty() || max_rows == 0 { - return (Vec::new(), None); - } - // No stored selection yet: the first row is the visual/default target. - // `selected` stays None until navigation so Up from a fresh palette - // still wraps to the last row, matching the existing keyboard model. - let Some(selected) = self.selected else { - let rows = self.filtered.iter().take(max_rows).map(|&row| self.row_for(row)).collect(); - let selected = (self.mode == PaletteMode::Commands).then_some(0); - return (rows, selected); - }; - // Keep the selection visible: once it passes the last row, scroll so it - // sits on the bottom line of the window. - let start = selected.saturating_sub(max_rows - 1); - let rows = - self.filtered.iter().skip(start).take(max_rows).map(|&row| self.row_for(row)).collect(); - (rows, Some(selected - start)) - } - - fn row_for(&self, candidate: PaletteCandidate) -> PaletteRow { - match candidate { - PaletteCandidate::Item(index) => PaletteRow { - icon: String::new(), - color_id: String::new(), - label: localized_item_label(&ITEMS[index], self.language).to_owned(), - hint: ITEMS[index].hint.to_string(), - is_default: false, - }, - PaletteCandidate::Profile(index) => PaletteRow { - icon: self.profiles[index].icon().to_string(), - color_id: self.profiles[index].color_id().to_string(), - label: self.profiles[index].label().to_string(), - hint: self.profiles[index].hint().to_string(), - is_default: matches!( - &self.profiles[index], - ProfileRow::Shell { shell, .. } - if self.default_shell_id.as_deref() == Some(shell.id.as_str()) - ), - }, - PaletteCandidate::Directory(index) => PaletteRow { - icon: "\u{f07b}".to_owned(), - color_id: String::new(), - label: self.directories[index].label.clone(), - hint: self.directories[index].hint.clone(), - is_default: false, - }, - } - } -} - -fn localized_item_label(item: &PaletteItem, language: super::UiLanguage) -> &'static str { - use PaletteAction::*; - if language == super::UiLanguage::ZhCn { - return item.label; - } - match item.action { - NewTab => "New tab", - OpenDirectoryPicker => "New terminal in a frequent directory...", - CloseTab => "Close tab", - NextTab => "Next tab", - PrevTab => "Previous tab", - NewWindow => "New window", - SplitRight => "Split right", - SplitDown => "Split down", - ToggleFilesPanel => "Files panel", - ToggleGitPanel => "Git panel", - OpenSettings => "Open settings", - OpenSettingsFile => "Open configuration file", - ToggleGhost => "Toggle ghost completion", - CycleAccept => "Cycle completion accept key", - PickBackgroundImage => "Choose background image...", - CycleBackground => "Cycle background color", - ResetAppearance => "Restore appearance defaults", - SelectTheme(NebulaTheme::Nebula) => "Theme: Nebula", - SelectTheme(NebulaTheme::SilverLight) => "Theme: Silver Light", - SelectTheme(NebulaTheme::SteelDark) => "Theme: Steel Dark", - SelectTheme(NebulaTheme::LimestoneLight) => "Theme: Limestone", - SelectTheme(NebulaTheme::CoalDark) => "Theme: Coal Dark", - SelectTheme(NebulaTheme::LinenLight) => "Theme: Linen Light", - SelectTheme(NebulaTheme::MossDark) => "Theme: Moss Dark", - ExportWorkspace => "Export workspace...", - ImportWorkspace => "Open workspace...", - SyncPush => "Sync: push settings", - SyncPull => "Sync: pull settings", - LaunchProfile(_) | LaunchShell(_) | SetDefaultShell(_) | NewAtDirectory(_) => item.label, - } -} - -/// One rendered palette row. `icon` is the Nerd Font fallback glyph (empty for -/// built-in action rows); `color_id` names a full-color brand PNG when the row -/// is a detected shell (empty otherwise, so the glyph shows instead). -pub struct PaletteRow { - pub icon: String, - pub color_id: String, - pub label: String, - pub hint: String, - pub is_default: bool, -} - -/// Subsequence fuzzy score, or `None` if the needle isn't a subsequence of the -/// haystack. Consecutive runs and word-start matches are rewarded so intuitive -/// queries rank first (e.g. "nt" prefers "new tab" over "next"). An empty -/// needle matches everything with score 0, preserving declaration order. -fn fuzzy_score(needle: &str, haystack: &str) -> Option { - if needle.is_empty() { - return Some(0); - } - let needle: Vec = needle.chars().flat_map(char::to_lowercase).collect(); - let mut next = 0usize; - let mut score = 0i32; - let mut run = 0i32; - let mut prev = ' '; - for hc in haystack.chars().flat_map(char::to_lowercase) { - if next < needle.len() && hc == needle[next] { - score += 1 + run * 5; // consecutive-match run bonus (dominant) - if !prev.is_alphanumeric() { - score += 4; // word / segment start - } - run += 1; - next += 1; - } else { - run = 0; - } - prev = hc; - } - (next == needle.len()).then_some(score) -} - -/// Popup layout rectangles, all in physical pixels for the given `scale`. -pub struct PaletteLayout { - /// Outer panel `(x, y, w, h)`. - pub panel: (f32, f32, f32, f32), - /// Query input box `(x, y, w, h)`. - pub input: (f32, f32, f32, f32), - /// Height of one standard result row. - pub row_h: f32, - /// Top Y of the first result row (or of its section header). - pub list_y: f32, - /// Maximum rows drawn before the list scrolls. - pub max_rows: usize, - /// Keyboard-hint footer for picker modes; absent in the full command list. - pub footer: Option<(f32, f32, f32, f32)>, - /// Per-visible-row rects `(y, h)`; x/w span the panel's inner width. The - /// picker's card geometry is non-uniform (hero card, section gaps), so - /// rendering AND hit-testing must both read row rects from here instead - /// of dividing by `row_h`. - pub rows: Vec<(f32, f32)>, - /// Section caption baselines for the picker: (推荐, 所有选项). - pub headers: (Option, Option), -} - -impl PaletteLayout { - /// Visible-row index under a point, honoring the non-uniform card - /// geometry. Points on section captions or card gaps return `None`. - pub fn row_at(&self, x: f32, y: f32) -> Option { - let (px, _, pw, _) = self.panel; - if x < px || x >= px + pw { - return None; - } - self.rows.iter().position(|&(ry, rh)| y >= ry && y < ry + rh) - } -} - -/// Compute the centered popup layout for a window of `win_w` × `win_h`. The -/// command list keeps a fixed panel height (sized for `max_rows`) so it -/// doesn't jump as the match count changes while typing; pickers shrink to -/// their content. Every palette mode uses the same search-input geometry, -/// keeping rendering, hover and click hit-testing on one contract. -pub fn palette_layout( - model: &CommandPalette, - win_w: f32, - win_h: f32, - scale: f32, -) -> PaletteLayout { - let s = |v: f32| v * scale; - let margin = s(8.0); - let pad = s(12.0); - let row_h = s(super::ui::tokens::control::COMPACT_ROW); - // 搜索框与结果行等高:输入仍然可发现,但不会压过真正的数据内容。 - let input_h = row_h; - let cards = model.is_picker(); - let max_rows = if cards { 10usize } else { 8usize }; - let visible = model.filtered.len().min(max_rows); - let hero = model.hero_row() && visible > 0; - let footer_h = if cards { s(36.0) } else { 0.0 }; - let header_h = s(24.0); - let gap = s(6.0); - let hero_h = s(58.0); - - // List geometry relative to the list's top edge. Pickers lay cards out - // with section captions and gaps (图4); the command list stays a dense - // uniform grid. - let mut rel_rows: Vec<(f32, f32)> = Vec::with_capacity(visible); - let mut rel_headers: (Option, Option) = (None, None); - let mut y = 0.0f32; - if cards { - let slots = visible.max(1); - let mut rest = slots; - if hero { - rel_headers.0 = Some(y); - y += header_h; - rel_rows.push((y, hero_h)); - y += hero_h + s(10.0); - rest = slots - 1; - if rest > 0 { - rel_headers.1 = Some(y); - y += header_h; - } - } - for extra in 0..rest { - if rel_rows.len() < visible { - rel_rows.push((y, row_h)); - } - y += row_h; - if extra + 1 < rest { - y += gap; - } - } - y += s(4.0); - } else { - for _ in 0..max_rows { - if rel_rows.len() < visible { - rel_rows.push((y, row_h)); - } - y += row_h; - } - } - - let pw = s(if cards { 620.0 } else { 640.0 }).min(win_w - 2.0 * margin); - let ph = pad + input_h + s(8.0) + y + if cards { footer_h } else { pad }; - let px = ((win_w - pw) * 0.5).max(margin); - let py = ((win_h - ph) * 0.5).max(s(48.0)); - - let input = (px + pad, py + pad, pw - 2.0 * pad, input_h); - let list_y = py + pad + input_h + s(8.0); - let rows = rel_rows.into_iter().map(|(ry, rh)| (list_y + ry, rh)).collect(); - let headers = - (rel_headers.0.map(|v| list_y + v), rel_headers.1.map(|v| list_y + v)); - - let footer = cards.then_some((px, py + ph - footer_h, pw, footer_h)); - - PaletteLayout { panel: (px, py, pw, ph), input, row_h, list_y, max_rows, footer, rows, headers } -} - -// ---- 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}; - -/// 卡片列的左右内边距,也是**右侧基准线**:输入框的 Ctrl+K 键帽、推荐卡 -/// 的 ↵ chip、路径列、命令行的快捷键 chip、底栏的 Esc 提示——所有右贴边 -/// 元素都从 `ix + iw - s(GUTTER)` 起算,右缘才连成一条竖线。 -/// -/// 2026-07-29:此前这里散着 10/12/14 三个值,底栏还错用了 panel 坐标 -/// (`px + pw - s(16)`,实际只内缩 4px),右侧看着毛糙且随字号漂移。 -const GUTTER: f32 = 14.0; - -/// 标签右缘与右侧信息列之间的最小呼吸缝。挤不下就整列让位,绝不重叠。 -const HINT_GAP: f32 = 24.0; - -/// 文字与相邻 chip(推荐卡的 ↵)之间的缝——比 [`HINT_GAP`] 窄,因为 chip -/// 自带视觉边界,不需要靠空白来分隔。 -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 -/// highlight. No-op while closed. -pub(super) fn push_quads( - model: &CommandPalette, - theme: &NebulaTheme, - quads: &mut Vec, - size: &SizeInfo, - scale: f32, -) { - if !model.is_open() { - return; - } - let w = size.width(); - let h = size.height(); - let s = |v: f32| v * scale; - let sk = theme.skin(); - let layout = palette_layout(model, w, h, scale); - let (ix, iy, iw, ih) = layout.input; - - // 命令面板是 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, - iy, - iw, - ih, - s(super::ui::tokens::radius::CONTROL), - sk.input, - )); - if model.query_all_selected() && !model.query.is_empty() { - let cell_w = size.cell_width(); - let columns: usize = model.query.chars().map(|c| c.width().unwrap_or(0)).sum(); - 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(super::ui::tokens::radius::CHIP), - sk.accent_soft, - )); - } - - let cell_w = size.cell_width(); - // 搜索图标槽与查询文字的起点。槽宽按 **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", - ix + iw - s(GUTTER), - iy + ih / 2.0, - cell_w, - scale, - ); - super::ui::keycap::push_combo(quads, &sk, &combo, scale); - } - - if let Some((fx, fy, fw, fh)) = layout.footer { - quads.push(UiQuad::solid(fx, fy, fw, fh, 0.0, sk.surface)); - quads.push(UiQuad::solid(fx, fy, fw, s(1.0), 0.0, sk.hairline)); - } - - let (visible_rows, selected_row) = model.visible(layout.max_rows); - let cards = model.is_picker(); - let hero = model.hero_row(); - let accent_ring = Rgba::new(sk.accent.r, sk.accent.g, sk.accent.b, 255); - if cards { - // 列表行**不画卡片**(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; - 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); - let cx = ix + iw - s(GUTTER) - chip; - let cy = ry + (rh - chip) / 2.0; - quads.push(UiQuad::solid( - cx - s(1.0), - cy - s(1.0), - chip + s(2.0), - chip + s(2.0), - s(7.0), - sk.hairline, - )); - quads.push(UiQuad::solid(cx, cy, chip, chip, s(6.0), sk.card)); - } - } - } else { - // Hover background: subtle highlight when the mouse is over a row. - if let Some(hover_row) = model.hover { - if let Some(&(ry, rh)) = layout.rows.get(hover_row) { - quads.push(UiQuad::solid( - ix + s(8.0), - ry + s(2.0), - iw - s(16.0), - rh - s(4.0), - s(6.0), - sk.hover, - )); - } - } - // Highlight pill behind the selected row (list scrolls to keep it - // shown). - if let Some(row) = selected_row { - if let Some(&(ry, rh)) = layout.rows.get(row) { - quads.push(UiQuad::solid( - ix + s(8.0), - ry, - iw - s(16.0), - rh - s(4.0), - s(6.0), - sk.accent_soft, - )); - quads.push(UiQuad::solid( - ix + s(8.0), - ry + s(5.0), - s(2.0), - rh - s(14.0), - s(1.0), - accent_ring, - )); - } - } - } - - // The default badge is deliberately a hairline chip, not another bright - // accent pill. It identifies the launch target without competing with the - // selected-row affordance. The hero card 已经用"推荐"分区表达默认身份, - // 不再叠加徽标。 - let text_x = ix + s(14.0); - let badge = model.language.pick("默认", "Default"); - let badge_w = badge.chars().map(|c| c.width().unwrap_or(1)).sum::() as f32 * cell_w - + s(12.0); - for (row, entry) in visible_rows.into_iter().enumerate() { - let Some(&(ry, rh)) = layout.rows.get(row) else { continue }; - // 图8 规范:命令列表右侧的快捷键 hint 画成逐键 chip 底(键名在文 - // 字 pass)。挤不下就整组让位,不压进标签。 - if !cards && !entry.hint.is_empty() { - let combo = super::ui::keycap::layout_combo( - &entry.hint, - ix + iw - s(GUTTER), - ry + rh / 2.0, - cell_w, - scale, - ); - let label_end = text_x - + entry.label.chars().map(|c| c.width().unwrap_or(1)).sum::() as f32 - * cell_w; - if combo.bounds.0 > label_end + s(HINT_GAP) { - super::ui::keycap::push_combo(quads, &sk, &combo, scale); - } - } - if !entry.is_default || (hero && row == 0) { - continue; - } - let label_x = if entry.icon.is_empty() { text_x } else { text_x + s(26.0) }; - let label_w = entry.label.chars().map(|c| c.width().unwrap_or(1)).sum::() as f32 - * cell_w; - let bx = label_x + label_w + s(8.0); - let by = ry + s(7.0); - let bh = rh - s(14.0); - quads.push(UiQuad::solid( - bx - s(1.0), - by - s(1.0), - badge_w + s(2.0), - bh + s(2.0), - s(5.0), - sk.hairline, - )); - quads.push(UiQuad::solid(bx, by, badge_w, bh, s(4.0), sk.surface)); - } - - // 底栏**不画 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, -/// then the result rows with right-aligned shortcut hints. No-op while closed. -/// -/// Returns the full-color brand-icon draw requests (`color_id`, pixel rect) -/// for detected-shell rows: the caller resolves each to a texture and stages -/// it for the post-text image pass (a textured quad can't be interleaved with -/// glyph batches). Rows whose id has no brand asset draw the Nerd Font glyph -/// here and contribute nothing to the returned list. -pub(super) fn draw_text( - model: &CommandPalette, - theme: &NebulaTheme, - r: &mut Renderer, - gc: &mut GlyphCache, - size: &SizeInfo, - scale: f32, -) -> Vec<(String, (f32, f32, f32, f32))> { - let mut icon_draws = Vec::new(); - if !model.is_open() { - return icon_draws; - } - let s = |v: f32| v * scale; - let w = size.width(); - let h = size.height(); - let cell_w = size.cell_width(); - let cell_h = size.cell_height(); - let layout = palette_layout(model, w, h, scale); - let (ix, iy, iw, ih) = layout.input; - - // Inks from the theme skin: dark text on light panels, pale on dark. - let sk = theme.skin(); - - // Left edge for result text and the search icon. - 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); - - // 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(); - - if query.is_empty() { - let placeholder = match model.mode { - PaletteMode::Commands => model - .language - .pick("搜索命令、Shell 或 Profile…", "Search commands, shells or profiles..."), - PaletteMode::Profiles | PaletteMode::DefaultShell => { - model.language.pick("搜索 Shell 或 Profile…", "Search shells or profiles...") - }, - PaletteMode::Directories => { - model.language.pick("搜索常用目录…", "Search frequent directories...") - }, - }; - r.draw_chrome_text(size, query_x, text_y, sk.ink_faint, placeholder, gc); - } else { - r.draw_chrome_text(size, query_x, text_y, sk.ink_strong, query, gc); - } - - if model.is_empty() { - r.draw_chrome_text( - size, - text_x, - layout.list_y + s(8.0), - sk.ink_dim, - match model.mode { - PaletteMode::Directories => { - model.language.pick("没有匹配的已访问目录", "No matching visited directories") - }, - PaletteMode::Commands | PaletteMode::Profiles | PaletteMode::DefaultShell => { - model.language.pick("无匹配命令", "No matching commands") - }, - }, - 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; - } - - // Section captions (图4): 推荐 / 所有选项。 - if let Some(hy) = layout.headers.0 { - r.draw_chrome_text( - size, - text_x, - hy + s(2.0), - sk.ink_dim, - model.language.pick("推荐", "Recommended"), - gc, - ); - } - if let Some(hy) = layout.headers.1 { - r.draw_chrome_text( - size, - text_x, - hy + s(2.0), - sk.ink_dim, - model.language.pick("所有选项", "All options"), - gc, - ); - } - // Ctrl+K 键帽文字(chip 底在 quad pass,几何同源)。 - if model.mode == PaletteMode::Profiles && model.query.is_empty() { - let combo = super::ui::keycap::layout_combo( - "Ctrl+K", - ix + iw - s(GUTTER), - iy + ih / 2.0, - cell_w, - scale, - ); - draw_combo_text(r, gc, size, &combo, cell_w, cell_h, &sk); - } - - 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 }; - let is_hero = hero && row == 0; - // Hero 卡片双行:名称在上、完整路径在下(图4);普通行单行居中。 - let ry = if is_hero { - row_y + s(8.0) - } else { - row_y + (row_hh - cell_h) / 2.0 - s(2.0) - }; - let fg = if Some(row) == selected_row || is_hero { sk.ink_strong } else { sk.ink }; - // Leading icon, then the label indented past it. Detected shells with a - // brand asset stage a full-color textured quad (drawn later); the rest - // fall back to the Nerd Font glyph. Built-in action rows carry an empty - // icon and keep the original left edge. - let has_color = - !color_id.is_empty() && crate::shell_detect::color_icon_png(&color_id).is_some(); - let indent = if is_hero { s(34.0) } else { s(26.0) }; - let label_x = if has_color { - // Square icon sized to the glyph ink, vertically centered on the - // row (the hero card gets a bigger brand mark). - let icon_s = if is_hero { (cell_h * 1.35).round() } else { (cell_h * 0.92).round() }; - let icon_y = (row_y + (row_hh - icon_s) / 2.0).round(); - icon_draws.push((color_id, (text_x, icon_y, icon_s, icon_s))); - text_x + indent - } else if icon.is_empty() { - text_x - } else { - let icon_y = row_y + (row_hh - cell_h) / 2.0; - r.draw_chrome_text(size, text_x, icon_y, sk.accent, &icon, gc); - text_x + indent - }; - r.draw_chrome_text(size, label_x, ry, fg, &label, gc); - let badge_span = if is_default && !is_hero { - r.draw_chrome_text( - size, - label_x + text_width_cols(&label) as f32 * cell_w + s(10.0), - ry, - sk.ink_dim, - badge, - gc, - ); - badge_w(true) - } else { - 0.0 - }; - if is_hero { - // 第二行完整路径(塞不下时尾部省略号),右侧回车 chip glyph。 - let chip = s(28.0); - let chip_x = ix + iw - s(GUTTER) - chip; - let path_y = row_y + row_hh - cell_h - s(8.0); - let budget = ((chip_x - s(CHIP_GAP) - label_x) / cell_w).floor(); - if budget >= 3.0 { - let shown = fit_head(&hint, budget as usize); - r.draw_chrome_text(size, label_x, path_y, sk.ink_dim, &shown, gc); - } - let chip_text_y = row_y + (row_hh - cell_h) / 2.0; - r.draw_chrome_text( - size, - chip_x + (chip - cell_w) / 2.0, - chip_text_y, - sk.accent, - "↵", - gc, - ); - } else if !hint.is_empty() { - if !cards { - // 图8 规范:命令列表快捷键 hint 逐键 chip;与 quad pass 用 - // 同一 layout_combo 几何与让位守卫。 - let combo = super::ui::keycap::layout_combo( - &hint, - ix + iw - s(GUTTER), - row_y + row_hh / 2.0, - cell_w, - scale, - ); - let label_end = ix + s(GUTTER) - + label.chars().map(|c| c.width().unwrap_or(1)).sum::() as f32 - * cell_w; - 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(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 { - draw_footer_hints(r, gc, size, model, ix, iw, fy, fh, cell_w, cell_h, scale, &sk); - } - icon_draws -} - -/// 底栏的三条键位提示。 -/// -/// 键名与释义分成两级墨(`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, - size: &SizeInfo, - combo: &super::ui::keycap::ComboChips, - cell_w: f32, - cell_h: f32, - sk: &super::ui::theme::Skin, -) { - let (_, key_y, _, key_h) = combo.bounds; - let ty = key_y + (key_h - cell_h) / 2.0; - for (chip_x, chip_w, key) in &combo.chips { - let key_cols: usize = key.chars().map(|c| c.width().unwrap_or(0)).sum(); - r.draw_chrome_text( - size, - chip_x + (chip_w - key_cols as f32 * cell_w) / 2.0, - ty, - sk.ink_dim, - key, - gc, - ); - } -} - -fn text_width_cols(text: &str) -> usize { - text.chars().map(|c| c.width().unwrap_or(0)).sum() -} - -/// picker 的路径列左缘:所有行共用一个 x,路径因此排成一条竖线。 -/// -/// `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 -/// ellipsis, matching the mockup's right-aligned path column. -fn fit_head(value: &str, budget: usize) -> String { - let width = |ch: char| ch.width().unwrap_or(0); - let total: usize = value.chars().map(width).sum(); - if total <= budget { - return value.to_owned(); - } - if budget <= 1 { - return "…".to_owned(); - } - let mut out = String::new(); - let mut used = 0usize; - for ch in value.chars() { - let w = width(ch); - if used + w > budget - 1 { - break; - } - out.push(ch); - used += w; - } - out.push('…'); - out -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn empty_query_lists_all_in_declaration_order() { - let palette = CommandPalette::new(); - assert_eq!(palette.filtered.len(), ITEMS.len()); - assert_eq!( - palette.filtered[0], - PaletteCandidate::Item(0), - "first declared item leads by default" - ); - } - - #[test] - fn recent_actions_surface_first_on_empty_query() { - let mut palette = CommandPalette::new(); - palette.record_recent(3); - palette.record_recent(7); - palette.refilter(); - // Most-recent first, then the previous recent, then the declared rest. - assert_eq!(palette.filtered[0], PaletteCandidate::Item(7)); - assert_eq!(palette.filtered[1], PaletteCandidate::Item(3)); - assert_eq!(palette.filtered[2], PaletteCandidate::Item(0)); - } - - #[test] - fn visible_returns_row_struct() { - let mut palette = CommandPalette::new(); - palette.profiles = vec![ProfileRow::Shell { - label: "PowerShell".into(), - hint: "pwsh.exe".into(), - search: "pwsh".into(), - shell: DetectedShell { - name: "PowerShell".into(), - id: "pwsh".into(), - program: "pwsh.exe".into(), - args: vec![], - }, - }]; - // Dynamic shell rows belong to the shell/profile picker. The full - // command palette deliberately keeps static actions first. - palette.open_profiles(); - let (rows, _) = palette.visible(10); - assert!(!rows.is_empty()); - assert_eq!(rows.last().unwrap().label, "PowerShell"); - } - - #[test] - fn record_recent_dedups_and_caps() { - let mut palette = CommandPalette::new(); - for i in 0..(RECENT_MAX + 3) { - palette.record_recent(i); - } - assert_eq!(palette.recent.len(), RECENT_MAX); - // Re-running an existing action moves it to the front without growing. - palette.record_recent(2); - assert_eq!(palette.recent.first(), Some(&2)); - assert_eq!(palette.recent.len(), RECENT_MAX); - } - - #[test] - fn fuzzy_matches_subsequence_and_rejects_the_rest() { - assert!(fuzzy_score("nt", "new tab").is_some()); - assert!(fuzzy_score("newtab", "new tab").is_some()); - assert!(fuzzy_score("xyz", "new tab").is_none()); - assert!(fuzzy_score("", "anything").is_some(), "empty query matches everything"); - } - - #[test] - fn fuzzy_rewards_consecutive_and_word_start() { - // A consecutive run beats the same letters scattered across separators. - let consecutive = fuzzy_score("tab", "xtab").unwrap(); - let scattered = fuzzy_score("tab", "t-a-b").unwrap(); - assert!(consecutive > scattered, "consecutive {consecutive} vs scattered {scattered}"); - // A word-start match beats a mid-word match of the same length. - let word_start = fuzzy_score("t", "x t").unwrap(); - let mid_word = fuzzy_score("t", "xt").unwrap(); - assert!(word_start > mid_word, "word-start {word_start} vs mid-word {mid_word}"); - } - - #[test] - fn confirm_records_recent_and_closes() { - let mut palette = CommandPalette::new(); - palette.open(); - palette.selected = Some(2); - let picked = palette.filtered[2]; - let action = palette.confirm(); - assert!(action.is_some()); - assert!(!palette.is_open()); - let PaletteCandidate::Item(picked) = picked else { panic!("expected static item") }; - assert_eq!(palette.recent.first(), Some(&picked)); - } - - #[test] - fn typing_filters_then_backspace_restores() { - let mut palette = CommandPalette::new(); - palette.open(); - let full = palette.filtered.len(); - for ch in "zqxjk".chars() { - palette.input_char(ch); - } - assert!(palette.filtered.len() < full, "gibberish should filter most out"); - for _ in 0.."zqxjk".len() { - palette.backspace(); - } - assert_eq!(palette.filtered.len(), full, "clearing the query restores the full list"); - } - - #[test] - fn move_selection_wraps_both_ends() { - let mut palette = CommandPalette::new(); - palette.open(); - assert_eq!(palette.selected, None); - palette.move_selection(-1); - assert_eq!( - palette.selected, - Some(palette.filtered.len() - 1), - "up from top wraps to bottom" - ); - palette.move_selection(1); - assert_eq!(palette.selected, Some(0), "down from bottom wraps to top"); - } - - #[test] - fn visible_window_scrolls_to_keep_selection_in_view() { - let mut palette = CommandPalette::new(); - palette.open(); - let max = 5; - // Selection at the top: window starts at 0, selection on row 0. - let (rows, sel) = palette.visible(max); - assert_eq!(rows.len(), max); - assert_eq!(sel, Some(0)); - // Move past the window; the selection pins to the bottom visible row. - for _ in 0..7 { - palette.move_selection(1); - } - assert_eq!(palette.selected, Some(7)); - let (rows, sel) = palette.visible(max); - assert_eq!(rows.len(), max); - assert_eq!(sel, Some(max - 1), "selection pinned to bottom row when scrolled"); - // The bottom visible row is the actually-selected item (field .label). - let PaletteCandidate::Item(index) = palette.filtered[7] else { - panic!("expected static item") - }; - assert_eq!(rows[max - 1].label, ITEMS[index].label); - } - - #[test] - fn profile_modes_keep_their_dismissible_picker_identity() { - let mut palette = CommandPalette::new(); - assert!(!palette.is_picker()); - - palette.open(); - assert!(!palette.is_picker(), "full palette keeps its search focus"); - - palette.open_profiles(); - assert!(palette.is_picker(), "new-tab shell selector dismisses on focus loss"); - - palette.close(); - assert!(!palette.is_picker()); - - palette.open_default_picker(); - assert!(palette.is_picker(), "default-shell selector shares dismissal semantics"); - - palette.set_directories(vec![PathBuf::from("D:/workspace")]); - palette.open_directories(); - assert!(palette.is_picker(), "directory selector shares dismissal semantics"); - } - - #[test] - fn default_shell_confirmation_preserves_picker_mode_until_action_is_built() { - let shell = DetectedShell { - name: "PowerShell".into(), - id: "pwsh".into(), - program: "pwsh.exe".into(), - args: vec![], - }; - let mut palette = CommandPalette::new(); - palette.set_default_shell_menu(std::slice::from_ref(&shell)); - palette.open_default_picker(); - - assert_eq!(palette.confirm(), Some(PaletteAction::SetDefaultShell(shell))); - } - - #[test] - fn directory_picker_returns_path_without_inventing_a_shell_command() { - let path = PathBuf::from("D:/项目 空间"); - let mut palette = CommandPalette::new(); - palette.set_directories(vec![path.clone()]); - palette.open_directories(); - let (rows, selected) = palette.visible(8); - - assert_eq!(selected, None); - assert_eq!(rows[0].hint, path.display().to_string()); - assert_eq!(palette.confirm(), Some(PaletteAction::NewAtDirectory(path))); - } - - #[test] - fn profile_picker_query_filters_rows() { - let mut palette = CommandPalette::new(); - palette.set_shell_menu( - &[], - &["Windows PowerShell".into(), "Git Bash".into()], - "powershell", - ); - palette.open_profiles(); - - palette.input_text("git"); - let (rows, _) = palette.visible(8); - assert_eq!(rows.len(), 1); - assert_eq!(rows[0].label, "Git Bash"); - } - - #[test] - fn search_input_and_result_rows_share_compact_height() { - let palette = CommandPalette::new(); - let layout = palette_layout(&palette, 1600.0, 900.0, 1.5); - assert_eq!(layout.input.3, layout.row_h); - } - - fn shell(name: &str, id: &str, program: &str) -> DetectedShell { - DetectedShell { - name: name.into(), - id: id.into(), - program: program.into(), - args: vec![], - } - } - - #[test] - fn shell_menu_promotes_default_to_hero_row() { - let mut palette = CommandPalette::new(); - palette.set_shell_menu( - &[ - shell("CMD", "cmd", r"C:\Windows\System32\cmd.exe"), - shell("PowerShell", "powershell", r"C:\Windows\...\powershell.exe"), - ], - &[], - "powershell", - ); - palette.open_profiles(); - // 检测顺序把 CMD 排前,但推荐位(首行大卡片)必须是默认 shell。 - assert!(palette.hero_row()); - let (rows, _) = palette.visible(10); - assert_eq!(rows[0].label, "PowerShell"); - assert!(rows[0].is_default); - // 搜索中不分区:hero 退场,回到平铺卡片。 - palette.input_char('c'); - assert!(!palette.hero_row()); - } - - #[test] - fn picker_layout_rows_are_non_uniform_and_hit_test_matches() { - let mut palette = CommandPalette::new(); - palette.set_shell_menu( - &[ - shell("CMD", "cmd", r"C:\Windows\System32\cmd.exe"), - shell("PowerShell", "powershell", r"C:\Windows\...\powershell.exe"), - ], - &[], - "powershell", - ); - palette.open_profiles(); - let layout = palette_layout(&palette, 1600.0, 900.0, 1.0); - assert_eq!(layout.rows.len(), 2); - let (hero_y, hero_h) = layout.rows[0]; - let (row_y, row_h) = layout.rows[1]; - assert!(hero_h > row_h, "推荐大卡片必须比普通卡片高"); - assert!(layout.headers.0.is_some() && layout.headers.1.is_some()); - // 命中测试与逐行矩形一致;卡片之间的缝隙与分区标题不可点。 - let (px, ..) = layout.panel; - assert_eq!(layout.row_at(px + 10.0, hero_y + hero_h / 2.0), Some(0)); - assert_eq!(layout.row_at(px + 10.0, row_y + row_h / 2.0), Some(1)); - assert_eq!(layout.row_at(px + 10.0, hero_y - 2.0), None, "分区标题不是行"); - assert_eq!(layout.row_at(px - 20.0, hero_y + 2.0), None, "面板外不命中"); - } - - #[test] - fn command_layout_rows_stay_uniform() { - let mut palette = CommandPalette::new(); - palette.open(); - let layout = palette_layout(&palette, 1600.0, 900.0, 1.0); - assert_eq!(layout.rows.len(), layout.max_rows.min(palette.filtered.len())); - for pair in layout.rows.windows(2) { - assert_eq!(pair[0].1, pair[1].1, "命令列表保持等高行"); - assert_eq!(pair[1].0 - pair[0].0, pair[0].1, "无缝隙"); - } - } - - #[test] - fn path_column_clears_the_widest_label() { - // 列位置由最宽的一行决定,其余行因此不可能与自己的标签冲突。 - let (ix, iw, cell_w, scale) = (960.0, 770.0, 14.0, 1.25); - 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 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!(cols <= 10, "按显示列宽裁剪,实测 {cols} 列"); - } - - #[test] - fn select_all_copy_and_paste_replace_the_query() { - let mut palette = CommandPalette::new(); - palette.open(); - palette.input_text("old query"); - palette.select_all(); - assert_eq!(palette.selected_text().as_deref(), Some("old query")); - palette.input_text("new\r\nquery"); - assert_eq!(palette.query(), "newquery"); - assert!(!palette.query_all_selected()); - } -} +//! Command palette (`Ctrl+Shift+P`): a fuzzy-searchable launcher for every +//! Nebula action — the discoverable entry point for features whose shortcuts +//! are hard to remember. +//! +//! This module owns only the *model*: the action list, the query/selection +//! state, fuzzy filtering, and the popup layout maths. Rendering lives in +//! `display::mod` (it mirrors the settings modal), and execution is dispatched +//! by the input layer, which is the only place that can reach both the display +//! and the window context. Keeping the model here makes it self-contained and +//! keeps the giant `mod.rs` free of the item table. + +use std::path::PathBuf; + +use super::{NebulaTheme, SizeInfo}; +use crate::shell_detect::DetectedShell; +use unicode_width::UnicodeWidthChar; + +/// A dynamic quick-launch row: a config profile (launched by index) or a +/// detected shell (spec carried inline). Built fresh on every menu open. +#[derive(Debug, Clone)] +enum ProfileRow { + /// Config profile at this index — routed through `TabRequest::NewProfile`. + Config { label: String, search: String, index: usize }, + /// Detected shell — routed through `TabRequest::NewShell`. `hint` is the + /// program path, shown dimmed (Windows Terminal's profile menu layout). + Shell { label: String, hint: String, search: String, shell: DetectedShell }, +} + +impl ProfileRow { + fn label(&self) -> &str { + match self { + Self::Config { label, .. } | Self::Shell { label, .. } => label, + } + } + + fn hint(&self) -> &str { + match self { + Self::Config { .. } => "", + Self::Shell { hint, .. } => hint, + } + } + + fn search(&self) -> &str { + match self { + Self::Config { search, .. } | Self::Shell { search, .. } => search, + } + } + + /// Leading Nerd Font glyph. Detected shells carry their own; config + /// profiles get a generic launch mark. + fn icon(&self) -> &'static str { + match self { + Self::Shell { shell, .. } => shell.icon(), + Self::Config { .. } => "\u{ea60}", + } + } + + /// Stable shell id for the full-color brand icon lookup, or `""` for + /// config profiles (which have no brand asset and keep their glyph). + fn color_id(&self) -> &str { + match self { + Self::Shell { shell, .. } => &shell.id, + Self::Config { .. } => "", + } + } +} + +/// 命令面板只负责展示目录;候选的匹配与 frecency 排序由共享目录服务完成, +/// 避免 UI 再维护一套会逐渐分叉的目录搜索规则。 +#[derive(Debug, Clone)] +struct DirectoryRow { + path: PathBuf, + label: String, + hint: String, +} + +impl DirectoryRow { + fn new(path: PathBuf) -> Self { + let label = path + .file_name() + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| path.as_os_str()) + .to_string_lossy() + .into_owned(); + let hint = path.display().to_string(); + Self { path, label, hint } + } +} + +/// 「恢复 AI 会话」模式的一行:标题 + 右侧「位置 · 相对时间」+ 确认时敲进 +/// 终端的 resume 命令行。由 `Display` 用 `ai_sessions::scan` 的结果构造。 +pub struct AiSessionRow { + pub label: String, + pub hint: String, + pub search: String, + pub command: String, + /// claude / codex——决定行首的品牌 logo 与右缘的来源 chip。 + pub source: crate::ai_sessions::AiSessionSource, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum PaletteCandidate { + Item(usize), + Profile(usize), + Directory(usize), + AiSession(usize), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PaletteMode { + Commands, + Profiles, + DefaultShell, + Directories, + AiSessions, +} + +/// A single executable action reachable from the command palette. +/// +/// Deliberately flat so the input layer can match on it after the palette +/// closes, without holding any borrow. Each variant maps onto either a +/// `TabRequest` (tab / split / window operations) or a `Display` method +/// (theme / settings / appearance) — see `keyboard.rs::run_palette_action`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PaletteAction { + NewTab, + /// 复制聚焦 pane 的工作目录到剪贴板。 + CopyCwd, + /// 在系统文件管理器里打开工作目录。 + RevealCwd, + /// 折叠 / 展开左侧标签侧栏。 + ToggleSidebar, + /// 设置·交互「拖拽调节侧栏宽度」开关(关→开要过确认框)。 + TogglePanelResize, + /// Open the frecency-ranked directory picker; this is a UI workflow, not a + /// shell-specific command or alias. + OpenDirectoryPicker, + /// 打开「恢复 AI 会话」列表(claude / codex 的本地历史会话)。 + OpenAiSessionPicker, + /// 把这条 resume 命令行敲进当前聚焦的终端执行。 + ResumeAiSession(String), + CloseTab, + NextTab, + PrevTab, + NewWindow, + SplitRight, + SplitDown, + OpenSettings, + OpenSettingsFile, + ToggleGhost, + CycleAccept, + PickBackgroundImage, + CycleBackground, + ResetAppearance, + SelectTheme(NebulaTheme), + /// Launch the quick-launch profile at this config index in a new tab. + LaunchProfile(usize), + /// Launch a detected shell (the new-tab dropdown) in a new tab. + LaunchShell(DetectedShell), + /// Set a detected shell as the default (the settings "默认 Shell" picker). + SetDefaultShell(DetectedShell), + /// Open a local terminal whose PTY starts directly in this directory. + NewAtDirectory(PathBuf), + ToggleFilesPanel, + ToggleGitPanel, + /// Save every terminal tab as a workspace file. + ExportWorkspace, + /// Pick a workspace file and append its tabs to this window. + ImportWorkspace, + /// WebDAV 同步(spec 003):推送本机设置到远端。 + SyncPush, + /// WebDAV 同步:拉取远端设置并合并到本机。 + SyncPull, +} + +/// One palette row. +/// +/// * `label` — localized text shown on the left. +/// * `hint` — optional shortcut / aux text, dimmed and right-aligned (ASCII, +/// so its on-screen width equals its `char` count). +/// * `search` — the haystack matched against the query. Includes the label plus +/// latin aliases (pinyin / English) so the palette is reachable even when the +/// IME can't feed CJK into it. +/// * `action` — what to run on confirm. +struct PaletteItem { + label: &'static str, + hint: &'static str, + search: &'static str, + action: PaletteAction, +} + +/// The full action table, in declaration order (also the tie-break order when +/// fuzzy scores are equal, and the order shown for an empty query). +const ITEMS: &[PaletteItem] = &[ + PaletteItem { + label: "新建标签页", + hint: "Ctrl+Shift+T", + search: "新建标签页 new tab xinjian biaoqianye", + action: PaletteAction::NewTab, + }, + PaletteItem { + label: "复制路径", + hint: "", + search: "复制路径 copy path cwd working directory fuzhi lujing gongzuo mulu", + action: PaletteAction::CopyCwd, + }, + PaletteItem { + label: "在资源管理器中显示", + hint: "", + search: "在资源管理器中显示 reveal open explorer file manager folder ziyuan guanliqi \ + wenjianjia", + action: PaletteAction::RevealCwd, + }, + PaletteItem { + label: "在常用目录中新建终端…", + hint: "", + search: "在常用目录中新建终端 new terminal in frequent directory changyong mulu", + action: PaletteAction::OpenDirectoryPicker, + }, + PaletteItem { + // 母命令:一个入口通向所有「名词」(AI 会话 / 目录 / SSH / 标签页), + // 而不是每加一个品类就往命令表里塞一条子命令——那样这张表迟早会被 + // 名词淹掉。目前只列 AI 会话,其余品类随面板打磨一起接进来。 + label: "快速跳转…", + hint: "", + search: "快速跳转 恢复 AI 会话 open quickly jump resume ai session claude codex kuaisu tiaozhuan huifu", + action: PaletteAction::OpenAiSessionPicker, + }, + PaletteItem { + label: "关闭标签页", + hint: "Ctrl+Shift+W", + search: "关闭标签页 close tab guanbi", + action: PaletteAction::CloseTab, + }, + PaletteItem { + label: "下一个标签页", + hint: "Ctrl+Tab", + search: "下一个标签页 next tab xiayige", + action: PaletteAction::NextTab, + }, + PaletteItem { + label: "上一个标签页", + hint: "Ctrl+Shift+Tab", + search: "上一个标签页 previous prev tab shangyige", + action: PaletteAction::PrevTab, + }, + PaletteItem { + label: "新建窗口", + hint: "Ctrl+Shift+E", + search: "新建窗口 new window xinjian chuangkou", + action: PaletteAction::NewWindow, + }, + PaletteItem { + label: "左右分屏", + hint: "Ctrl+Shift+D", + search: "左右分屏 split right vertical zuoyou fenping", + action: PaletteAction::SplitRight, + }, + PaletteItem { + label: "上下分屏", + hint: "Ctrl+Shift+S", + search: "上下分屏 split down horizontal shangxia fenping", + action: PaletteAction::SplitDown, + }, + PaletteItem { + label: "导出工作区…", + hint: "", + search: "导出工作区 export workspace save session daochu gongzuoqu", + action: PaletteAction::ExportWorkspace, + }, + PaletteItem { + label: "打开工作区…", + hint: "", + search: "打开工作区 open import workspace load session dakai daoru gongzuoqu", + action: PaletteAction::ImportWorkspace, + }, + PaletteItem { + label: "目录树面板", + hint: "Ctrl+Shift+O", + search: "目录树面板 files tree explorer panel mulushu wenjian", + action: PaletteAction::ToggleFilesPanel, + }, + PaletteItem { + label: "显示标签侧栏", + hint: "", + search: "显示标签侧栏 toggle show hide tab sidebar xianshi biaoqian celan", + action: PaletteAction::ToggleSidebar, + }, + PaletteItem { + label: "拖拽调节侧栏宽度", + hint: "", + search: "拖拽调节侧栏宽度 drag resize sidebar drawer panel width tuozhuai tiaojie kuandu", + action: PaletteAction::TogglePanelResize, + }, + PaletteItem { + label: "Git 面板", + hint: "Ctrl+Shift+G", + search: "git 面板 status branch panel mianban", + action: PaletteAction::ToggleGitPanel, + }, + PaletteItem { + label: "打开设置", + hint: "", + search: "打开设置 open settings preferences dakai shezhi", + action: PaletteAction::OpenSettings, + }, + PaletteItem { + label: "打开配置文件", + hint: "", + search: "打开配置文件 open config file dakai peizhi wenjian", + action: PaletteAction::OpenSettingsFile, + }, + PaletteItem { + label: "同步:推送设置到云端", + hint: "", + search: "同步推送设置到云端 webdav sync push upload settings tongbu tuisong", + action: PaletteAction::SyncPush, + }, + PaletteItem { + label: "同步:从云端拉取设置", + hint: "", + search: "同步从云端拉取设置 webdav sync pull download settings tongbu laqu", + action: PaletteAction::SyncPull, + }, + PaletteItem { + label: "切换行内补全 (Ghost)", + hint: "", + search: "切换行内补全 toggle ghost completion qiehuan buquan", + action: PaletteAction::ToggleGhost, + }, + PaletteItem { + label: "切换补全接受键", + hint: "", + search: "切换补全接受键 cycle accept key completion jieshou", + action: PaletteAction::CycleAccept, + }, + PaletteItem { + label: "选择背景图片…", + hint: "", + search: "选择背景图片 background image picture xuanze beijing tupian", + action: PaletteAction::PickBackgroundImage, + }, + PaletteItem { + label: "切换背景色", + hint: "", + search: "切换背景色 cycle background color qiehuan beijingse", + action: PaletteAction::CycleBackground, + }, + PaletteItem { + label: "恢复外观默认", + hint: "", + search: "恢复外观默认 reset appearance default huifu waiguan moren", + action: PaletteAction::ResetAppearance, + }, + PaletteItem { + label: "主题:Nebula", + hint: "", + search: "主题 nebula theme zhuti", + action: PaletteAction::SelectTheme(NebulaTheme::Nebula), + }, + PaletteItem { + label: "主题:Silver Light", + hint: "", + search: "主题 silver light theme zhuti", + action: PaletteAction::SelectTheme(NebulaTheme::SilverLight), + }, + PaletteItem { + label: "主题:Steel Dark", + hint: "", + search: "主题 steel dark theme zhuti", + action: PaletteAction::SelectTheme(NebulaTheme::SteelDark), + }, + PaletteItem { + label: "主题:Limestone", + hint: "", + search: "主题 limestone light theme zhuti", + action: PaletteAction::SelectTheme(NebulaTheme::LimestoneLight), + }, + PaletteItem { + label: "主题:Coal Dark", + hint: "", + search: "主题 coal dark theme zhuti", + action: PaletteAction::SelectTheme(NebulaTheme::CoalDark), + }, + PaletteItem { + label: "主题:Linen Light", + hint: "", + search: "主题 linen light theme zhuti", + action: PaletteAction::SelectTheme(NebulaTheme::LinenLight), + }, + PaletteItem { + label: "主题:Moss Dark", + hint: "", + search: "主题 moss dark theme zhuti", + action: PaletteAction::SelectTheme(NebulaTheme::MossDark), + }, +]; + +/// How many recently-run actions are remembered for the empty-query ordering. +const RECENT_MAX: usize = 6; + +/// 「工作目录」表头右缘那段路径的列宽上限。超出从头部省略——超过这个宽度 +/// 后路径就开始跟表头横线抢地方,横线一短,分组的视觉边界就散了。 +const CWD_CONTEXT_COLS: usize = 40; + +/// 命令列表左侧勾选态列的宽度。与带图标行的缩进(`s(26.0)`)取同一个值: +/// shell 行画品牌标、命令行画 ✓ 或留空,三种行的标签因此落在同一个 x 上。 +const CHECK_COL_W: f32 = 26.0; + +/// Command palette UI + filtering state, embedded in `Display`. +pub struct CommandPalette { + language: super::UiLanguage, + open: bool, + query: String, + query_selection: super::text_input::SelectAllState, + /// 已排序的可见候选。显式区分类型,避免动态列表长度变化后用“偏移量索引” + /// 把目录误解释成 Profile 或静态命令。 + filtered: Vec, + /// Selected row *within `filtered`*. `None` when nothing is selected yet + /// (initial state — keyboard nav or hover will activate selection). + selected: Option, + /// Recently-run `ITEMS` indices, most-recent first (deduped, capped at + /// `RECENT_MAX`). Lifts frequent actions to the top of an empty query. + /// Static items only: profile indices shift whenever the config changes. + recent: Vec, + /// Dynamic quick-launch rows, refreshed on every open so live config + /// reloads and shell (re)detection are picked up. In profiles-only (the + /// new-tab dropdown) these are detected shells + config profiles; in the + /// full palette they're the config profiles appended after the actions. + profiles: Vec, + /// Stable id of the default shell, used by the shell/profile picker badge. + default_shell_id: Option, + /// Frecency-ranked directory rows supplied by `DirectoryHistory`. + directories: Vec, + /// 「恢复 AI 会话」的行,打开时由 `ai_sessions::scan` 现扫——历史会话 + /// 随时在长,缓存一份很快就是旧账。 + ai_sessions: Vec, + mode: PaletteMode, + /// Mouse-hovered row within the visible window (`None` when not hovering). + hover: Option, + /// 打开时武装:指针从首次上报位置真正移动(>2px)前不点亮 hover。 + /// 「+」的下拉紧贴按钮弹出,首行往往恰在指针正下方——立即点亮会被 + /// 读成「PowerShell 被默认选中」(2026-07-28 用户反馈:全部待选)。 + hover_armed: bool, + /// 武装期间首次上报的指针位置(解除武装的位移基准)。 + pointer_baseline: Option<(f32, f32)>, + /// 打开这一刻对窗口状态的取样(见 [`PaletteContext`])。 + context: PaletteContext, +} + +/// 命令面板打开时对窗口状态的一次取样。 +/// +/// 面板是**模态**的:打开期间用户碰不到侧栏、也换不了目录,所以这些值取样 +/// 一次就够。让面板每帧回读 `Display` 会把面板的渲染路径和窗口状态绑在 +/// 一起——命令行为随后台事件(cwd 上报)在面板打开期间跳变,是比省一次 +/// 拷贝更贵的问题。 +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct PaletteContext { + /// 聚焦 pane 的工作目录。`None` = shell 没上报:此时整个「工作目录」 + /// 组不出现,而不是留一组作用在空路径上的死命令。 + pub cwd: Option, + /// 左侧标签侧栏是否展开(勾选态 ✓)。 + pub sidebar: bool, + /// 「拖拽调节侧栏宽度」是否已开(勾选态 ✓)。 + pub panel_resize: bool, + /// 新建标签页会不会真的落在 `cwd` 里。配了启动目录时不会——`spawn_tab` + /// 那边启动目录优先,此时这条命令必须留在「标签页」组,否则组标题就在 + /// 说谎(用户读到「工作目录 · D:\…」,敲下去却开在别处)。 + pub new_tab_inherits_cwd: bool, +} + +impl CommandPalette { + pub fn new() -> Self { + let mut palette = Self { + language: super::UiLanguage::ZhCn, + open: false, + query: String::new(), + query_selection: Default::default(), + filtered: Vec::new(), + selected: None, // No selection until user navigates + recent: Vec::new(), + profiles: Vec::new(), + default_shell_id: None, + directories: Vec::new(), + ai_sessions: Vec::new(), + mode: PaletteMode::Commands, + hover: None, + hover_armed: false, + pointer_baseline: None, + context: PaletteContext::default(), + }; + palette.refilter(); + palette + } + + pub fn is_open(&self) -> bool { + self.open + } + + pub fn set_language(&mut self, language: super::UiLanguage) { + self.language = language; + self.refilter(); + } + + /// 灌入打开这一刻的窗口状态。cwd 决定「工作目录」组在不在,所以变了 + /// 就得重新过滤——不然 shell 刚上报 cwd 时列表还是旧的那几行。 + pub fn set_context(&mut self, context: PaletteContext) { + if self.context == context { + return; + } + self.context = context; + self.refilter(); + } + + /// Whether the palette is in default-shell picking mode. + pub fn is_picking_default(&self) -> bool { + self.mode == PaletteMode::DefaultShell + } + + /// Shell/profile selector mode. This identity is used only for natural + /// dismissal on window focus loss; the selector remains searchable because + /// a long shell/profile list otherwise becomes needlessly hard to scan. + pub fn is_picker(&self) -> bool { + self.open && self.mode != PaletteMode::Commands + } + + /// 命令列表左侧那条**勾选态列**在不在。只有命令面板有开关类命令; + /// picker 的左列是类型图标 / 品牌标,两者不能共用同一列。 + pub fn has_check_column(&self) -> bool { + self.open && self.mode == PaletteMode::Commands + } + + pub fn is_picking_directory(&self) -> bool { + self.open && self.mode == PaletteMode::Directories + } + + /// 图4 版式:shell picker 空查询时,首行(默认 shell)渲染为"推荐" + /// 大卡片,其余行归入"所有选项"。搜索中或无默认时退回平铺卡片列表。 + pub fn hero_row(&self) -> bool { + self.mode == PaletteMode::Profiles + && self.query.is_empty() + && matches!( + self.filtered.first(), + Some(&PaletteCandidate::Profile(index)) if matches!( + &self.profiles[index], + ProfileRow::Shell { shell, .. } + if self.default_shell_id.as_deref() == Some(shell.id.as_str()) + ) + ) + } + + /// 打开「恢复 AI 会话」列表。行已按最近活动排好序,这里不再重排。 + pub fn open_ai_sessions(&mut self, rows: Vec) { + self.ai_sessions = rows; + self.open = true; + self.mode = PaletteMode::AiSessions; + self.query.clear(); + self.query_selection = Default::default(); + self.hover = None; + self.hover_armed = false; + self.pointer_baseline = None; + self.refilter(); + } + + /// Refresh the dynamic quick-launch rows from the config's profile names. + /// Called by the full-palette open path so a reloaded config is reflected. + pub fn set_profiles(&mut self, names: &[String]) { + self.profiles = names + .iter() + .enumerate() + .map(|(index, name)| ProfileRow::Config { + // The label carries a glyph-free prefix so profile rows read + // distinctly from built-in actions; the haystack adds latin + // aliases (matching the static items' convention). + label: format!("{}:{name}", self.language.pick("启动", "Launch")), + search: format!("启动 {name} profile launch connect qidong"), + index, + }) + .collect(); + } + + /// Populate the new-tab dropdown: detected shells first (installed-shell + /// order), then config profiles. The label carries no verb prefix here — + /// this menu IS the shell picker, so bare names read cleaner. + pub fn set_shell_menu( + &mut self, + shells: &[DetectedShell], + profiles: &[String], + default_shell_id: &str, + ) { + let mut rows: Vec = shells + .iter() + .map(|shell| ProfileRow::Shell { + label: shell.name.clone(), + hint: shell.program.clone(), + search: format!("{} {} shell profile", shell.name, shell.id), + shell: shell.clone(), + }) + .collect(); + rows.extend(profiles.iter().enumerate().map(|(index, name)| ProfileRow::Config { + label: name.clone(), + search: format!("{name} profile launch connect qidong"), + index, + })); + // 图4 版式:默认 shell 是"推荐"大卡片,必须占首行 —— Enter 直接 + // 打开推荐项,检测顺序不再决定谁排第一。 + if let Some(position) = rows.iter().position( + |row| matches!(row, ProfileRow::Shell { shell, .. } if shell.id == default_shell_id), + ) { + let default_row = rows.remove(position); + rows.insert(0, default_row); + } + self.profiles = rows; + self.default_shell_id = Some(default_shell_id.to_owned()); + } + + /// Populate the settings "默认 Shell" picker: detected shells only (no + /// config profiles — you can't default to an ssh jump), and confirming + /// sets the default instead of launching. + pub fn set_default_shell_menu(&mut self, shells: &[DetectedShell]) { + self.profiles = shells + .iter() + .map(|shell| ProfileRow::Shell { + label: shell.name.clone(), + hint: shell.program.clone(), + search: format!("{} {} shell profile", shell.name, shell.id), + shell: shell.clone(), + }) + .collect(); + self.default_shell_id = None; + } + + pub fn set_directories(&mut self, paths: Vec) { + self.directories = paths.into_iter().map(DirectoryRow::new).collect(); + if self.mode == PaletteMode::Directories { + self.refilter(); + } + } + + /// Open (or re-open) the palette with a cleared query and the full list. + pub fn open(&mut self) { + self.arm_pointer_hover(); + self.open = true; + self.mode = PaletteMode::Commands; + self.query.clear(); + self.query_selection.clear(); + self.refilter(); + } + + /// Open showing only the quick-launch profiles (the "+" dropdown). + pub fn open_profiles(&mut self) { + self.arm_pointer_hover(); + self.open = true; + self.mode = PaletteMode::Profiles; + self.query.clear(); + self.query_selection.clear(); + self.refilter(); + } + + /// Open the default-shell picker (settings row): profile rows only, and + /// confirm SETS the default rather than launching. + pub fn open_default_picker(&mut self) { + self.arm_pointer_hover(); + self.open = true; + self.mode = PaletteMode::DefaultShell; + self.query.clear(); + self.query_selection.clear(); + self.refilter(); + } + + /// Open the generic directory picker. Search results are refreshed by + /// `Display` after every query edit so ranking remains owned by one service. + pub fn open_directories(&mut self) { + self.arm_pointer_hover(); + self.open = true; + self.mode = PaletteMode::Directories; + self.query.clear(); + self.query_selection.clear(); + self.refilter(); + } + + pub fn close(&mut self) { + self.open = false; + self.mode = PaletteMode::Commands; + self.hover = None; + self.query_selection.clear(); + self.default_shell_id = None; + } + + /// 指针驱动的 hover 更新(武装门在此):打开后指针必须从首次上报 + /// 位置移动超过 2px 才开始点亮;解除一次后恢复普通 hover 跟随。 + pub fn pointer_hover(&mut self, pos: (f32, f32), row: Option) -> bool { + if self.hover_armed { + match self.pointer_baseline { + None => { + self.pointer_baseline = Some(pos); + return self.set_hover(None); + }, + Some(base) if (base.0 - pos.0).abs() < 2.0 && (base.1 - pos.1).abs() < 2.0 => { + return self.set_hover(None); + }, + Some(_) => { + self.hover_armed = false; + self.pointer_baseline = None; + }, + } + } + self.set_hover(row) + } + + /// 每次打开(任何模式)都重新武装 hover。 + fn arm_pointer_hover(&mut self) { + self.hover_armed = true; + self.pointer_baseline = None; + self.hover = None; + } + + /// Update hover based on mouse position. `row` is the index within the + /// visible window (`0..max_rows`), or `None` when the mouse left. Returns + /// whether the hover changed, so the caller only redraws on transitions. + pub fn set_hover(&mut self, row: Option) -> bool { + if self.hover == row { + return false; + } + self.hover = row; + true + } + + /// 光标该不该画。相位来自 [`ui::caret`](super::ui::caret) 的共享节律, + /// 与标签重命名框、SSH 编辑器、侧栏搜索框完全一致——打字时常亮、 + /// 停手后按系统的 `GetCaretBlinkTime` 呼吸。 + /// + /// 这里曾经自带一个 1060ms、占空比 0.5 的 `Pulse`。它的问题不在参数, + /// 而在于**每个输入框各造一套节律**:同一个窗口里两个光标以不同相位 + /// 闪烁,读起来像界面有两个焦点。 + pub fn cursor_visible(&self) -> bool { + super::ui::caret::is_on() + } + + pub fn toggle(&mut self) { + if self.open { + self.close(); + } else { + self.open(); + } + } + + /// Append a typed character (control chars ignored) and re-filter. + pub fn input_char(&mut self, c: char) { + if c.is_control() { + return; + } + let mut encoded = [0u8; 4]; + self.query_selection.insert(&mut self.query, c.encode_utf8(&mut encoded)); + self.refilter(); + } + + pub fn input_text(&mut self, text: &str) { + self.query_selection.insert(&mut self.query, text); + self.refilter(); + } + + pub fn backspace(&mut self) { + self.query_selection.backspace(&mut self.query); + self.refilter(); + } + + pub fn select_all(&mut self) { + self.query_selection.select(&self.query); + } + + pub fn selected_text(&self) -> Option { + self.query_selection.selected_text(&self.query) + } + + pub fn query_all_selected(&self) -> bool { + self.query_selection.is_selected() + } + + /// Move the selection by `delta` rows, wrapping at both ends. + pub fn move_selection(&mut self, delta: i32) { + if self.filtered.is_empty() { + return; + } + let len = self.filtered.len() as i32; + // Initialize selection on first navigation + let current = self.selected.unwrap_or(0) as i32; + self.selected = Some(((current + delta).rem_euclid(len)) as usize); + } + + /// Confirm the current selection: records it as recent, closes the palette, + /// and returns the action to run, or `None` when nothing matches. + pub fn confirm(&mut self) -> Option { + // Enter executes the first row before arrow navigation. The full + // command palette also paints that row selected; lightweight pickers + // start visually neutral per稿二 but keep this efficient keyboard path. + let selected = self.selected.unwrap_or(0); + let candidate = *self.filtered.get(selected)?; + // 必须在 close() 清空模式之前计算动作;旧实现先关闭再判断 + // picking_default,导致“设置默认 Shell”被错误执行成“启动 Shell”。 + let action = match candidate { + PaletteCandidate::Item(index) => ITEMS[index].action.clone(), + PaletteCandidate::Profile(profile) => match &self.profiles[profile] { + ProfileRow::Config { index, .. } => PaletteAction::LaunchProfile(*index), + ProfileRow::Shell { shell, .. } if self.mode == PaletteMode::DefaultShell => { + PaletteAction::SetDefaultShell(shell.clone()) + }, + ProfileRow::Shell { shell, .. } => PaletteAction::LaunchShell(shell.clone()), + }, + PaletteCandidate::Directory(directory) => { + PaletteAction::NewAtDirectory(self.directories[directory].path.clone()) + }, + PaletteCandidate::AiSession(session) => { + PaletteAction::ResumeAiSession(self.ai_sessions[session].command.clone()) + }, + }; + self.close(); + if let PaletteCandidate::Item(index) = candidate { + self.record_recent(index); + } + Some(action) + } + + /// Confirm the visible row at `row` (0 = topmost visible line, mirroring + /// [`Self::visible`]'s scroll window) — the mouse-click path. + /// 滚动窗口的首行下标。选中行越过最后一行时窗口整体下移,让它停在底行。 + /// + /// 行、表头、点击换算**必须**共用这一个算式。此前它被抄了两份(`visible` + /// 与 `click` 各一份)而表头那份压根没算,于是列表一滚动,行走了、表头 + /// 还停在开头那套分组上——分组标题指着一批已经滚走的行。 + fn scroll_start(&self, max_rows: usize) -> usize { + match self.selected { + Some(selected) if max_rows > 0 => selected.saturating_sub(max_rows - 1), + _ => 0, + } + } + + pub fn click(&mut self, row: usize, max_rows: usize) -> Option { + if self.filtered.is_empty() || max_rows == 0 { + return None; + } + let filtered_index = self.scroll_start(max_rows) + row; + if filtered_index >= self.filtered.len() { + return None; + } + self.selected = Some(filtered_index); + self.confirm() + } + + /// Remember `idx` as the most-recently run command (deduped, capped), so a + /// freshly-opened (empty-query) palette lists frequent actions first. + fn record_recent(&mut self, idx: usize) { + self.recent.retain(|&i| i != idx); + self.recent.insert(0, idx); + self.recent.truncate(RECENT_MAX); + } + + /// Re-score every item against the query and rebuild `filtered`. With a + /// query: fuzzy score, best first, ties in declaration order. Empty query: + /// recently-run first, then declaration order (a stable sort keeps the + /// declared order for the un-recent tail), then profiles. Resets the + /// selection to the top. + /// 每个可见行前面要不要起一条分组表头:`Some((标签, 右缘上下文))` = 这 + /// 一行是新一组的第一行。上下文为空串表示这一组不挂东西;「工作目录」 + /// 组挂当前 cwd——那一组的动作全作用在它身上,路径写在标题上一次,好过 + /// 每行重复一遍。返回 `None` 表示这个模式不分组(shell 选择器…)。 + /// + /// 参数是 `max_rows` 而不是可见行数:表头必须落在**滚动之后**的那个窗口 + /// 上(见 [`Self::scroll_start`]),拿行数算不出窗口从哪儿开始。窗口首行 + /// 永远起一条表头(`previous` 从 `None` 开始)——组标题滚走之后,列表顶 + /// 上仍然写着「你在哪一组」。 + /// + /// 分组的前提是同组的行**连续**,那件事由 [`Self::refilter`] 末尾的稳定 + /// 排序保证。 + fn group_labels(&self, max_rows: usize) -> Option>> { + let start = self.scroll_start(max_rows); + let window: Vec = + self.filtered.iter().skip(start).take(max_rows).copied().collect(); + if window.is_empty() { + return None; + } + match self.mode { + PaletteMode::AiSessions => { + let mut labels = Vec::with_capacity(window.len()); + let mut previous = None; + for candidate in &window { + let PaletteCandidate::AiSession(index) = candidate else { + labels.push(None); + continue; + }; + let source = self.ai_sessions[*index].source; + labels.push( + (previous != Some(source)) + .then(|| (source_group_label(source), String::new())), + ); + previous = Some(source); + } + Some(labels) + }, + PaletteMode::Commands => { + let mut labels = Vec::with_capacity(window.len()); + let mut previous = None; + for candidate in &window { + let group = self.command_group(*candidate); + labels.push((previous != Some(group)).then(|| { + let context = if group == CommandGroup::Cwd { + self.cwd_context() + } else { + String::new() + }; + (group.label(self.language), context) + })); + previous = Some(group); + } + Some(labels) + }, + _ => None, + } + } + + /// 「工作目录」表头右缘的路径。原样显示(技术值不美化),过长时砍掉 + /// **头部**——路径的辨识信息在尾部(项目名 / 子目录),砍尾等于把整行 + /// 变成一串没用的盘符。 + fn cwd_context(&self) -> String { + let Some(cwd) = self.context.cwd.as_ref() else { return String::new() }; + fit_tail(&cwd.display().to_string(), CWD_CONTEXT_COLS) + } + + /// 一条候选属于哪个分组。动态的 shell / profile 行自成一组。 + fn command_group(&self, candidate: PaletteCandidate) -> CommandGroup { + let PaletteCandidate::Item(index) = candidate else { return CommandGroup::Shells }; + let group = CommandGroup::of(&ITEMS[index].action); + // 工作目录组的成立条件逐条命令判定:cwd 未知时整组不存在;「新建 + // 标签页」还要额外满足**它确实会开在那里**(见 `new_tab_inherits_cwd`)。 + // 不满足就退回标签页组——留一条孤儿表头,或者让组标题指着一个命令 + // 并不会去的目录,都比少一行糟。 + if group == CommandGroup::Cwd { + let inherits = + ITEMS[index].action != PaletteAction::NewTab || self.context.new_tab_inherits_cwd; + if self.context.cwd.is_none() || !inherits { + return CommandGroup::Tabs; + } + } + group + } + + /// 让同一来源的会话连续排列。排在前面的是**拥有最新一条会话的那个来源** + /// ——既满足了分组表头「同组成块」的前提,又不至于把「我刚才在做的那件 + /// 事」压到第二组去。组内顺序原样保留(空查询=最近优先,有查询=模糊得分 + /// 优先),所以这里只做一次稳定划分,不重新排序。 + fn group_ai_sessions_by_source(&mut self) { + let lead = match self.filtered.first() { + Some(PaletteCandidate::AiSession(index)) => self.ai_sessions[*index].source, + _ => return, + }; + let rows = std::mem::take(&mut self.filtered); + let (mut head, tail): (Vec<_>, Vec<_>) = rows.into_iter().partition(|candidate| { + match candidate { + PaletteCandidate::AiSession(index) => self.ai_sessions[*index].source == lead, + _ => true, + } + }); + head.extend(tail); + self.filtered = head; + } + + /// 一条命令这一轮出不出现在列表里。 + /// + /// 两类:形态没打磨完的(`OpenAiSessionPicker` —— 筛选芯片、类型标、按 + /// 对象推导的页脚动词都还没做,先不让用户撞见半成品),和依赖的状态压根 + /// 不存在的(cwd 未知时的工作目录组)。 + /// + /// 放行只需删掉对应分支;不要改 `ITEMS`,那样会连带丢掉搜索词和本地化 + /// 文案。 + fn parked(&self, action: &PaletteAction) -> bool { + match action { + PaletteAction::OpenAiSessionPicker => true, + PaletteAction::CopyCwd | PaletteAction::RevealCwd => self.context.cwd.is_none(), + _ => false, + } + } + + fn refilter(&mut self) { + let candidates: Vec = match self.mode { + PaletteMode::Commands => (0..ITEMS.len()) + .filter(|index| !self.parked(&ITEMS[*index].action)) + .map(PaletteCandidate::Item) + .chain((0..self.profiles.len()).map(PaletteCandidate::Profile)) + .collect(), + PaletteMode::Profiles | PaletteMode::DefaultShell => { + (0..self.profiles.len()).map(PaletteCandidate::Profile).collect() + }, + PaletteMode::Directories => { + (0..self.directories.len()).map(PaletteCandidate::Directory).collect() + }, + PaletteMode::AiSessions => { + (0..self.ai_sessions.len()).map(PaletteCandidate::AiSession).collect() + }, + }; + let combined_search = |candidate: PaletteCandidate| -> &str { + match candidate { + PaletteCandidate::Item(index) => ITEMS[index].search, + PaletteCandidate::Profile(index) => self.profiles[index].search(), + // 目录模式已经由 DirectoryHistory 完成匹配和排序;这里 + // 不再二次模糊排序,以免破坏 frecency 的确定性。 + PaletteCandidate::Directory(_) => "", + PaletteCandidate::AiSession(index) => &self.ai_sessions[index].search, + } + }; + let query = self.query.trim(); + if self.mode == PaletteMode::Directories { + self.filtered = candidates; + } else if query.is_empty() { + let mut order = candidates; + order.sort_by_key(|candidate| match candidate { + PaletteCandidate::Item(index) => { + self.recent.iter().position(|recent| recent == index).unwrap_or(usize::MAX) + }, + PaletteCandidate::Profile(_) + | PaletteCandidate::Directory(_) + | PaletteCandidate::AiSession(_) => usize::MAX, + }); + self.filtered = order; + } else { + let mut scored: Vec<(i32, PaletteCandidate)> = candidates + .into_iter() + .filter_map(|candidate| { + fuzzy_score(query, combined_search(candidate)).map(|score| (score, candidate)) + }) + .collect(); + scored.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1))); + self.filtered = scored.into_iter().map(|(_, candidate)| candidate).collect(); + } + // 分组表头要求同组的行连续;上面两种排序(最近优先 / 模糊得分)都会把 + // 分类打散,所以这一步必须在**所有**排序之后。`sort_by_key` 是稳定 + // 排序,组内既有的先后原样保留。 + match self.mode { + PaletteMode::AiSessions => self.group_ai_sessions_by_source(), + PaletteMode::Commands => { + let groups: Vec<_> = + self.filtered.iter().map(|c| self.command_group(*c)).collect(); + let mut paired: Vec<_> = + groups.into_iter().zip(self.filtered.drain(..)).collect(); + paired.sort_by_key(|(group, _)| *group); + self.filtered = paired.into_iter().map(|(_, candidate)| candidate).collect(); + }, + _ => {}, + } + self.selected = None; // Reset selection on refilter + } + + pub fn query(&self) -> &str { + &self.query + } + + pub fn is_empty(&self) -> bool { + self.filtered.is_empty() + } + + /// The at-most `max_rows` visible rows, scrolled so the selection stays in + /// view, plus the selected row's index *within that window* (`None` when the + /// list is empty OR nothing is selected yet). Collected so the result borrows nothing. + pub fn visible(&self, max_rows: usize) -> (Vec, Option) { + if self.filtered.is_empty() || max_rows == 0 { + return (Vec::new(), None); + } + // No stored selection yet: the first row is the visual/default target. + // `selected` stays None until navigation so Up from a fresh palette + // still wraps to the last row, matching the existing keyboard model. + let Some(selected) = self.selected else { + let rows = self.filtered.iter().take(max_rows).map(|&row| self.row_for(row)).collect(); + let selected = (self.mode == PaletteMode::Commands).then_some(0); + return (rows, selected); + }; + // Keep the selection visible: once it passes the last row, scroll so it + // sits on the bottom line of the window. + let start = self.scroll_start(max_rows); + let rows = + self.filtered.iter().skip(start).take(max_rows).map(|&row| self.row_for(row)).collect(); + (rows, Some(selected - start)) + } + + fn row_for(&self, candidate: PaletteCandidate) -> PaletteRow { + match candidate { + PaletteCandidate::Item(index) => PaletteRow { + icon: String::new(), + color_id: String::new(), + label: localized_item_label(&ITEMS[index], self.language).to_owned(), + hint: ITEMS[index].hint.to_string(), + is_default: false, + chip: String::new(), + // 开关类命令带当前状态:勾上的是「已经开着」,不是「点了会 + // 开」。列表里同时有开关和一次性动作时,这是唯一能把两者分开 + // 的信号。 + checked: match ITEMS[index].action { + PaletteAction::ToggleSidebar => Some(self.context.sidebar), + PaletteAction::TogglePanelResize => Some(self.context.panel_resize), + _ => None, + }, + }, + PaletteCandidate::Profile(index) => PaletteRow { + icon: self.profiles[index].icon().to_string(), + color_id: self.profiles[index].color_id().to_string(), + label: self.profiles[index].label().to_string(), + hint: self.profiles[index].hint().to_string(), + is_default: matches!( + &self.profiles[index], + ProfileRow::Shell { shell, .. } + if self.default_shell_id.as_deref() == Some(shell.id.as_str()) + ), + chip: String::new(), + checked: None, + }, + PaletteCandidate::Directory(index) => PaletteRow { + icon: "\u{f07b}".to_owned(), + color_id: String::new(), + label: self.directories[index].label.clone(), + hint: self.directories[index].hint.clone(), + is_default: false, + chip: String::new(), + checked: None, + }, + PaletteCandidate::AiSession(index) => { + PaletteRow { + // 双星(mdi-creation):AI 会话的**通用**标,不按 claude / + // codex 分。来源交给分组承担,行首再放品牌标是同一条信息 + // 说两遍;而且品牌标每行长得都不一样,混合列表里就没有一个 + // 稳定的「这是会话」的记号可扫。 + // + // 必须是双星不能是单颗四角星——单星是 Gemini 的标识,拿它 + // 当通用 AI 标会被读成某一家的品牌入口(用户 08-02 裁定)。 + // + // 走字形而不是纹理,顺带解决主题自适应:字形用 skin 的墨色 + // 画,深浅主题自动跟随;纹理要为每个主题各备一版并重采样。 + icon: AI_SESSION_GLYPH.to_owned(), + color_id: String::new(), + label: self.ai_sessions[index].label.clone(), + hint: self.ai_sessions[index].hint.clone(), + is_default: false, + // 右缘是**类型**标,不是来源标。 + chip: self.language.pick("会话", "Session").to_owned(), + checked: None, + } + }, + } + } +} + +fn localized_item_label(item: &PaletteItem, language: super::UiLanguage) -> &'static str { + use PaletteAction::*; + if language == super::UiLanguage::ZhCn { + return item.label; + } + match item.action { + NewTab => "New tab", + CopyCwd => "Copy path", + RevealCwd => "Reveal in file manager", + ToggleSidebar => "Show tab sidebar", + TogglePanelResize => "Drag to resize panels", + OpenDirectoryPicker => "New terminal in a frequent directory...", + OpenAiSessionPicker => "Open quickly...", + CloseTab => "Close tab", + NextTab => "Next tab", + PrevTab => "Previous tab", + NewWindow => "New window", + SplitRight => "Split right", + SplitDown => "Split down", + ToggleFilesPanel => "Files panel", + ToggleGitPanel => "Git panel", + OpenSettings => "Open settings", + OpenSettingsFile => "Open configuration file", + ToggleGhost => "Toggle ghost completion", + CycleAccept => "Cycle completion accept key", + PickBackgroundImage => "Choose background image...", + CycleBackground => "Cycle background color", + ResetAppearance => "Restore appearance defaults", + SelectTheme(NebulaTheme::Nebula) => "Theme: Nebula", + SelectTheme(NebulaTheme::SilverLight) => "Theme: Silver Light", + SelectTheme(NebulaTheme::SteelDark) => "Theme: Steel Dark", + SelectTheme(NebulaTheme::LimestoneLight) => "Theme: Limestone", + SelectTheme(NebulaTheme::CoalDark) => "Theme: Coal Dark", + SelectTheme(NebulaTheme::LinenLight) => "Theme: Linen Light", + SelectTheme(NebulaTheme::MossDark) => "Theme: Moss Dark", + ExportWorkspace => "Export workspace...", + ImportWorkspace => "Open workspace...", + SyncPush => "Sync: push settings", + SyncPull => "Sync: pull settings", + LaunchProfile(_) | LaunchShell(_) | SetDefaultShell(_) | NewAtDirectory(_) + | ResumeAiSession(_) => item.label, + } +} + +/// 命令的分类。分组表头按它分,组的先后也按它排(`ordinal`)。 +/// +/// 分组的前提是同组的行**连续**,而空查询要把最近用过的命令顶上来、有查询 +/// 要按模糊得分排——两种排序都会把分类打散。所以 refilter 末尾再按 ordinal +/// 做一次**稳定**排序:组的顺序固定下来,组内仍然是「最近优先 / 得分优先」。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum CommandGroup { + /// 作用在聚焦 pane 工作目录上的动作。排在最前:面板打开时用户十有八九 + /// 是要对「手头这个目录」做点什么。 + Cwd, + Tabs, + Jump, + View, + Workspace, + Appearance, + Settings, + /// 动态的 shell / profile 行,永远排在静态命令之后。 + Shells, +} + +impl CommandGroup { + fn of(action: &PaletteAction) -> Self { + use PaletteAction::*; + match action { + NewTab | CopyCwd | RevealCwd => Self::Cwd, + OpenDirectoryPicker | CloseTab | NextTab | PrevTab | NewWindow | SplitRight + | SplitDown => Self::Tabs, + OpenAiSessionPicker => Self::Jump, + ToggleFilesPanel | ToggleGitPanel | ToggleSidebar | TogglePanelResize => Self::View, + ExportWorkspace | ImportWorkspace | SyncPush | SyncPull => Self::Workspace, + PickBackgroundImage | CycleBackground | ResetAppearance | SelectTheme(_) => { + Self::Appearance + }, + _ => Self::Settings, + } + } + + fn label(self, language: super::UiLanguage) -> String { + match self { + Self::Cwd => language.pick("工作目录", "WORKING DIRECTORY"), + Self::Tabs => language.pick("标签页", "TABS"), + Self::Jump => language.pick("跳转", "JUMP"), + Self::View => language.pick("视图", "VIEW"), + Self::Workspace => language.pick("工作区", "WORKSPACE"), + Self::Appearance => language.pick("外观", "APPEARANCE"), + Self::Settings => language.pick("设置", "SETTINGS"), + Self::Shells => language.pick("SHELL 与 PROFILE", "SHELLS & PROFILES"), + } + .to_owned() + } +} + +/// AI 会话分组表头的文案。用产品名而不是 `AiSessionSource::label()` 的小写 +/// 短标(`claude` / `codex`):表头是给人读的标题,不是数据里的枚举值。 +fn source_group_label(source: crate::ai_sessions::AiSessionSource) -> String { + match source { + crate::ai_sessions::AiSessionSource::Claude => "CLAUDE CODE 会话".to_owned(), + crate::ai_sessions::AiSessionSource::Codex => "CODEX 会话".to_owned(), + } +} + +/// AI 会话行的行首字形:mdi-creation,一大一小两颗四角星。见 `row_for` +/// 的 `AiSession` 分支说明为什么是「双星 + 通用 + 字形」这三条。 +const AI_SESSION_GLYPH: &str = "\u{f0674}"; + +/// One rendered palette row. `icon` is the Nerd Font fallback glyph (empty for +/// built-in action rows); `color_id` names a full-color brand PNG when the row +/// is a detected shell, or an `ai:*` id for AI-session brand logos (empty +/// otherwise, so the glyph shows instead). +pub struct PaletteRow { + pub icon: String, + pub color_id: String, + pub label: String, + pub hint: String, + pub is_default: bool, + /// 右缘小药丸的文字(AI 会话行 = 来源 `claude` / `codex`);空 = 无。 + pub chip: String, + /// 开关类命令的当前状态:`Some(true)` 画 ✓。`None` = 这行不是开关(一次 + /// 性动作),左列留空但**仍然占位**——同一组里一行缩进一行不缩进,标签 + /// 就成了锯齿。 + pub checked: Option, +} + +/// Subsequence fuzzy score, or `None` if the needle isn't a subsequence of the +/// haystack. Consecutive runs and word-start matches are rewarded so intuitive +/// queries rank first (e.g. "nt" prefers "new tab" over "next"). An empty +/// needle matches everything with score 0, preserving declaration order. +fn fuzzy_score(needle: &str, haystack: &str) -> Option { + if needle.is_empty() { + return Some(0); + } + let needle: Vec = needle.chars().flat_map(char::to_lowercase).collect(); + let mut next = 0usize; + let mut score = 0i32; + let mut run = 0i32; + let mut prev = ' '; + for hc in haystack.chars().flat_map(char::to_lowercase) { + if next < needle.len() && hc == needle[next] { + score += 1 + run * 5; // consecutive-match run bonus (dominant) + if !prev.is_alphanumeric() { + score += 4; // word / segment start + } + run += 1; + next += 1; + } else { + run = 0; + } + prev = hc; + } + (next == needle.len()).then_some(score) +} + +/// Popup layout rectangles, all in physical pixels for the given `scale`. +pub struct PaletteLayout { + /// Outer panel `(x, y, w, h)`. + pub panel: (f32, f32, f32, f32), + /// Query input box `(x, y, w, h)`. + pub input: (f32, f32, f32, f32), + /// Height of one standard result row. + pub row_h: f32, + /// Top Y of the first result row (or of its section header). + pub list_y: f32, + /// Maximum rows drawn before the list scrolls. + pub max_rows: usize, + /// Keyboard-hint footer for picker modes; absent in the full command list. + pub footer: Option<(f32, f32, f32, f32)>, + /// Per-visible-row rects `(y, h)`; x/w span the panel's inner width. The + /// picker's card geometry is non-uniform (hero card, section gaps), so + /// rendering AND hit-testing must both read row rects from here instead + /// of dividing by `row_h`. + pub rows: Vec<(f32, f32)>, + /// 分组表头:`(y, 标签, 右缘上下文)`。标签左对齐、右边拉一条淡色发丝线 + /// 到面板右缘;上下文非空时贴在线的右端(如「工作目录」那组挂当前 cwd + /// ——这组动作作用在谁身上,写在标题上而不是每行重复)。 + /// + /// 通用化之前这里是一对 `Option`,只够 hero 版式的「推荐 / 所有 + /// 选项」两条用;现在 hero 也走这个 vec,不再有第二套机制。 + pub groups: Vec<(f32, String, String)>, +} + +impl PaletteLayout { + /// Visible-row index under a point, honoring the non-uniform card + /// geometry. Points on section captions or card gaps return `None`. + pub fn row_at(&self, x: f32, y: f32) -> Option { + let (px, _, pw, _) = self.panel; + if x < px || x >= px + pw { + return None; + } + self.rows.iter().position(|&(ry, rh)| y >= ry && y < ry + rh) + } +} + +/// Compute the centered popup layout for a window of `win_w` × `win_h`. The +/// command list keeps a fixed panel height (sized for `max_rows`) so it +/// doesn't jump as the match count changes while typing; pickers shrink to +/// their content. Every palette mode uses the same search-input geometry, +/// keeping rendering, hover and click hit-testing on one contract. +pub fn palette_layout(model: &CommandPalette, win_w: f32, win_h: f32, scale: f32) -> PaletteLayout { + let s = |v: f32| v * scale; + let margin = s(8.0); + let pad = s(12.0); + let row_h = s(super::ui::tokens::control::COMPACT_ROW); + // 搜索框与结果行等高:输入仍然可发现,但不会压过真正的数据内容。 + let input_h = row_h; + let cards = model.is_picker(); + let max_rows = if cards { 10usize } else { 8usize }; + let visible = model.filtered.len().min(max_rows); + let hero = model.hero_row() && visible > 0; + let footer_h = if cards { s(36.0) } else { 0.0 }; + let header_h = s(24.0); + let gap = s(6.0); + let hero_h = s(58.0); + + // List geometry relative to the list's top edge. Pickers lay cards out + // with section captions and gaps (图4); the command list stays a dense + // uniform grid. + let mut rel_rows: Vec<(f32, f32)> = Vec::with_capacity(visible); + let mut rel_groups: Vec<(f32, String, String)> = Vec::new(); + let mut y = 0.0f32; + if cards { + let slots = visible.max(1); + let mut rest = slots; + if hero { + rel_groups.push((y, model.language.pick("推荐", "Recommended").to_owned(), String::new())); + y += header_h; + rel_rows.push((y, hero_h)); + y += hero_h + s(10.0); + rest = slots - 1; + if rest > 0 { + rel_groups.push(( + y, + model.language.pick("所有选项", "All options").to_owned(), + String::new(), + )); + y += header_h; + } + } else if let Some(labels) = model.group_labels(max_rows) { + // 分组版式:每遇到新的一组就插一条表头,然后铺该组的行。 + let count = labels.len(); + for (index, group) in labels.iter().enumerate() { + if let Some((label, context)) = group { + rel_groups.push((y, label.clone(), context.clone())); + y += header_h; + } + rel_rows.push((y, row_h)); + y += row_h; + if index + 1 < count { + y += gap; + } + } + y += s(4.0); + rest = 0; + } + for extra in 0..rest { + if rel_rows.len() < visible { + rel_rows.push((y, row_h)); + } + y += row_h; + if extra + 1 < rest { + y += gap; + } + } + if rest > 0 { + y += s(4.0); + } + } else if let Some(labels) = model.group_labels(max_rows) { + // 命令列表:均匀行距,但每组前面插一条表头。组与组之间再留半行呼吸, + // 首组紧贴列表顶——顶上那条线不该和输入框贴出一道双边。 + for (index, group) in labels.iter().enumerate() { + if let Some((label, context)) = group { + if index > 0 { + y += s(6.0); + } + rel_groups.push((y, label.clone(), context.clone())); + y += header_h; + } + rel_rows.push((y, row_h)); + y += row_h; + } + // 行少时也把面板撑到固定高度:否则每敲一个字面板都在长高缩矮。 + y = y.max(max_rows as f32 * row_h); + } else { + for _ in 0..max_rows { + if rel_rows.len() < visible { + rel_rows.push((y, row_h)); + } + y += row_h; + } + } + + let pw = s(if cards { 620.0 } else { 640.0 }).min(win_w - 2.0 * margin); + let ph = pad + input_h + s(8.0) + y + if cards { footer_h } else { pad }; + let px = ((win_w - pw) * 0.5).max(margin); + let py = ((win_h - ph) * 0.5).max(s(48.0)); + + let input = (px + pad, py + pad, pw - 2.0 * pad, input_h); + let list_y = py + pad + input_h + s(8.0); + let rows = rel_rows.into_iter().map(|(ry, rh)| (list_y + ry, rh)).collect(); + let groups = + rel_groups.into_iter().map(|(gy, label, ctx)| (list_y + gy, label, ctx)).collect(); + + let footer = cards.then_some((px, py + ph - footer_h, pw, footer_h)); + + PaletteLayout { panel: (px, py, pw, ph), input, row_h, list_y, max_rows, footer, rows, groups } +} + +// ---- 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}; + +/// 卡片列的左右内边距,也是**右侧基准线**:输入框的 Ctrl+K 键帽、推荐卡 +/// 的 ↵ chip、路径列、命令行的快捷键 chip、底栏的 Esc 提示——所有右贴边 +/// 元素都从 `ix + iw - s(GUTTER)` 起算,右缘才连成一条竖线。 +/// +/// 2026-07-29:此前这里散着 10/12/14 三个值,底栏还错用了 panel 坐标 +/// (`px + pw - s(16)`,实际只内缩 4px),右侧看着毛糙且随字号漂移。 +const GUTTER: f32 = 14.0; + +/// 标签右缘与右侧信息列之间的最小呼吸缝。挤不下就整列让位,绝不重叠。 +const HINT_GAP: f32 = 24.0; + +/// 文字与相邻 chip(推荐卡的 ↵)之间的缝——比 [`HINT_GAP`] 窄,因为 chip +/// 自带视觉边界,不需要靠空白来分隔。 +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 +/// highlight. No-op while closed. +pub(super) fn push_quads( + model: &CommandPalette, + theme: &NebulaTheme, + quads: &mut Vec, + size: &SizeInfo, + scale: f32, +) { + if !model.is_open() { + return; + } + let w = size.width(); + let h = size.height(); + let s = |v: f32| v * scale; + let sk = theme.skin(); + let layout = palette_layout(model, w, h, scale); + let (ix, iy, iw, ih) = layout.input; + + // 命令面板是 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, iy, iw, ih, s(super::ui::tokens::radius::CONTROL), sk.input)); + if model.query_all_selected() && !model.query.is_empty() { + let cell_w = size.cell_width(); + let columns: usize = model.query.chars().map(|c| c.width().unwrap_or(0)).sum(); + 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(super::ui::tokens::radius::CHIP), + sk.accent_soft, + )); + } + + let cell_w = size.cell_width(); + // 分组表头的淡色横线:从标签右侧一直拉到面板右缘(有右缘上下文时让位 + // 给它)。线是 quad、标签是文字,两边共用 `layout.groups` 的同一份 y + // 与同一套 GUTTER 基准,所以永远不会错位。 + for (gy, label, ctx) in &layout.groups { + let label_w = text_width_cols(label) as f32 * cell_w; + let x0 = ix + s(INPUT_PAD_X) + label_w + s(10.0); + let ctx_w = + if ctx.is_empty() { 0.0 } else { text_width_cols(ctx) as f32 * cell_w + s(10.0) }; + let x1 = ix + iw - s(GUTTER) - ctx_w; + if x1 > x0 { + // 一整像素高,吸附到像素网格:半像素的发丝线会被摊成两行半透明 + // 灰,比没有还糟(与 render-crispness 的整像素锚点同一条铁律)。 + quads.push( + UiQuad::solid(x0, gy + s(11.0), x1 - x0, 1.0, 0.0, sk.hairline).pixel_snapped(), + ); + } + } + // 搜索图标槽与查询文字的起点。槽宽按 **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", + ix + iw - s(GUTTER), + iy + ih / 2.0, + cell_w, + scale, + ); + super::ui::keycap::push_combo(quads, &sk, &combo, scale); + } + + if let Some((fx, fy, fw, fh)) = layout.footer { + quads.push(UiQuad::solid(fx, fy, fw, fh, 0.0, sk.surface)); + quads.push(UiQuad::solid(fx, fy, fw, s(1.0), 0.0, sk.hairline)); + } + + let (visible_rows, selected_row) = model.visible(layout.max_rows); + let cards = model.is_picker(); + let hero = model.hero_row(); + let accent_ring = Rgba::new(sk.accent.r, sk.accent.g, sk.accent.b, 255); + if cards { + // 列表行**不画卡片**(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; + 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); + let cx = ix + iw - s(GUTTER) - chip; + let cy = ry + (rh - chip) / 2.0; + quads.push(UiQuad::solid( + cx - s(1.0), + cy - s(1.0), + chip + s(2.0), + chip + s(2.0), + s(7.0), + sk.hairline, + )); + quads.push(UiQuad::solid(cx, cy, chip, chip, s(6.0), sk.card)); + } + } + } else { + // Hover background: subtle highlight when the mouse is over a row. + if let Some(hover_row) = model.hover { + if let Some(&(ry, rh)) = layout.rows.get(hover_row) { + quads.push(UiQuad::solid( + ix + s(8.0), + ry + s(2.0), + iw - s(16.0), + rh - s(4.0), + s(6.0), + sk.hover, + )); + } + } + // Highlight pill behind the selected row (list scrolls to keep it + // shown). + if let Some(row) = selected_row { + if let Some(&(ry, rh)) = layout.rows.get(row) { + quads.push(UiQuad::solid( + ix + s(8.0), + ry, + iw - s(16.0), + rh - s(4.0), + s(6.0), + sk.accent_soft, + )); + quads.push(UiQuad::solid( + ix + s(8.0), + ry + s(5.0), + s(2.0), + rh - s(14.0), + s(1.0), + accent_ring, + )); + } + } + } + + // The default badge is deliberately a hairline chip, not another bright + // accent pill. It identifies the launch target without competing with the + // selected-row affordance. The hero card 已经用"推荐"分区表达默认身份, + // 不再叠加徽标。 + let text_x = ix + s(14.0); + let check_col = model.has_check_column(); + let badge = model.language.pick("默认", "Default"); + let badge_w = + badge.chars().map(|c| c.width().unwrap_or(1)).sum::() as f32 * cell_w + s(12.0); + for (row, entry) in visible_rows.into_iter().enumerate() { + let Some(&(ry, rh)) = layout.rows.get(row) else { continue }; + // 图8 规范:命令列表右侧的快捷键 hint 画成逐键 chip 底(键名在文 + // 字 pass)。挤不下就整组让位,不压进标签。 + if !cards && !entry.hint.is_empty() { + let combo = super::ui::keycap::layout_combo( + &entry.hint, + ix + iw - s(GUTTER), + ry + rh / 2.0, + cell_w, + scale, + ); + let label_end = text_x + + entry.label.chars().map(|c| c.width().unwrap_or(1)).sum::() as f32 + * cell_w; + if combo.bounds.0 > label_end + s(HINT_GAP) { + super::ui::keycap::push_combo(quads, &sk, &combo, scale); + } + } + // AI 会话行右缘的来源 chip("claude"/"codex")底:push_stroke 发丝 + // 环 + 面板底,与「默认」徽标同宗——身份标注,不与选中态抢强调色。 + // 文字在 text pass 按同一几何画。 + if !entry.chip.is_empty() { + let chip_w = + entry.chip.chars().map(|c| c.width().unwrap_or(1)).sum::() as f32 * cell_w + + s(12.0); + let cx = ix + iw - s(GUTTER) - chip_w; + let cy = ry + s(7.0); + let ch = rh - s(14.0); + let corner = s(super::ui::tokens::radius::CHIP); + surface::push_stroke(quads, (cx, cy, chip_w, ch), corner, scale, sk.hairline); + quads.push(UiQuad::solid(cx, cy, chip_w, ch, corner, sk.surface)); + } + // 开关类命令的勾选态。画成线段 quad 而不是字体里的 `✓` 字形:那个 + // 码位在不同 fallback 字体里宽窄不一,行与行之间会左右晃半格,而这 + // 一列的全部价值就在于**竖直对齐**。青色(`ok`)是「已经是这样」的 + // 语义色,与强调色分开——列表里同时有开关和一次性动作时,这是唯一 + // 能把两者分开的信号。 + if entry.checked == Some(true) { + super::ui::icons::push_check( + quads, + text_x + s(CHECK_COL_W) / 2.0, + ry + rh / 2.0, + scale, + sk.ok, + ); + } + if !entry.is_default || (hero && row == 0) { + continue; + } + let label_x = if !entry.icon.is_empty() { + text_x + s(26.0) + } else if check_col { + text_x + s(CHECK_COL_W) + } else { + text_x + }; + let label_w = + entry.label.chars().map(|c| c.width().unwrap_or(1)).sum::() as f32 * cell_w; + let bx = label_x + label_w + s(8.0); + let by = ry + s(7.0); + let bh = rh - s(14.0); + quads.push(UiQuad::solid( + bx - s(1.0), + by - s(1.0), + badge_w + s(2.0), + bh + s(2.0), + s(5.0), + sk.hairline, + )); + quads.push(UiQuad::solid(bx, by, badge_w, bh, s(4.0), sk.surface)); + } + + // 底栏**不画 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, +/// then the result rows with right-aligned shortcut hints. No-op while closed. +/// +/// Returns the full-color brand-icon draw requests (`color_id`, pixel rect) +/// for detected-shell rows: the caller resolves each to a texture and stages +/// it for the post-text image pass (a textured quad can't be interleaved with +/// glyph batches). Rows whose id has no brand asset draw the Nerd Font glyph +/// here and contribute nothing to the returned list. +pub(super) fn draw_text( + model: &CommandPalette, + theme: &NebulaTheme, + r: &mut Renderer, + gc: &mut GlyphCache, + size: &SizeInfo, + scale: f32, +) -> Vec<(String, (f32, f32, f32, f32))> { + let mut icon_draws = Vec::new(); + if !model.is_open() { + return icon_draws; + } + let s = |v: f32| v * scale; + let w = size.width(); + let h = size.height(); + let cell_w = size.cell_width(); + let cell_h = size.cell_height(); + let layout = palette_layout(model, w, h, scale); + let (ix, iy, iw, ih) = layout.input; + + // Inks from the theme skin: dark text on light panels, pale on dark. + let sk = theme.skin(); + + // Left edge for result text and the search icon. + 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); + + // 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(); + + if query.is_empty() { + let placeholder = match model.mode { + PaletteMode::Commands => model + .language + .pick("搜索命令、Shell 或 Profile…", "Search commands, shells or profiles..."), + PaletteMode::Profiles | PaletteMode::DefaultShell => { + model.language.pick("搜索 Shell 或 Profile…", "Search shells or profiles...") + }, + PaletteMode::Directories => { + model.language.pick("搜索常用目录…", "Search frequent directories...") + }, + PaletteMode::AiSessions => { + model.language.pick("搜索 AI 会话…", "Search AI sessions...") + }, + }; + r.draw_chrome_text(size, query_x, text_y, sk.ink_faint, placeholder, gc); + } else { + r.draw_chrome_text(size, query_x, text_y, sk.ink_strong, query, gc); + } + + if model.is_empty() { + r.draw_chrome_text( + size, + text_x, + layout.list_y + s(8.0), + sk.ink_dim, + match model.mode { + PaletteMode::Directories => { + model.language.pick("没有匹配的已访问目录", "No matching visited directories") + }, + PaletteMode::AiSessions => model + .language + .pick("没有找到 AI 会话(claude / codex)", "No AI sessions found"), + PaletteMode::Commands | PaletteMode::Profiles | PaletteMode::DefaultShell => { + model.language.pick("无匹配命令", "No matching commands") + }, + }, + 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; + } + + // 分组表头(图4 的推荐/所有选项,以及 AI 会话按来源分的组)。 + for (gy, label, ctx) in &layout.groups { + r.draw_chrome_text(size, text_x, gy + s(2.0), sk.ink_dim, label, gc); + // 右缘上下文(如「工作目录」组挂当前 cwd):右对齐贴到面板右缘, + // 与横线让出的位置同一套基准(见 quad pass 的 `ctx_w`)。 + if !ctx.is_empty() { + let ctx_w = text_width_cols(ctx) as f32 * cell_w; + r.draw_chrome_text( + size, + ix + iw - s(GUTTER) - ctx_w, + gy + s(2.0), + sk.ink_faint, + ctx, + gc, + ); + } + } + // Ctrl+K 键帽文字(chip 底在 quad pass,几何同源)。 + if model.mode == PaletteMode::Profiles && model.query.is_empty() { + let combo = super::ui::keycap::layout_combo( + "Ctrl+K", + ix + iw - s(GUTTER), + iy + ih / 2.0, + cell_w, + scale, + ); + draw_combo_text(r, gc, size, &combo, cell_w, cell_h, &sk); + } + + let cards = model.is_picker(); + let hero = model.hero_row(); + let check_col = model.has_check_column(); + 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, chip, checked: _ } = entry; + let Some(&(row_y, row_hh)) = layout.rows.get(row) else { break }; + let is_hero = hero && row == 0; + // Hero 卡片双行:名称在上、完整路径在下(图4);普通行单行居中。 + let ry = if is_hero { row_y + s(8.0) } else { row_y + (row_hh - cell_h) / 2.0 - s(2.0) }; + let fg = if Some(row) == selected_row || is_hero { sk.ink_strong } else { sk.ink }; + // Leading icon, then the label indented past it. Detected shells with a + // brand asset stage a full-color textured quad (drawn later); the rest + // fall back to the Nerd Font glyph. Built-in action rows carry an empty + // icon and keep the original left edge. + let has_color = !color_id.is_empty() + && crate::shell_detect::color_icon_png(&color_id).is_some(); + let indent = if is_hero { s(34.0) } else { s(26.0) }; + let label_x = if has_color { + // Square icon sized to the glyph ink, vertically centered on the + // row (the hero card gets a bigger brand mark). + let icon_s = if is_hero { (cell_h * 1.35).round() } else { (cell_h * 0.92).round() }; + let icon_y = (row_y + (row_hh - icon_s) / 2.0).round(); + icon_draws.push((color_id, (text_x, icon_y, icon_s, icon_s))); + text_x + indent + } else if check_col { + // 命令面板的左列**永远占位**(勾选态列,✓ 在 quad pass 画): + // 开关命令勾上,一次性动作留空。留空而不是收窄——同一组里一行 + // 缩进一行不缩进,标签就排成锯齿,而分组表头刚把这些行归到了 + // 一起。 + text_x + s(CHECK_COL_W) + } else if icon.is_empty() { + text_x + } else { + let icon_y = row_y + (row_hh - cell_h) / 2.0; + r.draw_chrome_text(size, text_x, icon_y, sk.accent, &icon, gc); + text_x + indent + }; + // 右缘边界:来源 chip 占掉的宽度从这一行所有右侧内容里扣除;chip + // 文字与 quad pass 的药丸底同一几何。 + let right_limit = if chip.is_empty() { + ix + iw - s(GUTTER) + } else { + let chip_w = text_width_cols(&chip) as f32 * cell_w + s(12.0); + let cx = ix + iw - s(GUTTER) - chip_w; + r.draw_chrome_text(size, cx + s(6.0), ry, sk.ink_dim, &chip, gc); + cx - s(CHIP_GAP) + }; + // 标签按可用宽截断(尾部省略号)。60 字符的会话标题此前不截断, + // 直接横穿路径列画出面板(用户 08-02 截图);任何一行的标签都不许 + // 画进右侧信息区。 + let label_limit = match path_col_x { + Some(col) if !is_hero => right_limit.min(col - s(HINT_GAP)), + _ => right_limit, + }; + let label_budget = ((label_limit - label_x) / cell_w).floor().max(0.0) as usize; + let label = fit_head(&label, label_budget); + r.draw_chrome_text(size, label_x, ry, fg, &label, gc); + let badge_span = if is_default && !is_hero { + r.draw_chrome_text( + size, + label_x + text_width_cols(&label) as f32 * cell_w + s(10.0), + ry, + sk.ink_dim, + badge, + gc, + ); + badge_w(true) + } else { + 0.0 + }; + if is_hero { + // 第二行完整路径(塞不下时尾部省略号),右侧回车 chip glyph。 + let chip = s(28.0); + let chip_x = ix + iw - s(GUTTER) - chip; + let path_y = row_y + row_hh - cell_h - s(8.0); + let budget = ((chip_x - s(CHIP_GAP) - label_x) / cell_w).floor(); + if budget >= 3.0 { + let shown = fit_head(&hint, budget as usize); + r.draw_chrome_text(size, label_x, path_y, sk.ink_dim, &shown, gc); + } + let chip_text_y = row_y + (row_hh - cell_h) / 2.0; + r.draw_chrome_text( + size, + chip_x + (chip - cell_w) / 2.0, + chip_text_y, + sk.accent, + "↵", + gc, + ); + } else if !hint.is_empty() { + if !cards { + // 图8 规范:命令列表快捷键 hint 逐键 chip;与 quad pass 用 + // 同一 layout_combo 几何与让位守卫。 + let combo = super::ui::keycap::layout_combo( + &hint, + ix + iw - s(GUTTER), + row_y + row_hh / 2.0, + cell_w, + scale, + ); + let label_end = ix + + s(GUTTER) + + label.chars().map(|c| c.width().unwrap_or(1)).sum::() as f32 * cell_w; + 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(col_x) = path_col_x { + // 路径左对齐到共用的竖线。挤不下(标签太长压到列位)就整条 + // 让位,绝不叠在标签上——与命令列表的 hint 同一条规矩。 + let label_end = label_x + text_width_cols(&label) as f32 * cell_w + badge_span; + let budget = ((right_limit - 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 { + draw_footer_hints(r, gc, size, model, ix, iw, fy, fh, cell_w, cell_h, scale, &sk); + } + icon_draws +} + +/// 底栏的三条键位提示。 +/// +/// 键名与释义分成两级墨(`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, + size: &SizeInfo, + combo: &super::ui::keycap::ComboChips, + cell_w: f32, + cell_h: f32, + sk: &super::ui::theme::Skin, +) { + let (_, key_y, _, key_h) = combo.bounds; + let ty = key_y + (key_h - cell_h) / 2.0; + for (chip_x, chip_w, key) in &combo.chips { + let key_cols: usize = key.chars().map(|c| c.width().unwrap_or(0)).sum(); + r.draw_chrome_text( + size, + chip_x + (chip_w - key_cols as f32 * cell_w) / 2.0, + ty, + sk.ink_dim, + key, + gc, + ); + } +} + +fn text_width_cols(text: &str) -> usize { + text.chars().map(|c| c.width().unwrap_or(0)).sum() +} + +/// picker 的路径列左缘:所有行共用一个 x,路径因此排成一条竖线。 +/// +/// `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 +/// ellipsis, matching the mockup's right-aligned path column. +fn fit_head(value: &str, budget: usize) -> String { + let width = |ch: char| ch.width().unwrap_or(0); + let total: usize = value.chars().map(width).sum(); + if total <= budget { + return value.to_owned(); + } + if budget <= 1 { + return "…".to_owned(); + } + let mut out = String::new(); + let mut used = 0usize; + for ch in value.chars() { + let w = width(ch); + if used + w > budget - 1 { + break; + } + out.push(ch); + used += w; + } + out.push('…'); + out +} + +/// 路径的**头部**省略:`D:\a\b\…\nebula`。与 [`fit_head`] 相反,因为这两处 +/// 要保住的信息在两头——picker 的路径列扫的是共同前缀(哪个盘、哪个用户), +/// 分组表头挂的那个 cwd 扫的是尾巴(在哪个项目里)。 +fn fit_tail(value: &str, budget: usize) -> String { + let width = |ch: char| ch.width().unwrap_or(0); + let total: usize = value.chars().map(width).sum(); + if total <= budget { + return value.to_owned(); + } + if budget <= 1 { + return "…".to_owned(); + } + let mut tail: Vec = Vec::new(); + let mut used = 0usize; + for ch in value.chars().rev() { + let w = width(ch); + if used + w > budget - 1 { + break; + } + tail.push(ch); + used += w; + } + let mut out = String::from("…"); + out.extend(tail.into_iter().rev()); + out +} + +/// `PaletteRow::color_id` 的 `ai:` 命名空间曾在这里解析成 claude / codex 的 +/// 品牌纹理。2026-08-02 撤掉:AI 会话统一走 [`AI_SESSION_GLYPH`] 双星字形, +/// 来源由分组承担。Shell / Profile 行的彩色真标不受影响——那是识别不同的 +/// **程序**,与「同一类事物的不同来源」不是一回事。 +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_query_lists_all_in_declaration_order() { + let palette = CommandPalette::new(); + // 暂缓露出的命令(`CommandPalette::parked`)不进列表,但仍留在 + // `ITEMS` 里——它的动作、搜索词和本地化文案都还在,放行时只需删掉 + // 那条判定。工作目录组的两条也在此列:新面板没有 cwd。 + let parked = ITEMS.iter().filter(|item| palette.parked(&item.action)).count(); + assert_eq!(palette.filtered.len(), ITEMS.len() - parked); + assert_eq!( + palette.filtered[0], + PaletteCandidate::Item(0), + "first declared item leads by default" + ); + } + + #[test] + fn recent_actions_surface_first_on_empty_query() { + let mut palette = CommandPalette::new(); + palette.record_recent(3); + palette.record_recent(7); + palette.refilter(); + // Most-recent first, then the previous recent, then the declared rest. + assert_eq!(palette.filtered[0], PaletteCandidate::Item(7)); + assert_eq!(palette.filtered[1], PaletteCandidate::Item(3)); + assert_eq!(palette.filtered[2], PaletteCandidate::Item(0)); + } + + #[test] + fn visible_returns_row_struct() { + let mut palette = CommandPalette::new(); + palette.profiles = vec![ProfileRow::Shell { + label: "PowerShell".into(), + hint: "pwsh.exe".into(), + search: "pwsh".into(), + shell: DetectedShell { + name: "PowerShell".into(), + id: "pwsh".into(), + program: "pwsh.exe".into(), + args: vec![], + }, + }]; + // Dynamic shell rows belong to the shell/profile picker. The full + // command palette deliberately keeps static actions first. + palette.open_profiles(); + let (rows, _) = palette.visible(10); + assert!(!rows.is_empty()); + assert_eq!(rows.last().unwrap().label, "PowerShell"); + } + + #[test] + fn record_recent_dedups_and_caps() { + let mut palette = CommandPalette::new(); + for i in 0..(RECENT_MAX + 3) { + palette.record_recent(i); + } + assert_eq!(palette.recent.len(), RECENT_MAX); + // Re-running an existing action moves it to the front without growing. + palette.record_recent(2); + assert_eq!(palette.recent.first(), Some(&2)); + assert_eq!(palette.recent.len(), RECENT_MAX); + } + + #[test] + fn fuzzy_matches_subsequence_and_rejects_the_rest() { + assert!(fuzzy_score("nt", "new tab").is_some()); + assert!(fuzzy_score("newtab", "new tab").is_some()); + assert!(fuzzy_score("xyz", "new tab").is_none()); + assert!(fuzzy_score("", "anything").is_some(), "empty query matches everything"); + } + + #[test] + fn fuzzy_rewards_consecutive_and_word_start() { + // A consecutive run beats the same letters scattered across separators. + let consecutive = fuzzy_score("tab", "xtab").unwrap(); + let scattered = fuzzy_score("tab", "t-a-b").unwrap(); + assert!(consecutive > scattered, "consecutive {consecutive} vs scattered {scattered}"); + // A word-start match beats a mid-word match of the same length. + let word_start = fuzzy_score("t", "x t").unwrap(); + let mid_word = fuzzy_score("t", "xt").unwrap(); + assert!(word_start > mid_word, "word-start {word_start} vs mid-word {mid_word}"); + } + + #[test] + fn confirm_records_recent_and_closes() { + let mut palette = CommandPalette::new(); + palette.open(); + palette.selected = Some(2); + let picked = palette.filtered[2]; + let action = palette.confirm(); + assert!(action.is_some()); + assert!(!palette.is_open()); + let PaletteCandidate::Item(picked) = picked else { panic!("expected static item") }; + assert_eq!(palette.recent.first(), Some(&picked)); + } + + #[test] + fn typing_filters_then_backspace_restores() { + let mut palette = CommandPalette::new(); + palette.open(); + let full = palette.filtered.len(); + for ch in "zqxjk".chars() { + palette.input_char(ch); + } + assert!(palette.filtered.len() < full, "gibberish should filter most out"); + for _ in 0.."zqxjk".len() { + palette.backspace(); + } + assert_eq!(palette.filtered.len(), full, "clearing the query restores the full list"); + } + + #[test] + fn move_selection_wraps_both_ends() { + let mut palette = CommandPalette::new(); + palette.open(); + assert_eq!(palette.selected, None); + palette.move_selection(-1); + assert_eq!( + palette.selected, + Some(palette.filtered.len() - 1), + "up from top wraps to bottom" + ); + palette.move_selection(1); + assert_eq!(palette.selected, Some(0), "down from bottom wraps to top"); + } + + #[test] + fn visible_window_scrolls_to_keep_selection_in_view() { + let mut palette = CommandPalette::new(); + palette.open(); + let max = 5; + // Selection at the top: window starts at 0, selection on row 0. + let (rows, sel) = palette.visible(max); + assert_eq!(rows.len(), max); + assert_eq!(sel, Some(0)); + // Move past the window; the selection pins to the bottom visible row. + for _ in 0..7 { + palette.move_selection(1); + } + assert_eq!(palette.selected, Some(7)); + let (rows, sel) = palette.visible(max); + assert_eq!(rows.len(), max); + assert_eq!(sel, Some(max - 1), "selection pinned to bottom row when scrolled"); + // The bottom visible row is the actually-selected item (field .label). + let PaletteCandidate::Item(index) = palette.filtered[7] else { + panic!("expected static item") + }; + assert_eq!(rows[max - 1].label, ITEMS[index].label); + } + + #[test] + fn profile_modes_keep_their_dismissible_picker_identity() { + let mut palette = CommandPalette::new(); + assert!(!palette.is_picker()); + + palette.open(); + assert!(!palette.is_picker(), "full palette keeps its search focus"); + + palette.open_profiles(); + assert!(palette.is_picker(), "new-tab shell selector dismisses on focus loss"); + + palette.close(); + assert!(!palette.is_picker()); + + palette.open_default_picker(); + assert!(palette.is_picker(), "default-shell selector shares dismissal semantics"); + + palette.set_directories(vec![PathBuf::from("D:/workspace")]); + palette.open_directories(); + assert!(palette.is_picker(), "directory selector shares dismissal semantics"); + } + + #[test] + fn default_shell_confirmation_preserves_picker_mode_until_action_is_built() { + let shell = DetectedShell { + name: "PowerShell".into(), + id: "pwsh".into(), + program: "pwsh.exe".into(), + args: vec![], + }; + let mut palette = CommandPalette::new(); + palette.set_default_shell_menu(std::slice::from_ref(&shell)); + palette.open_default_picker(); + + assert_eq!(palette.confirm(), Some(PaletteAction::SetDefaultShell(shell))); + } + + #[test] + fn directory_picker_returns_path_without_inventing_a_shell_command() { + let path = PathBuf::from("D:/项目 空间"); + let mut palette = CommandPalette::new(); + palette.set_directories(vec![path.clone()]); + palette.open_directories(); + let (rows, selected) = palette.visible(8); + + assert_eq!(selected, None); + assert_eq!(rows[0].hint, path.display().to_string()); + assert_eq!(palette.confirm(), Some(PaletteAction::NewAtDirectory(path))); + } + + #[test] + fn profile_picker_query_filters_rows() { + let mut palette = CommandPalette::new(); + palette.set_shell_menu( + &[], + &["Windows PowerShell".into(), "Git Bash".into()], + "powershell", + ); + palette.open_profiles(); + + palette.input_text("git"); + let (rows, _) = palette.visible(8); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].label, "Git Bash"); + } + + #[test] + fn search_input_and_result_rows_share_compact_height() { + let palette = CommandPalette::new(); + let layout = palette_layout(&palette, 1600.0, 900.0, 1.5); + assert_eq!(layout.input.3, layout.row_h); + } + + fn shell(name: &str, id: &str, program: &str) -> DetectedShell { + DetectedShell { name: name.into(), id: id.into(), program: program.into(), args: vec![] } + } + + #[test] + fn shell_menu_promotes_default_to_hero_row() { + let mut palette = CommandPalette::new(); + palette.set_shell_menu( + &[ + shell("CMD", "cmd", r"C:\Windows\System32\cmd.exe"), + shell("PowerShell", "powershell", r"C:\Windows\...\powershell.exe"), + ], + &[], + "powershell", + ); + palette.open_profiles(); + // 检测顺序把 CMD 排前,但推荐位(首行大卡片)必须是默认 shell。 + assert!(palette.hero_row()); + let (rows, _) = palette.visible(10); + assert_eq!(rows[0].label, "PowerShell"); + assert!(rows[0].is_default); + // 搜索中不分区:hero 退场,回到平铺卡片。 + palette.input_char('c'); + assert!(!palette.hero_row()); + } + + /// 命令列表按分类分组:组的顺序固定(工作目录→标签页→跳转→视图→ + /// 工作区→外观→设置→shell),组内保持「最近优先」。表头只在每组第一 + /// 行起一次。 + #[test] + fn commands_group_by_category_and_headers_start_each_group() { + let palette = CommandPalette::new(); + let visible = palette.filtered.len().min(24); + let groups: Vec<_> = + palette.filtered.iter().take(visible).map(|c| palette.command_group(*c)).collect(); + let mut sorted = groups.clone(); + sorted.sort(); + assert_eq!(groups, sorted, "同一分类的命令必须连续,否则表头会切进组中间"); + + let labels = palette.group_labels(visible).unwrap(); + assert_eq!(labels[0].as_ref().map(|(l, _)| l.as_str()), Some("标签页")); + assert_eq!(labels[1], None, "组内第二行不再起表头"); + // 起表头的次数 == 分类数 + let distinct = { + let mut seen = groups.clone(); + seen.dedup(); + seen.len() + }; + assert_eq!(labels.iter().flatten().count(), distinct); + } + + /// cwd 未知时整个「工作目录」组不出现——留一组作用在空路径上的死命令 + /// 比少一组糟得多。cwd 一到,这组排在最前,路径挂在表头右缘(写一次, + /// 而不是每行重复),「新建标签页」也从标签页组挪进来:它确实继承那个 + /// 目录,组名已经把「在此」说清楚了。 + #[test] + fn cwd_group_appears_only_with_a_working_directory() { + let mut palette = CommandPalette::new(); + let without: Vec<_> = palette + .group_labels(palette.filtered.len()) + .unwrap() + .into_iter() + .flatten() + .map(|(label, _)| label) + .collect(); + assert!(!without.iter().any(|l| l == "工作目录"), "没有 cwd 就没有这一组"); + assert!(!palette.filtered.iter().any(|&c| matches!( + c, + PaletteCandidate::Item(i) if ITEMS[i].action == PaletteAction::CopyCwd + ))); + + palette.set_context(PaletteContext { + cwd: Some(PathBuf::from("D:\\temp_build\\nebula")), + sidebar: true, + panel_resize: false, + new_tab_inherits_cwd: true, + }); + let labels = palette.group_labels(palette.filtered.len()).unwrap(); + let (label, context) = labels[0].as_ref().expect("第一行起一条表头"); + assert_eq!(label, "工作目录", "有 cwd 时它排最前"); + assert_eq!(context, "D:\\temp_build\\nebula", "路径挂在表头右缘"); + assert_eq!( + palette.command_group(palette.filtered[0]), + CommandGroup::Cwd, + "「新建标签页」跟着进工作目录组" + ); + + // 配了启动目录:新标签页开在别处,于是它退回标签页组,而复制 / 定位 + // 两条仍然作用在 cwd 上——组还在,只是少一行。 + palette.set_context(PaletteContext { + cwd: Some(PathBuf::from("D:\\temp_build\\nebula")), + sidebar: true, + panel_resize: false, + new_tab_inherits_cwd: false, + }); + let new_tab = ITEMS.iter().position(|item| item.action == PaletteAction::NewTab).unwrap(); + assert_eq!( + palette.command_group(PaletteCandidate::Item(new_tab)), + CommandGroup::Tabs, + "开不在那里就不能挂在工作目录标题下" + ); + assert!(palette + .group_labels(palette.filtered.len()) + .unwrap() + .into_iter() + .flatten() + .any(|(label, _)| label == "工作目录")); + } + + /// 开关类命令带勾选态,一次性动作不带。列表里同时有两类时,✓ 是唯一 + /// 能把它们分开的信号;`None` 与 `Some(false)` 的区别不能丢——后者是 + /// 「开关,当前关着」,前者是「这行根本不是开关」。 + #[test] + fn toggle_rows_carry_their_current_state() { + let mut palette = CommandPalette::new(); + palette.set_context(PaletteContext { + cwd: None, + sidebar: true, + panel_resize: false, + new_tab_inherits_cwd: true, + }); + let row_of = |palette: &CommandPalette, action: PaletteAction| { + let index = ITEMS.iter().position(|item| item.action == action).unwrap(); + palette.row_for(PaletteCandidate::Item(index)).checked + }; + assert_eq!(row_of(&palette, PaletteAction::ToggleSidebar), Some(true)); + assert_eq!(row_of(&palette, PaletteAction::TogglePanelResize), Some(false)); + assert_eq!(row_of(&palette, PaletteAction::NewTab), None, "一次性动作不是开关"); + } + + /// 表头跟着滚动窗口走。此前 `group_labels` 取的是 `filtered` 的**前 N + /// 条**、行取的是滚动后的窗口,两边基准不同:往下翻一页,行换了、表头 + /// 还写着列表开头那几组,标题于是指着一批已经滚走的行。 + #[test] + fn headers_follow_the_scrolled_window() { + const MAX_ROWS: usize = 4; + let mut palette = CommandPalette::new(); + palette.open(); + palette.set_context(PaletteContext { + cwd: Some(PathBuf::from("D:\\temp_build\\nebula")), + sidebar: false, + panel_resize: false, + new_tab_inherits_cwd: true, + }); + assert!(palette.filtered.len() > MAX_ROWS * 2, "要够长才能翻页"); + + let group_at = |palette: &CommandPalette, index: usize| { + palette.command_group(palette.filtered[index]) + }; + let top_label = |palette: &CommandPalette| { + palette.group_labels(MAX_ROWS).unwrap()[0].as_ref().map(|(l, _)| l.clone()) + }; + assert_eq!(top_label(&palette), Some(group_at(&palette, 0).label(palette.language))); + + // 一路下翻到第 9 行:窗口变成 6..10,表头必须跟着换成第 6 行那一组。 + palette.move_selection(9); + let start = palette.scroll_start(MAX_ROWS); + assert_eq!(start, 6, "选中行停在底行"); + let labels = palette.group_labels(MAX_ROWS).unwrap(); + let (rows, selected) = palette.visible(MAX_ROWS); + assert_eq!(labels.len(), rows.len(), "表头与行必须逐条对齐"); + assert_eq!(selected, Some(MAX_ROWS - 1)); + assert_eq!( + top_label(&palette), + Some(group_at(&palette, start).label(palette.language)), + "窗口首行永远起一条表头,写的是**它自己**那一组" + ); + // 窗口内其余各行只在换组时起表头。 + for (offset, label) in labels.iter().enumerate().skip(1) { + let changed = group_at(&palette, start + offset) != group_at(&palette, start + offset - 1); + assert_eq!(label.is_some(), changed, "第 {offset} 行的表头判定与实际换组不符"); + } + } + + /// 分组表头的前提是同来源的行**连续**。模糊搜索按得分排序会把两家的 + /// 会话打散,所以 refilter 末尾必须再做一次稳定划分;打头的那一组是 + /// 拥有最新一条会话的来源,「我刚才在做的那件事」不会被压到第二组。 + #[test] + fn ai_sessions_stay_grouped_by_source_even_while_searching() { + use crate::ai_sessions::AiSessionSource::{Claude, Codex}; + let row = |source, label: &str| AiSessionRow { + label: label.to_owned(), + hint: String::new(), + search: label.to_owned(), + command: String::new(), + source, + }; + let mut palette = CommandPalette::new(); + // 交错到达,最新的一条(首行)是 claude。 + palette.open_ai_sessions(vec![ + row(Claude, "aa 修复"), + row(Codex, "ab 修复"), + row(Claude, "ac 修复"), + row(Codex, "ad 修复"), + ]); + let sources = |p: &CommandPalette| -> Vec<_> { + p.filtered + .iter() + .map(|c| match c { + PaletteCandidate::AiSession(i) => p.ai_sessions[*i].source, + _ => unreachable!(), + }) + .collect() + }; + assert_eq!(sources(&palette), vec![Claude, Claude, Codex, Codex], "空查询就该分好组"); + let labels = palette.group_labels(4).unwrap(); + assert_eq!(labels[0].as_ref().map(|(l, _)| l.as_str()), Some("CLAUDE CODE 会话")); + assert_eq!(labels[1], None, "组内第二行不再起表头"); + assert_eq!(labels[2].as_ref().map(|(l, _)| l.as_str()), Some("CODEX 会话")); + assert_eq!(labels[3], None); + } + + #[test] + fn picker_layout_rows_are_non_uniform_and_hit_test_matches() { + let mut palette = CommandPalette::new(); + palette.set_shell_menu( + &[ + shell("CMD", "cmd", r"C:\Windows\System32\cmd.exe"), + shell("PowerShell", "powershell", r"C:\Windows\...\powershell.exe"), + ], + &[], + "powershell", + ); + palette.open_profiles(); + let layout = palette_layout(&palette, 1600.0, 900.0, 1.0); + assert_eq!(layout.rows.len(), 2); + let (hero_y, hero_h) = layout.rows[0]; + let (row_y, row_h) = layout.rows[1]; + assert!(hero_h > row_h, "推荐大卡片必须比普通卡片高"); + assert_eq!(layout.groups.len(), 2, "hero 版式仍是「推荐 / 所有选项」两条表头"); // 命中测试与逐行矩形一致;卡片之间的缝隙与分区标题不可点。 + let (px, ..) = layout.panel; + assert_eq!(layout.row_at(px + 10.0, hero_y + hero_h / 2.0), Some(0)); + assert_eq!(layout.row_at(px + 10.0, row_y + row_h / 2.0), Some(1)); + assert_eq!(layout.row_at(px + 10.0, hero_y - 2.0), None, "分区标题不是行"); + assert_eq!(layout.row_at(px - 20.0, hero_y + 2.0), None, "面板外不命中"); + } + + #[test] + fn command_layout_rows_stay_uniform() { + let mut palette = CommandPalette::new(); + palette.open(); + let layout = palette_layout(&palette, 1600.0, 900.0, 1.0); + assert_eq!(layout.rows.len(), layout.max_rows.min(palette.filtered.len())); + for pair in layout.rows.windows(2) { + assert_eq!(pair[0].1, pair[1].1, "命令列表保持等高行"); + assert_eq!(pair[1].0 - pair[0].0, pair[0].1, "无缝隙"); + } + } + + #[test] + fn path_column_clears_the_widest_label() { + // 列位置由最宽的一行决定,其余行因此不可能与自己的标签冲突。 + let (ix, iw, cell_w, scale) = (960.0, 770.0, 14.0, 1.25); + 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 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!(cols <= 10, "按显示列宽裁剪,实测 {cols} 列"); + } + + #[test] + fn select_all_copy_and_paste_replace_the_query() { + let mut palette = CommandPalette::new(); + palette.open(); + palette.input_text("old query"); + palette.select_all(); + assert_eq!(palette.selected_text().as_deref(), Some("old query")); + palette.input_text("new\r\nquery"); + assert_eq!(palette.query(), "newquery"); + assert!(!palette.query_all_selected()); + } +} diff --git a/nebula_app/src/display/content.rs b/nebula_app/src/display/content.rs index 9d58de8..aa896fa 100644 --- a/nebula_app/src/display/content.rs +++ b/nebula_app/src/display/content.rs @@ -15,9 +15,9 @@ use nebula_terminal::vte::ansi::{Color, CursorShape, NamedColor}; use crate::config::UiConfig; use crate::config::color::{InvertedCellColors, NEBULA_DEFAULT_CURSOR}; use crate::display::color::{CellRgb, DIM_FACTOR, List, Rgb}; -use crate::display::ui::tokens::terminal_feedback; use crate::display::hint::{self, HintState}; use crate::display::terminal_color::{TerminalColorResolver, is_fixed_color}; +use crate::display::ui::tokens::terminal_feedback; use crate::display::{Display, SizeInfo}; use crate::event::SearchState; diff --git a/nebula_app/src/display/context_menu.rs b/nebula_app/src/display/context_menu.rs index 84fcabf..d0d2bd8 100644 --- a/nebula_app/src/display/context_menu.rs +++ b/nebula_app/src/display/context_menu.rs @@ -37,7 +37,10 @@ pub enum ContextMenuTarget { SftpPanel, /// 本地文件树(侧栏 Files 视图)的一行。`is_dir` 决定首行是「打开」 /// 还是「在此处打开终端」。row 是可见行索引,执行时再验证路径。 - FileTree { row: usize, is_dir: bool }, + FileTree { + row: usize, + is_dir: bool, + }, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/nebula_app/src/display/file_dialog/folder.rs b/nebula_app/src/display/file_dialog/folder.rs index 12da7ca..d7e9bc3 100644 --- a/nebula_app/src/display/file_dialog/folder.rs +++ b/nebula_app/src/display/file_dialog/folder.rs @@ -8,13 +8,14 @@ use windows_sys::Win32::System::Com::{ CoInitializeEx, CoTaskMemFree, CoUninitialize, }; use windows_sys::Win32::UI::Shell::{ - FILEOPENDIALOGOPTIONS, FOS_FORCEFILESYSTEM, FOS_NOCHANGEDIR, FOS_PATHMUSTEXIST, - FOS_PICKFOLDERS, FileOpenDialog, SIGDN, SIGDN_FILESYSPATH, + FDAP_TOP, FILEOPENDIALOGOPTIONS, FOS_FORCEFILESYSTEM, FOS_NOCHANGEDIR, FOS_PATHMUSTEXIST, + FOS_PICKFOLDERS, FileOpenDialog, SHCreateItemFromParsingName, SIGDN, SIGDN_FILESYSPATH, }; use windows_sys::core::{GUID, HRESULT, PCWSTR, PWSTR}; use winit::raw_window_handle::RawWindowHandle; const FILE_OPEN_DIALOG_IID: GUID = GUID::from_u128(0xd57c7288_d4ad_4768_be02_9d969532d960); +const SHELL_ITEM_IID: GUID = GUID::from_u128(0x43826d1e_e718_42ee_bc55_a1e261c37bfe); const HRESULT_CANCELLED: HRESULT = 0x800704c7_u32 as HRESULT; #[repr(C)] @@ -60,7 +61,8 @@ struct IFileDialogVTable { _set_ok_button_label: usize, _set_file_name_label: usize, get_result: unsafe extern "system" fn(this: *mut c_void, item: *mut *mut c_void) -> HRESULT, - _add_place: usize, + add_place: + unsafe extern "system" fn(this: *mut c_void, item: *mut c_void, placement: i32) -> HRESULT, _set_default_extension: usize, _close: usize, _set_client_guid: usize, @@ -191,6 +193,32 @@ pub(super) fn pick(owner: RawWindowHandle, title: &str) -> Option { return None; } + // WSL 发行版钉进侧栏(issue #12:系统的「Linux」导航节点不是每台 + // Win11 都有,钉进去的入口才保证在)。逐个失败静默跳过——发行版 + // 停着时解析可能失败,这不该拦住对话框本身。 + // + // FDAP_TOP 是"插到最顶",逐个插会倒序——倒着喂,最终按字母序落位。 + for name in crate::shell_detect::wsl_distro_names().iter().rev() { + let unc = format!(r"\\wsl.localhost\{name}") + .encode_utf16() + .chain(std::iter::once(0)) + .collect::>(); + let mut item_pointer = std::ptr::null_mut(); + let result = unsafe { + SHCreateItemFromParsingName( + unc.as_ptr(), + std::ptr::null_mut(), + &SHELL_ITEM_IID, + &mut item_pointer, + ) + }; + if result < 0 { + continue; + } + let Some(item) = ComPtr::from_raw(item_pointer) else { continue }; + unsafe { (dialog_vtable.base.add_place)(dialog.0, item.0, FDAP_TOP) }; + } + let result = unsafe { (dialog_vtable.base.base.show)(dialog.0, owner) }; if result == HRESULT_CANCELLED { return None; diff --git a/nebula_app/src/display/keymap.rs b/nebula_app/src/display/keymap.rs index 627f5fe..e76daed 100644 --- a/nebula_app/src/display/keymap.rs +++ b/nebula_app/src/display/keymap.rs @@ -164,10 +164,7 @@ fn parse_key_name(lower: &str) -> Option { return Some(BindingKey::Scancode(PhysicalKey::Code(*code))); } if let Some((_, named)) = NAMED_ALIASES.iter().find(|(name, _)| *name == lower) { - return Some(BindingKey::Keycode { - key: Key::Named(*named), - location: KeyLocation::Any, - }); + return Some(BindingKey::Keycode { key: Key::Named(*named), location: KeyLocation::Any }); } let key = match lower { "esc" => Key::Named(NamedKey::Escape), @@ -323,9 +320,8 @@ pub(super) fn effective_combo(action: &Action, custom: &[KeyBinding]) -> Option< if binding.action != *action { continue; } - let shadowed_by_newer = custom[..index] - .iter() - .any(|b| b.trigger == binding.trigger && b.mods == binding.mods); + let shadowed_by_newer = + custom[..index].iter().any(|b| b.trigger == binding.trigger && b.mods == binding.mods); if !shadowed_by_newer { return display_combo(binding.mods, &binding.trigger).map(|text| (text, true)); } @@ -343,10 +339,7 @@ pub(super) fn effective_combo(action: &Action, custom: &[KeyBinding]) -> Option< !matches!( &b.trigger, BindingKey::Keycode { key: Key::Named(NamedKey::Copy | NamedKey::Paste), .. } - | BindingKey::Keycode { - location: KeyLocation::Numpad, - .. - } + | BindingKey::Keycode { location: KeyLocation::Numpad, .. } ) }) .find(|b| !shadowed(b)) @@ -390,9 +383,8 @@ pub(crate) fn capture_combo(key: &KeyEvent, mods: ModifiersState) -> CaptureOutc } let binding_key = match key.key_without_modifiers() { - Key::Named(named) => BindingKey::Keycode { - key: Key::Named(named), - location: KeyLocation::Any, + Key::Named(named) => { + BindingKey::Keycode { key: Key::Named(named), location: KeyLocation::Any } }, Key::Character(c) => { let lower = c.to_lowercase(); @@ -472,8 +464,7 @@ mod tests { fn custom_binding_shadows_default_in_display() { // 自定义 Ctrl+Shift+T → SplitRight 后:SplitRight 显示新键(自定义), // CreateNewTab 的默认 Ctrl+Shift+T 被遮蔽 → 显示未绑定。 - let raw = - vec![("ctrl+shift+t".to_owned(), "SplitRight".to_owned())]; + let raw = vec![("ctrl+shift+t".to_owned(), "SplitRight".to_owned())]; let custom = build_bindings(&raw); assert_eq!( effective_combo(&Action::SplitRight, &custom), @@ -533,9 +524,7 @@ mod tests { for (combo, action) in expect { let (mods, key) = parse_combo(combo).unwrap(); assert!( - defaults - .iter() - .any(|b| b.action == action && b.mods == mods && b.trigger == key), + defaults.iter().any(|b| b.action == action && b.mods == mods && b.trigger == key), "default table is missing {combo} → {action:?}" ); } diff --git a/nebula_app/src/display/mod.rs b/nebula_app/src/display/mod.rs index 344fea6..3c09aa4 100644 --- a/nebula_app/src/display/mod.rs +++ b/nebula_app/src/display/mod.rs @@ -109,9 +109,9 @@ pub use state::{ mod file_dialog; pub(crate) mod keymap; mod settings; +pub(crate) mod ssh_connect; mod ssh_editor_input; mod ssh_editor_render; -pub(crate) mod ssh_connect; mod ssh_ui; mod text_input; @@ -132,8 +132,8 @@ pub(crate) use ui::theme::write_nebula_prompt_theme; pub(crate) fn caret_blink_on() -> bool { ui::caret::is_on() } -pub use settings::{NebulaSettingsSection, SettingsDropdown, SettingsHit, settings_hit}; pub(crate) use settings::SettingsOpacityTarget; +pub use settings::{NebulaSettingsSection, SettingsDropdown, SettingsHit, settings_hit}; /// 按显示列宽贪心断行(确认框正文等 UI 段落用):CJK 逐字可断,行首空 /// 格吞掉;零宽字符跟随前一个字。不做拉丁连词回退——正文以中文为主, @@ -215,6 +215,12 @@ pub(crate) const NEBULA_UNFOCUSED_SPLIT_DIM: f32 = 0.30; /// terminal card, so the first grid row doesn't touch the card's top edge. pub const CHROME_BAR_LOGICAL: f32 = 64.0; +/// 「刚完成」对勾在徽章位上停留多久,随后落回未读圆点。 +/// +/// 短到不像一个需要处理的状态、长到能被余光捕捉:低于 ~0.6s 在扫视中会被 +/// 整个错过,高于 ~2s 就开始像"它卡在完成态上了"。 +pub(crate) const BADGE_FLASH: std::time::Duration = std::time::Duration::from_millis(1100); + /// Shared chrome/control corner radius. Used for the small in-shell affordances /// (window-control hover pills, tab pills, the "+" square) — kept modest so the /// controls stay crisp. @@ -266,14 +272,64 @@ pub fn content_pad_x(scale_factor: f32) -> f32 { /// close affordance, narrow enough to leave the grid roomy. pub const SIDEBAR_W_LOGICAL: f32 = 230.0; +/// 拖拽调节(设置·交互开关)允许的范围,逻辑 px。下限保行内容可读, +/// 上限防把终端挤成一条缝;settings 解析与拖拽 update 用同一组钳制, +/// 手拖出来的值和手改文件写出来的值才不会各有一套边界。 +pub const SIDEBAR_W_MIN: f32 = 170.0; +pub const SIDEBAR_W_MAX: f32 = 420.0; +pub const DRAWER_W_MIN: f32 = 220.0; +pub const DRAWER_W_MAX: f32 = 560.0; +/// SSH HOSTS 停靠区高度覆盖的下限 = 只剩标题条(`hosts_header_h` 的逻辑值)。 +pub const HOSTS_BAND_MIN: f32 = 38.0; + /// Sidebar width in physical pixels for `scale_factor`, honouring the collapsed -/// state. Collapsed folds the panel away entirely (0) so the grid reclaims the -/// full width; the reveal affordance then lives in the top bar. +/// state. `logical_w` 是当前(可能被拖拽调过的)逻辑宽,[`SIDEBAR_W_LOGICAL`] +/// 只是它的默认值。 #[inline] -pub fn sidebar_width(scale_factor: f32, collapsed: bool) -> f32 { - if collapsed { 0.0 } else { (SIDEBAR_W_LOGICAL * scale_factor).round() } +pub fn sidebar_width(scale_factor: f32, collapsed: bool, logical_w: f32) -> f32 { + if collapsed { 0.0 } else { (logical_w * scale_factor).round() } } +/// 三条可拖拽的面板分界线(设置·交互的「拖拽调节」开关管辖)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PanelDragKind { + /// 左侧栏右缘:拖宽度。 + SidebarWidth, + /// SSH HOSTS 停靠区顶缘:拖高度。 + HostsBand, + /// 右抽屉左缘:拖宽度。 + DrawerWidth, +} + +/// 进行中的面板拖拽。侧栏/抽屉的宽度变化会重排终端(PTY 端另有 settle +/// 延迟),所以应用端按 [`PANEL_DRAG_REFLOW_MS`] 节流:**视觉**几何每帧 +/// 跟手(chrome/抽屉布局读 `target`),**reflow** 用的已应用字段到点才 +/// 同步,松手必同步——拖动过程平滑,网格重排最多 12 次/秒。 +#[derive(Debug, Clone, Copy)] +pub struct PanelDrag { + pub kind: PanelDragKind, + /// 拖动中的目标值(逻辑 px):宽度或停靠区高度。 + pub target: f32, + /// 上次把 `target` 同步进已应用字段的时刻。 + pub last_apply: std::time::Instant, + /// HOSTS 分界专用:按下时缓存的停靠区内容底缘(物理 px)。它只取决于 + /// 面板几何、与停靠区自身高度无关,所以整场拖拽都不会变——缓存下来, + /// 每次指针移动就不必重跑一遍 `chrome_tab_layout`。 + pub anchor: f32, +} + +/// 拖动期间两次终端 reflow 的最小间隔(毫秒)。 +pub const PANEL_DRAG_REFLOW_MS: u64 = 80; + +/// 侧栏拖到比这更窄(逻辑 px)就直接收起,而不是卡在 [`SIDEBAR_W_MIN`]。 +/// 用户裁定:下限的语义是「关掉」不是「最窄」——把边界一路推到左边缘是 +/// 最自然的收起手势。宽度字段保持折叠前的值,重新展开还是原来那么宽。 +pub const SIDEBAR_COLLAPSE_AT: f32 = 120.0; + +/// 右抽屉的同款阈值:拖到比这更窄就关掉抽屉。两侧手势必须对称,否则 +/// 「左边拖到头会关、右边拖到头只是卡住」本身就是个 bug(用户 08-02 报)。 +pub const DRAWER_COLLAPSE_AT: f32 = 150.0; + #[derive(Debug, Clone, Copy)] struct UiAnim { spring: crate::motion::Spring, @@ -778,6 +834,17 @@ pub(crate) fn nebula_settings_value(key: &str) -> Option { }) } +/// 启动时是否回放 `session.json`(设置·高级→会话,默认开)。 +/// +/// 事件循环在建窗之前就要问这一句,那时还没有 `Display`,也没有 `UiConfig` +/// 之外的东西——所以走原始读取而不是 `settings::nebula_settings_load`: +/// 后者是 `pub(super)`,且会为了一个 bool 解析整份设置。 +pub(crate) fn restore_session_enabled() -> bool { + nebula_settings_value("restore_session") + .map(|v| !matches!(v.trim().to_ascii_lowercase().as_str(), "0" | "false" | "no" | "off")) + .unwrap_or(true) +} + #[cfg(windows)] fn nebula_pathexts() -> Vec { std::env::var("PATHEXT") @@ -1180,7 +1247,7 @@ pub struct Display { nebula_ssh_editor_hover: SshEditorHit, /// 正在拖选的字段。鼠标按在输入框里时置位,松开清掉——拖拽的语义是 /// "从按下的那个字符拉到现在这个字符",所以中途划出框外也要继续跟。 - nebula_ssh_editor_drag: Option, + nebula_ssh_editor_drag: Option, /// 「测试连接」点击时暂存的请求;input 层随后取走并交给 SSH runtime。 /// display 不持有事件代理,这一格就是点击→网络之间的交接台。 nebula_ssh_test_request: Option, @@ -1246,6 +1313,15 @@ pub struct Display { nebula_tab_bells: Vec, /// Per-tab "command is running" flags driving the sidebar spinners. nebula_tab_running: Vec, + /// 每个标签是否停在「等你批准」上,画手掌而不是圆点。 + nebula_tab_attention: Vec, + /// 每个 tab 的 shell 短标(pwsh / cmd / ubuntu / ssh…),空 = 不显示。 + /// 静默行(无任何徽章)的右侧亮它,回答"这个 tab 是什么环境"。 + nebula_tab_shells: Vec, + /// 上一条命令非零退出且未被看到,画警示三角。 + nebula_tab_failed: Vec, + /// 刚成功收尾,正在放对勾闪现([`BADGE_FLASH`] 之内)。 + nebula_tab_flashing: Vec, /// Per-tab real AI brand logo, textured over the icon slot. nebula_tab_logos: Vec>, /// Decoded (and, where appropriate, theme-tinted) logo pixels with stable renderer texture ids, @@ -1273,6 +1349,20 @@ pub struct Display { /// Whether the tab sidebar is folded away. When collapsed the grid /// reclaims the full width and only a reveal button remains in the top bar. nebula_sidebar_collapsed: bool, + /// 左侧栏逻辑宽(拖拽调节的**已应用**值——reflow/持久化读它)。 + nebula_sidebar_w: f32, + /// 右抽屉逻辑宽(同上;布局时仍钳在窗口 42%)。 + nebula_drawer_w: f32, + /// SSH HOSTS 停靠区高度覆盖(逻辑 px),0 = 自动弹性规则。 + nebula_hosts_band: f32, + /// 「拖拽调节侧栏」总开关(设置·交互,默认关,开启需过确认框)。 + pub nebula_panel_resize: bool, + /// 聚焦 pane 的工作目录(shell 通过标题上报),每帧由 `draw` 灌进来。 + /// 命令面板的「工作目录」组用它:组名右缘挂路径,组里的复制 / 定位 / + /// 新建标签页都作用在它身上。`None` = shell 没上报,那一组整组不出现。 + pub nebula_focused_cwd: Option, + /// 进行中的面板分界线拖拽(见 [`PanelDrag`])。 + pub nebula_panel_drag: Option, /// SSH host aliases from `~/.ssh/config` for the sidebar's "SSH HOSTS" /// section, pinned entries first (see `nebula_pinned_hosts`). pub nebula_ssh_hosts: Vec, @@ -1291,6 +1381,8 @@ pub struct Display { /// 地址 → 用户起的显示名,从 `ssh_profiles.json` 缓存而来。侧栏每帧都要 /// 画这些行,读文件必须发生在保存那一刻,而不是绘制路径上。 nebula_ssh_labels: std::collections::HashMap, + /// 地址 → 图标 id(`ui::os_icons`),缓存策略同上。缺项 = 自动。 + nebula_ssh_icons: std::collections::HashMap, /// Accordion fold state of the two sidebar sections. nebula_tabs_section_open: bool, nebula_hosts_section_open: bool, @@ -1324,6 +1416,9 @@ pub struct Display { /// Closing a window detaches its panes into the resident process for /// re-attach (multiplexer restore). Off = close kills the shells. pub nebula_keep_session: bool, + /// 启动时回放 `session.json`(正常关窗与崩溃恢复共用这一条路)。关掉 + /// 只是不回放——快照照写,导出工作区与崩溃诊断仍然可用。 + pub nebula_restore_session: bool, /// Runtime window opacity controlled from Nebula settings. pub nebula_window_opacity: f32, /// Which settings combobox (floating option list) is expanded, if any. @@ -1537,11 +1632,8 @@ fn send_to_recycle_bin(path: &std::path::Path) -> Result<(), String> { #[cfg(not(windows))] fn send_to_recycle_bin(path: &std::path::Path) -> Result<(), String> { // 非 Windows 暂无回收站集成:递归删除前置确认已由弹窗承担。 - let result = if path.is_dir() { - std::fs::remove_dir_all(path) - } else { - std::fs::remove_file(path) - }; + let result = + if path.is_dir() { std::fs::remove_dir_all(path) } else { std::fs::remove_file(path) }; result.map_err(|err| err.to_string()) } @@ -1635,8 +1727,14 @@ impl Display { .metrics_at(base_font_size) .map(|base_metrics| compute_cell_size(config, &base_metrics)) .unwrap_or((cell_width, cell_height)); - let size = - window_size(config, dimensions, base_cell_width, base_cell_height, scale_factor); + let size = window_size( + config, + dimensions, + base_cell_width, + base_cell_height, + scale_factor, + settings_init.sidebar_w, + ); window.request_inner_size(size); // Create the GL surface to draw into. @@ -1676,7 +1774,7 @@ impl Display { viewport_size.height as f32, cell_width, cell_height, - padding.0 + content_pad + sidebar_width(scale, false), + padding.0 + content_pad + sidebar_width(scale, false, settings_init.sidebar_w), padding.0 + content_pad, padding.1 + chrome, padding.1 + bottom_content_reserve(scale), @@ -1877,6 +1975,10 @@ impl Display { nebula_tab_colors: vec![None], nebula_tab_bells: vec![false], nebula_tab_running: vec![false], + nebula_tab_attention: vec![false], + nebula_tab_shells: vec![String::new()], + nebula_tab_failed: vec![false], + nebula_tab_flashing: vec![false], nebula_tab_logos: vec![None], nebula_ai_logo_cache: Default::default(), nebula_shell_icon_cache: Default::default(), @@ -1885,6 +1987,12 @@ impl Display { nebula_tab_drag: None, nebula_tabs_reorderable: true, nebula_sidebar_collapsed: false, + nebula_sidebar_w: settings_init.sidebar_w, + nebula_drawer_w: settings_init.drawer_w, + nebula_hosts_band: settings_init.hosts_band, + nebula_panel_resize: settings_init.panel_resize, + nebula_focused_cwd: None, + nebula_panel_drag: None, nebula_ssh_hosts: merge_ssh_hosts( &settings_init.saved_hosts, &settings_init.pinned_hosts, @@ -1898,6 +2006,11 @@ impl Display { ) .map(|profiles| profiles.labels()) .unwrap_or_default(), + nebula_ssh_icons: crate::ssh_profiles::SshProfiles::load( + &nebula_data_dir().join("ssh_profiles.json"), + ) + .map(|profiles| profiles.icons()) + .unwrap_or_default(), nebula_tabs_section_open: true, nebula_hosts_section_open: true, nebula_tabs_scroll: 0, @@ -1916,6 +2029,7 @@ impl Display { nebula_powerline_enabled: settings_init.powerline, nebula_blur: settings_init.blur, nebula_keep_session: settings_init.keep_session, + nebula_restore_session: settings_init.restore_session, nebula_window_opacity: settings_init.opacity, nebula_background: if settings_init.follow_system_theme { Some(nebula_theme.palette().term_bg) @@ -2129,7 +2243,11 @@ impl Display { mut colors: Vec>, mut dots: Vec, mut running: Vec, + mut attention: Vec, + mut failed: Vec, + mut flashing: Vec, mut logos: Vec>, + mut shells: Vec, active: usize, reorderable: bool, ) { @@ -2143,9 +2261,21 @@ impl Display { running.truncate(self.nebula_tab_labels.len()); running.resize(self.nebula_tab_labels.len(), false); self.nebula_tab_running = running; + attention.truncate(self.nebula_tab_labels.len()); + attention.resize(self.nebula_tab_labels.len(), false); + self.nebula_tab_attention = attention; + failed.truncate(self.nebula_tab_labels.len()); + failed.resize(self.nebula_tab_labels.len(), false); + self.nebula_tab_failed = failed; + flashing.truncate(self.nebula_tab_labels.len()); + flashing.resize(self.nebula_tab_labels.len(), false); + self.nebula_tab_flashing = flashing; logos.truncate(self.nebula_tab_labels.len()); logos.resize(self.nebula_tab_labels.len(), None); self.nebula_tab_logos = logos; + shells.truncate(self.nebula_tab_labels.len()); + shells.resize(self.nebula_tab_labels.len(), String::new()); + self.nebula_tab_shells = shells; self.nebula_active_tab = active.min(self.nebula_tab_labels.len().saturating_sub(1)); self.nebula_tabs_reorderable = reorderable; // A tab count change (close/open) mid-drag invalidates the grabbed slot. @@ -2154,6 +2284,12 @@ impl Display { } } + /// 有标签正在放对勾闪现。闪现靠挂钟判定,没有帧驱动它就会停在对勾上 + /// 直到下一次因为别的原因重绘——所以这段时间要让 chrome 时钟继续走。 + pub fn any_tab_flashing(&self) -> bool { + self.nebula_tab_flashing.iter().any(|f| *f) + } + /// Whether any sidebar tab currently shows a running spinner. Only this /// state raises the chrome clock to display-rate frames. pub fn any_tab_running(&self) -> bool { @@ -2610,6 +2746,192 @@ impl Display { self.pending_update.dirty = true; } + /// 侧栏的**视觉**逻辑宽:拖动中读 target(每帧跟手),否则读已应用值。 + /// chrome 布局与卡片几何用它;reflow(`handle_update` 的 padding)只认 + /// `nebula_sidebar_w`——两者的差就是节流窗口内允许的短暂错位。 + fn sidebar_w_visual(&self) -> f32 { + match self.nebula_panel_drag { + Some(d) if d.kind == PanelDragKind::SidebarWidth => d.target, + _ => self.nebula_sidebar_w, + } + } + + /// 右抽屉的视觉逻辑宽(同 [`Self::sidebar_w_visual`] 的拖动语义)。 + fn drawer_w_visual(&self) -> f32 { + match self.nebula_panel_drag { + Some(d) if d.kind == PanelDragKind::DrawerWidth => d.target, + _ => self.nebula_drawer_w, + } + } + + /// 指针是否落在三条可拖分界线之一。热区 ±4 逻辑 px;动画进行中不给热区 + /// ——滑动中的边缘抓不准。 + /// + /// 两条**宽度**分界(侧栏右缘、抽屉左缘)要拖动会重排终端,归「拖拽调节」 + /// 开关管;SSH HOSTS 分界只在侧栏内部分配高度,不碰网格,所以默认就能拖, + /// 不受开关约束(用户 08-02 裁定)。 + pub fn panel_resize_hit(&self, x: f32, y: f32) -> Option { + let scale = self.window.scale_factor as f32; + let grip = 4.0 * scale; + if self.nebula_panel_resize + && self.side_panel_visible() + && self.nebula_ui_anims.right_drawer.value() > 0.996 + { + let (px, py, _, ph) = self.side_panel_layout().panel; + if y >= py && y <= py + ph && (x - px).abs() <= grip { + return Some(PanelDragKind::DrawerWidth); + } + } + if self.left_sidebar_visible() && self.left_sidebar_progress() > 0.996 { + let layout = + chrome::chrome_tab_layout(&self.ui_size_info(), scale, self.sidebar_model(), 1.0); + let (px, py, pw, ph) = layout.panel; + if pw > 0.0 { + if self.nebula_panel_resize + && y >= py + && y <= py + ph + && (x - (px + pw)).abs() <= grip + { + return Some(PanelDragKind::SidebarWidth); + } + // HOSTS 分界 = 停靠区标题条的顶缘。 + let (hx, hy, hw, _) = layout.hosts_header; + if self.nebula_hosts_section_open + && hw > 0.0 + && x >= hx + && x <= hx + hw + && (y - hy).abs() <= grip + { + return Some(PanelDragKind::HostsBand); + } + } + } + None + } + + /// input 层在分界线上按下时开启一场拖拽。 + pub fn begin_panel_drag(&mut self, kind: PanelDragKind) { + let target = match kind { + PanelDragKind::SidebarWidth => self.nebula_sidebar_w, + PanelDragKind::DrawerWidth => self.nebula_drawer_w, + PanelDragKind::HostsBand => self.nebula_hosts_band.max(HOSTS_BAND_MIN), + }; + let anchor = if kind == PanelDragKind::HostsBand { + let scale = self.window.scale_factor as f32; + chrome::chrome_tab_layout(&self.ui_size_info(), scale, self.sidebar_model(), 1.0) + .dock_content_bottom + } else { + 0.0 + }; + self.nebula_panel_drag = + Some(PanelDrag { kind, target, last_apply: std::time::Instant::now(), anchor }); + } + + /// 拖动中的指针移动:换算目标值。HOSTS 分界纯 chrome 内部、即时生效; + /// 两个宽度分界的**视觉**几何每帧跟手,真正的 reflow 要同时满足两道闸 + /// ——[`PANEL_DRAG_REFLOW_MS`] 的时间节流,以及位移至少跨过一个单元格 + /// 宽度。后者才是重点:网格列数只在跨过整数列时才会变,同一列内反复 + /// reflow 是纯浪费。返回 true = 需要重绘。 + pub fn update_panel_drag(&mut self, x: f32, y: f32) -> bool { + let scale = self.window.scale_factor as f32; + let Some(drag) = self.nebula_panel_drag else { return false }; + let target = match drag.kind { + // chrome_tab_layout:panel_x = margin(8)、panel_w = sw - 8 - 12, + // 右缘 = sw - 12 ⇒ sw = x + 12(都在逻辑座标系里算)。 + PanelDragKind::SidebarWidth => { + let raw = x / scale + 12.0; + if raw < SIDEBAR_COLLAPSE_AT { + // 推到左边缘 = 收起。这场拖拽就此结束(侧栏没了,分界线 + // 也就没了),宽度字段保持不动。 + self.nebula_panel_drag = None; + if !self.nebula_sidebar_collapsed { + self.toggle_sidebar(); + } + self.persist_nebula_settings(); + return true; + } + raw.clamp(SIDEBAR_W_MIN, SIDEBAR_W_MAX) + }, + PanelDragKind::DrawerWidth => { + let w = (self.size_info.width() - 8.0 * scale - x) / scale; + if w < DRAWER_COLLAPSE_AT { + // 推到右边缘 = 关掉抽屉,与侧栏拖到最左同一手势。宽度 + // 字段不动,下次打开还是原来那么宽。不走 close_sftp_panel: + // 那条路会取消正在进行的传输,而这里只是把面板收起来。 + self.nebula_panel_drag = None; + if self.nebula_side_panel.open { + self.nebula_side_panel.open = false; + let size = PhysicalSize::new( + self.size_info.width() as u32, + self.size_info.height() as u32, + ); + self.pending_update.set_dimensions(size); + } + self.persist_nebula_settings(); + self.pending_update.dirty = true; + self.window.request_redraw(); + return true; + } + let cap = DRAWER_W_MAX.min(self.size_info.width() * 0.42 / scale); + w.clamp(DRAWER_W_MIN.min(cap), cap) + }, + PanelDragKind::HostsBand => ((drag.anchor - y) / scale).max(HOSTS_BAND_MIN), + }; + // 已应用值:宽度类要用它判断这次位移够不够跨一个单元格。 + let applied = match drag.kind { + PanelDragKind::SidebarWidth => self.nebula_sidebar_w, + PanelDragKind::DrawerWidth => self.nebula_drawer_w, + PanelDragKind::HostsBand => 0.0, + }; + let cell_w = self.size_info.cell_width().max(1.0); + let Some(drag) = self.nebula_panel_drag.as_mut() else { return false }; + if (target - drag.target).abs() < 0.5 { + return false; + } + drag.target = target; + let due = drag.last_apply.elapsed() >= std::time::Duration::from_millis(PANEL_DRAG_REFLOW_MS) + && (target - applied).abs() * scale >= cell_w; + match drag.kind { + PanelDragKind::HostsBand => self.nebula_hosts_band = target, + PanelDragKind::SidebarWidth | PanelDragKind::DrawerWidth if due => { + drag.last_apply = std::time::Instant::now(); + self.apply_panel_drag_target(); + }, + _ => {}, + } + self.pending_update.dirty = true; + self.window.request_redraw(); + true + } + + /// 把 target 同步进已应用字段,宽度类走 toggle_sidebar 同款 reflow 触发。 + fn apply_panel_drag_target(&mut self) { + let Some(drag) = self.nebula_panel_drag else { return }; + match drag.kind { + PanelDragKind::SidebarWidth => self.nebula_sidebar_w = drag.target, + PanelDragKind::DrawerWidth => self.nebula_drawer_w = drag.target, + PanelDragKind::HostsBand => { + self.nebula_hosts_band = drag.target; + return; + }, + } + let size = PhysicalSize::new(self.size_info.width() as u32, self.size_info.height() as u32); + self.pending_update.set_dimensions(size); + } + + /// 松开:最终应用 + 持久化。返回 true = 确有一场拖拽在收尾。 + pub fn end_panel_drag(&mut self) -> bool { + if self.nebula_panel_drag.is_none() { + return false; + } + self.apply_panel_drag_target(); + self.nebula_panel_drag = None; + self.persist_nebula_settings(); + self.pending_update.dirty = true; + self.window.request_redraw(); + true + } + /// Snapshot of the state the settings render reads, owning the wallpaper /// path so `draw_chrome` can still borrow `&mut renderer` afterwards. fn settings_view(&self) -> settings::SettingsView { @@ -2661,11 +2983,13 @@ impl Display { powerline: self.nebula_powerline_enabled, blur: self.nebula_blur, keep_session: self.nebula_keep_session, + restore_session: self.nebula_restore_session, opacity: self.nebula_window_opacity, dragging_opacity: self.nebula_settings_opacity_drag.map(|(target, _, _)| target), cursor_shape: self.nebula_cursor_shape, cursor_blink: self.nebula_cursor_blink, copy_on_select: self.nebula_copy_on_select, + panel_resize: self.nebula_panel_resize, cjk_bold_regular: self.nebula_cjk_bold_regular, tab_reveal: self.nebula_tab_reveal_motion, preview_bg: self.preview_terminal_bg(), @@ -3165,6 +3489,14 @@ impl Display { self.pending_update.dirty = true; } + /// 高级→会话: 启动时是否回放上次的标签。写进设置文件即可——真正读它的 + /// 是下次启动的 `create_initial_window`。 + pub fn toggle_restore_session(&mut self) { + self.nebula_restore_session = !self.nebula_restore_session; + self.persist_nebula_settings(); + self.pending_update.dirty = true; + } + pub fn begin_settings_opacity_drag(&mut self, target: SettingsOpacityTarget, pointer_x: f32) { let slider = settings::opacity_slider_rect( &self.ui_size_info(), @@ -3284,8 +3616,7 @@ impl Display { self.nebula_sync_inputs[2].clear(); self.nebula_sync_inputs[3].clear(); self.nebula_sync_auto_pull = cfg.auto_pull; - self.nebula_sync_secret_set = - [crate::sync::has_password(), crate::sync::has_passphrase()]; + self.nebula_sync_secret_set = [crate::sync::has_password(), crate::sync::has_passphrase()]; self.nebula_sync_focus = None; } @@ -3413,8 +3744,7 @@ impl Display { self.nebula_sync_busy = false; self.nebula_sync_status = Some((message.to_owned(), error)); // 拉取可能改写了设置文件的凭据外字段;存在性也可能被首存翻转。 - self.nebula_sync_secret_set = - [crate::sync::has_password(), crate::sync::has_passphrase()]; + self.nebula_sync_secret_set = [crate::sync::has_password(), crate::sync::has_passphrase()]; self.pending_update.dirty = true; self.window.request_redraw(); } @@ -3470,6 +3800,27 @@ impl Display { self.pending_update.dirty = true; } + /// 设置·交互「拖拽调节侧栏」开关。关→开要过一次确认框(宽度拖动会 + /// 实时重排终端,用户裁定必须明确告知);开→关直接生效。 + pub fn request_toggle_panel_resize(&mut self) { + if self.nebula_panel_resize { + self.nebula_panel_resize = false; + self.persist_nebula_settings(); + } else { + self.nebula_confirm = Some(NebulaConfirm::EnablePanelResize); + } + self.pending_update.dirty = true; + } + + /// 确认框「是」:真正开启拖拽调节。 + pub fn confirm_panel_resize(&mut self) { + self.nebula_confirm = None; + self.nebula_panel_resize = true; + self.persist_nebula_settings(); + self.pending_update.dirty = true; + self.window.request_redraw(); + } + pub fn confirm_background_image_cover_chrome(&mut self) { self.nebula_confirm = None; self.nebula_background_image_cover_chrome = true; @@ -3542,20 +3893,80 @@ impl Display { /// reloads are reflected. pub fn toggle_command_palette(&mut self, profiles: &[String]) { self.nebula_palette.set_profiles(profiles); + // 打开这一刻取一次窗口状态:「工作目录」组作用在哪个目录上、两个 + // 开关命令各自的勾选态。取样而不是每帧回读,见 `PaletteContext`。 + self.nebula_palette.set_context(command_palette::PaletteContext { + cwd: self.nebula_focused_cwd.clone(), + sidebar: !self.nebula_sidebar_collapsed, + panel_resize: self.nebula_panel_resize, + new_tab_inherits_cwd: self.startup_directory().is_none(), + }); self.nebula_palette.toggle(); self.pending_update.dirty = true; } + /// 在系统文件管理器里打开聚焦 pane 的工作目录。目录未知时什么也不做 + /// ——命令面板里那条命令此时根本不出现,这里只是兜底。 + pub fn reveal_focused_cwd(&mut self) { + let Some(path) = self.nebula_focused_cwd.clone() else { return }; + #[cfg(windows)] + let _ = std::process::Command::new("explorer.exe").arg(&path).spawn(); + #[cfg(target_os = "macos")] + let _ = std::process::Command::new("open").arg(&path).spawn(); + #[cfg(all(not(windows), not(target_os = "macos")))] + let _ = std::process::Command::new("xdg-open").arg(&path).spawn(); + self.pending_update.dirty = true; + } + + /// 聚焦 pane 的工作目录,给「复制路径」用(剪贴板在输入层,不在这里)。 + pub fn focused_cwd_string(&self) -> Option { + self.nebula_focused_cwd.as_ref().map(|path| path.display().to_string()) + } + + /// 默认 shell 的短标(settings 覆盖优先),给 `TabLaunch::Default` 的行用。 + pub fn default_shell_tag(&self) -> String { + let id = + self.nebula_shell_id.as_deref().unwrap_or_else(|| self.nebula_shell.settings_value()); + crate::shell_detect::shell_short_tag(id) + } + + /// 「恢复 AI 会话」面板:现扫 claude / codex 的本地会话档(最近 30 条, + /// 只读文件头取标题,见 `ai_sessions` 模块),确认后把 resume 命令行敲 + /// 进当前聚焦的终端。 + pub fn open_ai_session_palette(&mut self) { + let rows = crate::ai_sessions::scan(30) + .into_iter() + .map(|session| { + // 右列 = 「位置 · 相对时间」。来源不再挤进这段文字——行首 + // 品牌 logo + 右缘 chip 已经把 claude/codex 标满了(纯文字 + // 的来源在长标题下会被挤没,用户 08-02 截图的现场)。 + let time = crate::ai_sessions::relative_label(session.modified); + let place = session.place_label(); + let hint = if place.is_empty() { time } else { format!("{place} · {time}") }; + let search = + format!("{} {} {}", session.title, session.project, session.source.label()); + command_palette::AiSessionRow { + label: session.title.clone(), + hint, + search, + command: session.resume_command(), + source: session.source, + } + }) + .collect(); + self.nebula_palette.open_ai_sessions(rows); + self.pending_update.dirty = true; + self.window.request_redraw(); + } + /// Open the new-tab dropdown: detected shells (installed-shell order) plus /// any config profiles. Detection runs once and is cached — the chevron /// beside the "+" opens this, mirroring Windows Terminal's profile menu. pub fn open_shell_menu(&mut self, profiles: &[String]) { let shells = self.nebula_detected_shells.get_or_insert_with(crate::shell_detect::detect_shells); - let default_shell = self - .nebula_shell_id - .as_deref() - .unwrap_or_else(|| self.nebula_shell.settings_value()); + let default_shell = + self.nebula_shell_id.as_deref().unwrap_or_else(|| self.nebula_shell.settings_value()); self.nebula_palette.set_shell_menu(shells, profiles, default_shell); self.nebula_palette.open_profiles(); self.pending_update.dirty = true; @@ -3873,6 +4284,7 @@ impl Display { reserve, scale, self.nebula_ui_anims.right_drawer.value(), + self.drawer_w_visual(), ) } @@ -4152,7 +4564,7 @@ impl Display { // panel's right edge (`sw - 12` logical, see `chrome_tab_layout`); // resting collapsed: the chrome margin. let t = self.left_sidebar_progress().clamp(0.0, 1.0); - let sw = (SIDEBAR_W_LOGICAL * scale).round(); + let sw = (self.sidebar_w_visual() * scale).round(); let x = s(8.0) + t * (sw - s(4.0) - s(8.0)); // Top edge: the top bar's bottom (margin 8 + bar height 40, matching // `draw_chrome`), plus a seam so the card visibly floats below it. @@ -4160,7 +4572,8 @@ impl Display { // Right edge follows the file/git drawer the same way: as it slides // in, the card cedes its width (drawer width + margin) plus the seam. let dt = self.nebula_ui_anims.right_drawer.value().clamp(0.0, 1.0); - let drawer = dt * (side_panel::PANEL_W_LOGICAL * scale + s(8.0)); + let drawer = + dt * ((self.drawer_w_visual() * scale).min(self.size_info.width() * 0.42) + s(8.0)); let w = (self.size_info.width() - drawer - seam - x).max(0.0); let h = (self.size_info.height() - seam - y).max(0.0); (x, y, w, h) @@ -4180,6 +4593,8 @@ impl Display { hosts_open: self.nebula_hosts_section_open, tabs_scroll: self.nebula_tabs_scroll, hosts_scroll: self.nebula_hosts_scroll, + sidebar_w: self.sidebar_w_visual(), + hosts_band: self.nebula_hosts_band, } } @@ -4384,6 +4799,7 @@ impl Display { powerline: self.nebula_powerline_enabled, blur: self.nebula_blur, keep_session: self.nebula_keep_session, + restore_session: self.nebula_restore_session, opacity: self.nebula_window_opacity, background: self.nebula_background, background_image: self.nebula_background_image.clone(), @@ -4402,6 +4818,10 @@ impl Display { pinned_hosts: self.nebula_pinned_hosts.clone(), saved_hosts: self.nebula_saved_hosts.clone(), hidden_hosts: self.nebula_hidden_hosts.clone(), + panel_resize: self.nebula_panel_resize, + sidebar_w: self.nebula_sidebar_w, + drawer_w: self.nebula_drawer_w, + hosts_band: self.nebula_hosts_band, keybinds: self.nebula_keybinds.clone(), quick_terminal_hotkey: self.nebula_quick_terminal_hotkey.clone(), }); @@ -4470,6 +4890,19 @@ impl Display { self.nebula_powerline_enabled = settings.powerline; self.nebula_blur = settings.blur; self.nebula_keep_session = settings.keep_session; + self.nebula_restore_session = settings.restore_session; + self.nebula_panel_resize = settings.panel_resize; + // 手改文件把宽度调了的话,和拖拽一样要触发一次 reflow。 + let panel_dims_changed = (self.nebula_sidebar_w - settings.sidebar_w).abs() > 0.5 + || (self.nebula_drawer_w - settings.drawer_w).abs() > 0.5; + self.nebula_sidebar_w = settings.sidebar_w; + self.nebula_drawer_w = settings.drawer_w; + self.nebula_hosts_band = settings.hosts_band; + if panel_dims_changed { + let size = + PhysicalSize::new(self.size_info.width() as u32, self.size_info.height() as u32); + self.pending_update.set_dimensions(size); + } if self.nebula_cjk_bold_regular != settings.cjk_bold_regular { // 字形层策略变了:已缓存的 bold CJK 位图作废,清缓存重栅格。 self.nebula_cjk_bold_regular = settings.cjk_bold_regular; @@ -4768,12 +5201,12 @@ impl Display { let scale = self.window.scale_factor as f32; let content_pad = content_pad_x(scale); - let sidebar = sidebar_width(scale, self.nebula_sidebar_collapsed); + let sidebar = sidebar_width(scale, self.nebula_sidebar_collapsed, self.nebula_sidebar_w); // The file/git drawer occupies real layout space: the grid cedes its // width (plus the window margin) on the right, exactly like the left // sidebar reserve — it does not float over the terminal. let drawer = if self.nebula_side_panel.open { - (side_panel::PANEL_W_LOGICAL * scale + 8.0 * scale).round() + ((self.nebula_drawer_w * scale).min(width * 0.42) + 8.0 * scale).round() } else { 0.0 }; @@ -5771,6 +6204,11 @@ impl Display { "背景图会延伸到标题栏、窗口按钮、Tab 与 SSH 侧栏下方,低对比度图片可能影响操作可见性;界面仍会保留最低不透明度保护。".to_owned(), false, ), + NebulaConfirm::EnablePanelResize => ( + "开启侧栏拖拽调节?".to_owned(), + "拖动左侧栏或右侧抽屉的宽度时,终端内容会跟随实时重排;在低性能设备或超大回滚缓冲下可能出现掉帧。拖动已按帧率与列宽双重节流,把左侧栏一路拖到最左即可收起。宽度会保存,此功能可随时关闭。".to_owned(), + false, + ), NebulaConfirm::InstallRequiredFont { .. } => ( "建议安装终端字体".to_owned(), "未检测到 Maple Mono Nerd Font;缺少图标时可安装后重启 Nebula。".to_owned(), @@ -5926,13 +6364,7 @@ impl Display { // Cancel: quiet ghost button (hairline + faint fill). let control_r = s(ui::tokens::radius::CONTROL); - ui::surface::push_stroke( - &mut quads, - cancel_rect, - control_r, - scale, - sk.hairline, - ); + 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, 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. @@ -6060,11 +6492,8 @@ impl Display { let command = truncate_tab_label(&command, cmd_budget.min(96)); let explain_budget = cmd_budget.saturating_sub(text_cols(&command)).saturating_sub(3).min(60); - let explain = if text_cols(&explain) + 8 > explain_budget { - String::new() - } else { - explain - }; + let explain = + if text_cols(&explain) + 8 > explain_budget { String::new() } else { explain }; let mut content_cols = 2 + text_cols(&command); if !explain.is_empty() { @@ -6289,9 +6718,7 @@ impl Display { /// 聚焦 pane 是否正被连接卡片接管(遮罩已经在画了)。 pub fn ssh_connect_active(&self) -> bool { - self.nebula_ssh_connect - .get(&self.nebula_focused_pane) - .is_some_and(|state| state.visible()) + self.nebula_ssh_connect.get(&self.nebula_focused_pane).is_some_and(|state| state.visible()) } /// 遮罩盖住整个 pane,所以卡片在场时 pane 内的一切点击都归卡片,不能 @@ -6448,14 +6875,7 @@ impl Display { mix(sk.ink_strong.b, hud_rgb.b), ); let glyph_cache = &mut self.glyph_cache; - self.renderer.draw_chrome_text( - &size, - box_x + pad, - box_y + pad, - ink, - &text, - glyph_cache, - ); + self.renderer.draw_chrome_text(&size, box_x + pad, box_y + pad, ink, &text, glyph_cache); // Keep the frame loop alive so the HUD animates out. self.window.request_redraw(); @@ -7299,11 +7719,8 @@ impl Display { // The destination under the mouse (first highlighted hint with a URI) // plus that hint's start cell as the anchor. - let Some((uri, hint_start)) = self - .highlighted_hint - .iter() - .chain(&self.vi_highlighted_hint) - .find_map(|hint| { + let Some((uri, hint_start)) = + self.highlighted_hint.iter().chain(&self.vi_highlighted_hint).find_map(|hint| { hint.hyperlink().map(|h| (h.uri().to_owned(), *hint.bounds().start())) }) else { @@ -7732,6 +8149,7 @@ fn window_size( cell_width: f32, cell_height: f32, scale_factor: f32, + sidebar_w: f32, ) -> PhysicalSize { let padding = config.window.padding(scale_factor); let chrome = chrome_reserve(scale_factor); @@ -7741,7 +8159,9 @@ fn window_size( // Left absorbs the sidebar (expanded by default), right is the plain // content margin, matching the asymmetric grid the sidebar produces. - let pad_left = padding.0 + content_pad_x(scale_factor) + sidebar_width(scale_factor, false); + // 侧栏宽被拖宽过的话窗口相应更宽——启动公式仍是「字号 × 116 × 30」, + // 列数不因侧栏变化而缩水。 + let pad_left = padding.0 + content_pad_x(scale_factor) + sidebar_width(scale_factor, false, sidebar_w); let pad_right = padding.0 + content_pad_x(scale_factor); let width = (grid_width + pad_left + pad_right).floor(); let pad_top = padding.1 + chrome; diff --git a/nebula_app/src/display/settings.rs b/nebula_app/src/display/settings.rs index 29429c3..5b95fcf 100644 --- a/nebula_app/src/display/settings.rs +++ b/nebula_app/src/display/settings.rs @@ -233,6 +233,9 @@ pub enum SettingsHit { CursorBlinkToggle, /// 交互: copy-on-select toggle row. CopyOnSelectToggle, + /// 交互: 拖拽调节左侧栏宽 / 右抽屉宽。开启走确认框(reflow 开销)。 + /// SSH HOSTS 分界高度不归它管,始终可拖。 + PanelResizeToggle, /// 交互: 全宽字形 bold run 用 Regular 字形(粗体提亮不加粗)。 CjkBoldToggle, TabRevealDropdown, @@ -266,6 +269,8 @@ pub enum SettingsHit { Reset, /// 高级: keep the resident server (detach) on window close. KeepSessionToggle, + /// 高级: 启动时恢复上次会话(也是崩溃恢复的总开关)。 + RestoreSessionToggle, /// 高级→同步: 输入框(0=url 1=用户名 2=WebDAV 密码 3=E2E 口令)。 SyncInput(usize), SyncAutoPullToggle, @@ -310,6 +315,8 @@ pub(super) struct NebulaRuntimeSettings { /// Window close keeps the PTYs alive in the resident process (detach / /// re-attach session restore). Off = closing a window kills its shells. pub(super) keep_session: bool, + /// 高级·会话:启动时恢复上次的标签。 + pub(super) restore_session: bool, /// 窗口背景模糊。Windows 11 上是 Mica(见 /// `display::window::apply_windows_backdrop`),macOS / Wayland 上走 /// winit 自己的实现。默认开:纯 alpha 会让背景的高频细节直接透上来压在 @@ -339,6 +346,15 @@ pub(super) struct NebulaRuntimeSettings { /// `saved_hosts` because entries discovered in `~/.ssh/config` would /// otherwise reappear on the very next merge. pub(super) hidden_hosts: Vec, + /// 交互:允许拖拽调节左侧栏宽 / SSH HOSTS 分界高 / 右抽屉宽。默认关, + /// 开启走一次确认框——宽度拖动会实时重排终端,性能敏感。 + pub(super) panel_resize: bool, + /// 左侧栏逻辑宽;[`super::SIDEBAR_W_LOGICAL`] 是默认值。 + pub(super) sidebar_w: f32, + /// 右抽屉逻辑宽;布局时仍钳在窗口 42%。 + pub(super) drawer_w: f32, + /// SSH HOSTS 停靠区高度覆盖(逻辑 px);0 = 自动弹性规则。 + pub(super) hosts_band: f32, /// User keybinding overrides, raw `(combo, action)` pairs in file order /// (spec 002). Kept verbatim so unknown-but-valid future actions survive a /// load/save cycle; `display::keymap::build_bindings` parses them. @@ -375,6 +391,7 @@ pub(super) fn nebula_settings_load(config: &UiConfig) -> NebulaRuntimeSettings { // clean on close. Residency leaves shells running in the background, // which reads as "the app didn't really exit" — opt IN, not out. keep_session: false, + restore_session: true, // 2026-07-31 用户裁定:默认开。纯 alpha 下背景的高频细节压着文字, // 那正是"透明度调低就看不清"的物理来源;模糊把它拍成低频色块。想 // 真透出后面窗口内容的人可以关掉。 @@ -393,6 +410,10 @@ pub(super) fn nebula_settings_load(config: &UiConfig) -> NebulaRuntimeSettings { pinned_hosts: Vec::new(), saved_hosts: Vec::new(), hidden_hosts: Vec::new(), + panel_resize: false, + sidebar_w: super::SIDEBAR_W_LOGICAL, + drawer_w: super::side_panel::PANEL_W_LOGICAL, + hosts_band: 0.0, keybinds: Vec::new(), quick_terminal_hotkey: keymap::DEFAULT_QUICK_TERMINAL_HOTKEY.to_owned(), }; @@ -444,9 +465,7 @@ pub(super) fn nebula_settings_load(config: &UiConfig) -> NebulaRuntimeSettings { }, Some(("cursor_blink", v)) => settings.cursor_blink = parse_bool(v, true), Some(("copy_on_select", v)) => settings.copy_on_select = parse_bool(v, true), - Some(("cjk_bold_regular", v)) => { - settings.cjk_bold_regular = parse_bool(v, true) - }, + Some(("cjk_bold_regular", v)) => settings.cjk_bold_regular = parse_bool(v, true), Some(("tab_reveal", v)) => { settings.tab_reveal = TabRevealMotion::parse(v).unwrap_or_default(); }, @@ -459,6 +478,24 @@ pub(super) fn nebula_settings_load(config: &UiConfig) -> NebulaRuntimeSettings { Some(("fetch", v)) => settings.fetch = parse_bool(v, true), Some(("powerline", v)) => settings.powerline = parse_bool(v, true), Some(("keep_session", v)) => settings.keep_session = parse_bool(v, false), + Some(("restore_session", v)) => settings.restore_session = parse_bool(v, true), + Some(("panel_resize", v)) => settings.panel_resize = parse_bool(v, false), + Some(("sidebar_w", v)) => { + if let Ok(w) = v.trim().parse::() { + settings.sidebar_w = w.clamp(super::SIDEBAR_W_MIN, super::SIDEBAR_W_MAX); + } + }, + Some(("drawer_w", v)) => { + if let Ok(w) = v.trim().parse::() { + settings.drawer_w = w.clamp(super::DRAWER_W_MIN, super::DRAWER_W_MAX); + } + }, + Some(("hosts_band", v)) => { + if let Ok(h) = v.trim().parse::() { + settings.hosts_band = + if h > 0.0 { h.max(super::HOSTS_BAND_MIN) } else { 0.0 }; + } + }, Some(("blur", v)) => settings.blur = parse_bool(v, true), Some(("opacity", v)) => { if let Ok(opacity) = v.trim().parse::() { @@ -635,8 +672,7 @@ pub(super) fn nebula_settings_write(settings: &NebulaRuntimeSettings) { let pinned_hosts = settings.pinned_hosts.join(","); let saved_hosts = settings.saved_hosts.join(","); let hidden_hosts = settings.hidden_hosts.join(","); - let font_size = - settings.font_size.map(|size| format!("{size:.1}")).unwrap_or_default(); + let font_size = settings.font_size.map(|size| format!("{size:.1}")).unwrap_or_default(); let mut keybinds = String::new(); for (combo, action) in &settings.keybinds { keybinds.push_str(&format!("keybind={combo}:{action}\n")); @@ -646,7 +682,7 @@ pub(super) fn nebula_settings_write(settings: &NebulaRuntimeSettings) { path, format!( "language={}\ntheme={theme}\nfollow_system_theme={}\nghost={}\naccept={accept}\nshell={shell}\nstartup_directory={startup_directory}\nfont_family={}\nfont_size={font_size}\ncursor_shape={}\ncursor_blink={}\ncopy_on_select={}\ncjk_bold_regular={} -tab_reveal={}\nfetch={}\npowerline={}\nkeep_session={}\nblur={}\nopacity={:.2}\nbackground={background}\nbackground_image={background_image}\nbackground_image_opacity={:.2}\nbackground_image_fit={}\nbackground_image_alignment={}\nbackground_image_cover_chrome={}\npinned_hosts={pinned_hosts}\nsaved_hosts={saved_hosts}\nhidden_hosts={hidden_hosts}\nquick_terminal_hotkey={quick_terminal_hotkey}\n{keybinds}", +tab_reveal={}\nfetch={}\npowerline={}\nkeep_session={}\nrestore_session={}\nblur={}\nopacity={:.2}\nbackground={background}\nbackground_image={background_image}\nbackground_image_opacity={:.2}\nbackground_image_fit={}\nbackground_image_alignment={}\nbackground_image_cover_chrome={}\npanel_resize={}\nsidebar_w={:.0}\ndrawer_w={:.0}\nhosts_band={:.0}\npinned_hosts={pinned_hosts}\nsaved_hosts={saved_hosts}\nhidden_hosts={hidden_hosts}\nquick_terminal_hotkey={quick_terminal_hotkey}\n{keybinds}", settings.language.as_str(), settings.follow_system_theme as u8, settings.ghost as u8, @@ -659,12 +695,17 @@ tab_reveal={}\nfetch={}\npowerline={}\nkeep_session={}\nblur={}\nopacity={:.2}\n settings.fetch as u8, settings.powerline as u8, settings.keep_session as u8, + settings.restore_session as u8, settings.blur as u8, settings.opacity, settings.background_image_opacity, settings.background_image_fit.settings_value(), settings.background_image_alignment.settings_value(), settings.background_image_cover_chrome as u8, + settings.panel_resize as u8, + settings.sidebar_w, + settings.drawer_w, + settings.hosts_band, ), ); } @@ -715,6 +756,8 @@ struct SettingsGeometry { background_image_opacity_slider: (f32, f32, f32, f32), /// 交互: copy-on-select toggle row. copy_on_select: (f32, f32, f32, f32), + /// 交互·拖拽调节侧栏的开关行。 + panel_resize: (f32, f32, f32, f32), /// 交互: CJK 粗体策略 toggle row. cjk_bold: (f32, f32, f32, f32), tab_reveal: (f32, f32, f32, f32), @@ -735,6 +778,8 @@ struct SettingsGeometry { keymap_row_h: f32, advanced_h: f32, keep_session: (f32, f32, f32, f32), + /// 高级·会话:启动时恢复上次的标签(崩溃/强杀后同样走这条路)。 + restore_session: (f32, f32, f32, f32), /// 高级→同步(WebDAV):url/用户名/密码/口令 四行 + 自动拉取开关行 /// + 动作按钮行。输入框矩形由 [`sync_input_rect`] 从行矩形推导。 sync_rows: [(f32, f32, f32, f32); 4], @@ -748,7 +793,7 @@ fn settings_viewport_h(popup_h: f32, scale_factor: f32) -> f32 { } fn advanced_content_end(advanced_y0: f32, sync_y0: f32, row_h: f32) -> f32 { - if SHOW_WEBDAV_SYNC_SETTINGS { sync_y0 + 7.0 * row_h } else { advanced_y0 + row_h } + if SHOW_WEBDAV_SYNC_SETTINGS { sync_y0 + 7.0 * row_h } else { advanced_y0 + 2.0 * row_h } } /// Max scroll offset for `section` at the current window size. The input @@ -890,20 +935,20 @@ fn settings_geometry( }; let profiles_h = s(profiles_end + 32.0 - 72.0); - // 交互: 剪贴板行为与标签展开一组,文本渲染(CJK 粗体策略)另一组。 + // 交互: 剪贴板行为、标签展开与拖拽调节一组,文本渲染(CJK 粗体策略)另一组。 let interaction_y0 = 146.0; - let cjk_bold_y0 = interaction_y0 + ROW_H * 2.0 + GROUP_ADVANCE; + let cjk_bold_y0 = interaction_y0 + ROW_H * 3.0 + GROUP_ADVANCE; let interaction_h = s(cjk_bold_y0 + ROW_H + 32.0 - 72.0); let copy_on_select = (row_x, at(interaction_y0), row_w, row_h); let tab_reveal = (row_x, at(interaction_y0 + ROW_H), row_w, row_h); + let panel_resize = (row_x, at(interaction_y0 + ROW_H * 2.0), row_w, row_h); let cjk_bold = (row_x, at(cjk_bold_y0), row_w, row_h); // Keymap: editable action rows, then a read-only extras group (spec 002). let keymap_y0 = 146.0; let keymap_readonly_y0 = keymap_y0 + keymap::editable_row_count() as f32 * ROW_H + GROUP_ADVANCE; - let keymap_h = - s(keymap_readonly_y0 + keymap::READONLY_ROWS.len() as f32 * ROW_H + 32.0 - 72.0); + let keymap_h = s(keymap_readonly_y0 + keymap::READONLY_ROWS.len() as f32 * ROW_H + 32.0 - 72.0); let keymap_row0 = (row_x, at(keymap_y0), row_w, row_h); let keymap_readonly_row0 = (row_x, at(keymap_readonly_y0), row_w, row_h); @@ -911,7 +956,8 @@ fn settings_geometry( // 隐藏期间同步几何仍保留,方便后续继续完善,但页面高度只计算可见内容。 let advanced_y0 = 146.0; let keep_session = (row_x, at(advanced_y0), row_w, row_h); - let sync_y0 = advanced_y0 + ROW_H + GROUP_ADVANCE; + let restore_session = (row_x, at(advanced_y0 + ROW_H), row_w, row_h); + let sync_y0 = advanced_y0 + ROW_H * 2.0 + GROUP_ADVANCE; let sync_row = |i: f32| (row_x, at(sync_y0 + i * ROW_H), row_w, row_h); let sync_rows = [sync_row(0.0), sync_row(1.0), sync_row(2.0), sync_row(3.0)]; let sync_auto_pull = sync_row(4.0); @@ -973,6 +1019,7 @@ fn settings_geometry( hidden_host_row0: (row_x, at(hidden_y0), row_w, row_h), hidden_host_count, copy_on_select, + panel_resize, cjk_bold, tab_reveal, reset: (popup_x + popup_w - s(170.0), popup_y + s(24.0), s(150.0), s(42.0)), @@ -986,6 +1033,7 @@ fn settings_geometry( keymap_row_h: row_h, advanced_h, keep_session, + restore_session, sync_rows, sync_auto_pull, sync_actions, @@ -1065,12 +1113,8 @@ pub(super) fn background_color_popup( for (i, rect) in swatch.iter_mut().enumerate() { let row = i / COLS; let col = i % COLS; - *rect = ( - x + pad + col as f32 * (cell + gap), - y + pad + row as f32 * (cell + gap), - cell, - cell, - ); + *rect = + (x + pad + col as f32 * (cell + gap), y + pad + row as f32 * (cell + gap), cell, cell); } let hex = (x + pad, y + pad + grid_h + gap, w - 2.0 * pad, hex_h); BackgroundColorPopup { rect: (x, y, w, h), swatch, hex } @@ -1308,6 +1352,9 @@ pub fn settings_hit( if contains_rect(geometry.tab_reveal, x, y) { return SettingsHit::TabRevealDropdown; } + if contains_rect(geometry.panel_resize, x, y) { + return SettingsHit::PanelResizeToggle; + } if contains_rect(geometry.cjk_bold, x, y) { return SettingsHit::CjkBoldToggle; } @@ -1325,6 +1372,9 @@ pub fn settings_hit( if contains_rect(geometry.keep_session, x, y) { return SettingsHit::KeepSessionToggle; } + if contains_rect(geometry.restore_session, x, y) { + return SettingsHit::RestoreSessionToggle; + } if SHOW_WEBDAV_SYNC_SETTINGS { for (index, rect) in geometry.sync_rows.iter().enumerate() { // 命中整行都算输入框:行左侧是它的 label,点标签聚焦 @@ -1389,6 +1439,8 @@ pub(super) struct SettingsView { pub(super) fetch: bool, pub(super) powerline: bool, pub(super) keep_session: bool, + /// 高级·会话:启动时恢复上次的标签。 + pub(super) restore_session: bool, pub(super) blur: bool, pub(super) opacity: f32, /// Which opacity slider is mid-drag, for thumb-dot grow feedback. @@ -1396,6 +1448,8 @@ pub(super) struct SettingsView { pub(super) cursor_shape: CursorShape, pub(super) cursor_blink: bool, pub(super) copy_on_select: bool, + /// 交互·「拖拽调节侧栏」总开关。 + pub(super) panel_resize: bool, pub(super) cjk_bold_regular: bool, pub(super) tab_reveal: TabRevealMotion, /// Live-preview colors: the ACTUAL terminal background/foreground the @@ -1451,7 +1505,8 @@ fn sync_input_display(view: &SettingsView, index: usize, max_cols: usize) -> (St let raw = &view.sync_inputs[index]; if raw.is_empty() { let text = match index { - 0 => language.pick("https://dav.example.com/nebula.sync", "https://dav.example.com/nebula.sync"), + 0 => language + .pick("https://dav.example.com/nebula.sync", "https://dav.example.com/nebula.sync"), 1 => language.pick("WebDAV 用户名", "WebDAV username"), 2 if view.sync_secret_set[0] => { language.pick("已保存(输入以更换)", "Saved (type to replace)") @@ -1483,7 +1538,10 @@ fn sync_input_display(view: &SettingsView, index: usize, max_cols: usize) -> (St } /// 动作行的 [立即推送, 立即拉取] 按钮矩形。 -fn sync_button_rects((rx, ry, _, rh): (f32, f32, f32, f32), scale: f32) -> [(f32, f32, f32, f32); 2] { +fn sync_button_rects( + (rx, ry, _, rh): (f32, f32, f32, f32), + scale: f32, +) -> [(f32, f32, f32, f32); 2] { let s = |v: f32| v * scale; let w = s(150.0); let h = rh - s(12.0); @@ -1511,7 +1569,9 @@ fn keymap_row_value(view: &SettingsView, index: usize) -> (String, bool, bool) { if view.keymap_capture == Some(index) { // 按住修饰键时实时回显("Ctrl+…"),否则给占位 + 取消提示。 let text = if view.keymap_capture_preview.is_empty() { - view.language.pick("按下新按键…(Esc 取消)", "Press new keys… (Esc cancels)").to_owned() + view.language + .pick("按下新按键…(Esc 取消)", "Press new keys… (Esc cancels)") + .to_owned() } else { format!("{}…", view.keymap_capture_preview) }; @@ -1541,10 +1601,9 @@ const PREVIEW_PROMPT_COLS: usize = 2; fn dropdown_selected_index(view: &SettingsView, dropdown: SettingsDropdown) -> Option { match dropdown { - SettingsDropdown::Shell => view - .shells - .iter() - .position(|(id, _, _)| view.shell_id.as_deref() == Some(id.as_str())), + SettingsDropdown::Shell => { + view.shells.iter().position(|(id, _, _)| view.shell_id.as_deref() == Some(id.as_str())) + }, SettingsDropdown::Font => view.fonts.iter().position(|family| family == &view.font_family), SettingsDropdown::BackgroundFit => { BACKGROUND_FIT_OPTIONS.iter().position(|fit| *fit == view.background_image_fit) @@ -1718,13 +1777,16 @@ pub(super) fn push_quads( // Widget wrappers: stage → viewport-clip → push. Every multi-option row // shares ONE combobox component (user ruling 2026-07-23), hover/press // feedback included, so no page ever hand-rolls its own control again. - let combobox = - |quads: &mut Vec, staged: &mut Vec, row, hot: bool, open: bool| { - widgets::push_combobox(staged, widgets::combobox_rect(row, scale), scale, &sk, hot, open); - for quad in staged.drain(..) { - clip(quads, quad); - } - }; + let combobox = |quads: &mut Vec, + staged: &mut Vec, + row, + hot: bool, + open: bool| { + widgets::push_combobox(staged, widgets::combobox_rect(row, scale), scale, &sk, hot, open); + for quad in staged.drain(..) { + clip(quads, quad); + } + }; let slider = |quads: &mut Vec, staged: &mut Vec, hit, value: f32, hot: bool| { widgets::push_slider(staged, hit, value, scale, &sk, hot); for quad in staged.drain(..) { @@ -1782,7 +1844,10 @@ pub(super) fn push_quads( let beam_w = (2.0 * scale).max(1.0); match view.cursor_shape { CursorShape::Beam => { - clip(quads, UiQuad::solid(cursor_x, cursor_y, beam_w, cell_h, 0.0, ink)); + clip( + quads, + UiQuad::solid(cursor_x, cursor_y, beam_w, cell_h, 0.0, ink), + ); }, CursorShape::Underline => { clip( @@ -1798,7 +1863,10 @@ pub(super) fn push_quads( ); }, CursorShape::HollowBlock => { - clip(quads, UiQuad::solid(cursor_x, cursor_y, cell_w, cell_h, 0.0, ink)); + clip( + quads, + UiQuad::solid(cursor_x, cursor_y, cell_w, cell_h, 0.0, ink), + ); clip( quads, UiQuad::solid( @@ -1813,7 +1881,10 @@ pub(super) fn push_quads( }, CursorShape::Hidden => {}, CursorShape::Block => { - clip(quads, UiQuad::solid(cursor_x, cursor_y, cell_w, cell_h, 0.0, ink)); + clip( + quads, + UiQuad::solid(cursor_x, cursor_y, cell_w, cell_h, 0.0, ink), + ); }, } } @@ -2016,7 +2087,11 @@ pub(super) fn push_quads( view.hover == SettingsHit::CursorShapeDropdown, view.dropdown == Some(SettingsDropdown::CursorShape), ); - row_hover(quads, geometry.cursor_blink_row, view.hover == SettingsHit::CursorBlinkToggle); + row_hover( + quads, + geometry.cursor_blink_row, + view.hover == SettingsHit::CursorBlinkToggle, + ); toggle(quads, &mut staged, geometry.cursor_blink_row, view.cursor_blink); // 界面组:语言(同一通用下拉组件)+ 终端不透明度 + 背景模糊。 @@ -2118,7 +2193,7 @@ pub(super) fn push_quads( } }, NebulaSettingsSection::Interaction => { - group_frame(quads, geometry.copy_on_select, 2); + group_frame(quads, geometry.copy_on_select, 3); row_hover( quads, geometry.copy_on_select, @@ -2133,6 +2208,12 @@ pub(super) fn push_quads( view.hover == SettingsHit::TabRevealDropdown, view.dropdown == Some(SettingsDropdown::TabReveal), ); + row_hover( + quads, + geometry.panel_resize, + view.hover == SettingsHit::PanelResizeToggle, + ); + toggle(quads, &mut staged, geometry.panel_resize, view.panel_resize); group_frame(quads, geometry.cjk_bold, 1); row_hover(quads, geometry.cjk_bold, view.hover == SettingsHit::CjkBoldToggle); toggle(quads, &mut staged, geometry.cjk_bold, view.cjk_bold_regular); @@ -2184,9 +2265,15 @@ pub(super) fn push_quads( } }, NebulaSettingsSection::Advanced => { - group_frame(quads, geometry.keep_session, 1); + group_frame(quads, geometry.keep_session, 2); row_hover(quads, geometry.keep_session, view.hover == SettingsHit::KeepSessionToggle); toggle(quads, &mut staged, geometry.keep_session, view.keep_session); + row_hover( + quads, + geometry.restore_session, + view.hover == SettingsHit::RestoreSessionToggle, + ); + toggle(quads, &mut staged, geometry.restore_session, view.restore_session); if SHOW_WEBDAV_SYNC_SETTINGS { // ---- 同步(WebDAV,spec 003):4 输入行 + 自动拉取开关 ---- @@ -2366,7 +2453,14 @@ pub(super) fn push_popup_quads( sk.hairline, )); let color = BACKGROUND_SWATCHES[index]; - quads.push(UiQuad::solid(sx, sy, sw2, sh2, s(6.0), Rgba::new(color.r, color.g, color.b, 255))); + quads.push(UiQuad::solid( + sx, + sy, + sw2, + sh2, + s(6.0), + Rgba::new(color.r, color.g, color.b, 255), + )); } // hex 输入框:聚焦态用主题色描边;caret 与 UI 光标共用 500ms 相位。 @@ -3423,6 +3517,24 @@ pub(super) fn draw_text( sk.accent, ); } + if visible(geometry.panel_resize.1, geometry.panel_resize.3) { + // 开关本体在 quads pass;开启前的性能告知走确认框,这里的 + // label 只说清楚它管哪三条分界线。 + row_label( + r, + gc, + size, + scale, + &sk, + geometry.panel_resize, + language.pick( + "拖拽调节侧栏宽度(左侧栏 / 右抽屉;SSH 分界高度无需开关)", + "Drag to resize panel widths (sidebar / drawer)", + ), + "", + sk.ink, + ); + } let (bx, by, _, bh) = geometry.cjk_bold; if visible(group_y(by), title_h) { section_title( @@ -3529,8 +3641,7 @@ pub(super) fn draw_text( scale, ); for (chip_x, chip_w, key) in &combo.chips { - let key_cols: usize = - key.chars().map(|c| c.width().unwrap_or(0)).sum(); + let key_cols: usize = key.chars().map(|c| c.width().unwrap_or(0)).sum(); r.draw_chrome_text( size, chip_x + (chip_w - key_cols as f32 * cell_w) / 2.0, @@ -3604,6 +3715,27 @@ pub(super) fn draw_text( sk.ink, ); } + { + let (_, ry, _, rh) = geometry.restore_session; + if visible(ry, rh) { + // 关掉它就永远干净启动:session.json 照写不误(导出工作区 + // 与崩溃诊断都靠它),只是开机不再回放。 + row_label( + r, + gc, + size, + scale, + &sk, + geometry.restore_session, + language.pick( + "启动时恢复上次的标签(异常退出后同样恢复)", + "Restore last tabs on launch (also after a crash)", + ), + "", + sk.ink, + ); + } + } if SHOW_WEBDAV_SYNC_SETTINGS { // ---- 同步(WebDAV)---- @@ -3714,7 +3846,9 @@ mod tests { #[test] fn hidden_webdav_group_does_not_extend_advanced_content() { assert!(!SHOW_WEBDAV_SYNC_SETTINGS); - assert_eq!(advanced_content_end(146.0, 264.0, 44.0), 190.0); + // 会话组现在是两行(驻留 + 启动恢复),页面高度只算这两行—— + // 隐藏的同步组不许把 Advanced 撑高出一屏空白。 + assert_eq!(advanced_content_end(146.0, 264.0, 44.0), 234.0); } #[test] diff --git a/nebula_app/src/display/sftp_panel.rs b/nebula_app/src/display/sftp_panel.rs index ed145e0..052de5f 100644 --- a/nebula_app/src/display/sftp_panel.rs +++ b/nebula_app/src/display/sftp_panel.rs @@ -712,7 +712,7 @@ mod tests { #[test] fn filter_hit_uses_the_drawn_geometry_without_a_toolbar_band() { - let base = side_panel::panel_layout(1000.0, 800.0, 0.0, 0.0, 1.0, 1.0); + let base = side_panel::panel_layout(1000.0, 800.0, 0.0, 0.0, 1.0, 1.0, 300.0); let layout = layout(&base, 1.0); let rect = layout.filter; assert_eq!( diff --git a/nebula_app/src/display/side_panel.rs b/nebula_app/src/display/side_panel.rs index 4088c9c..35d9beb 100644 --- a/nebula_app/src/display/side_panel.rs +++ b/nebula_app/src/display/side_panel.rs @@ -1,1986 +1,1988 @@ -//! Right-side drawer: directory tree / git status for the focused pane's cwd -//! This module owns only the *model* — tree flattening, git -//! parsing, layout maths, and hit-testing. Rendering lives in `display::mod` -//! (mirroring the command palette split), and input dispatch in `input::mod`. -//! -//! The panel is an overlay drawer: it floats above the terminal's right edge -//! instead of reflowing the PTY, so toggling it never resizes the shell. -//! -//! Refresh model: cheap and synchronous, but *only* on toggle, on a cwd/root -//! change, or when the throttle window (a few seconds) has elapsed — never on -//! every frame. `git --no-optional-locks` keeps the status call from touching -//! the index lock, so it can't corrupt or stall a concurrent git operation. - -use std::collections::HashSet; -use std::path::{Path, PathBuf}; -use std::time::{Duration, Instant}; -use unicode_width::UnicodeWidthChar; - -/// Which view the drawer shows. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PanelView { - /// Directory tree of the focused pane's cwd. - Files, - /// Git branch + working-tree changes of the enclosing repository. - Git, -} - -/// One flattened row of the directory tree. -#[derive(Debug, Clone)] -pub struct FileRow { - pub path: PathBuf, - pub name: String, - pub depth: usize, - pub is_dir: bool, - pub expanded: bool, - /// 用于切换到上级根目录的合成 `..` 行。必须显式区分,避免普通目录的 - /// 展开、拖拽和双击逻辑把导航项误认为真实文件系统条目。 - pub is_parent: bool, -} - -/// Parsed `git status` snapshot. -#[derive(Debug, Clone, Default)] -pub struct GitInfo { - /// Current branch (or short detached-HEAD description). - pub branch: String, - /// Working-tree line insertions/deletions (unstaged + staged). - pub plus: u64, - pub minus: u64, - /// Commits ahead of upstream — what a push would publish. 0 = nothing to - /// push (the push button keys off this: only committed work is pushable). - pub ahead: u32, - /// Worktree changes not yet staged (`??` counts here as `?`). - pub unstaged: Vec<(char, String)>, - /// Index changes ready to commit. - pub staged: Vec<(char, String)>, -} - -/// Result of hit-testing a pixel against the open drawer. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PanelHit { - None, - /// The "文件" view tab in the header. - ViewFiles, - /// The "Git" view tab in the header. - ViewGit, - /// The Files view's filter input box. - Search, - /// Choose a window-local tree root with the native folder picker. - OpenDirectory, - /// Open a fresh terminal tab whose PTY starts at the current tree root. - NewTerminalHere, - /// Clear the window-local root and resume following the focused pane cwd. - FollowCurrentDirectory, - /// A list row (index into the *visible* rows of the current view). - Row(usize), - /// Inside the panel but on nothing interactive. - Inside, -} - -/// An in-progress drag of a tree entry toward the terminal (drop = paste the -/// full path into the shell, like dropping an entry from Explorer). -#[derive(Debug, Clone)] -pub struct FileDrag { - pub path: PathBuf, - /// Display name for the drag ghost that follows the pointer. - pub name: String, - /// Pointer position at press; the drag activates past a small threshold - /// so plain clicks (and double-clicks) don't count as drags. - pub origin: (f32, f32), - /// Latest pointer position (physical px) — anchors the drag ghost. - pub pos: (f32, f32), - /// Directories defer their normal expand/collapse click until release, so - /// crossing the drag threshold never mutates the tree as a side effect. - pub is_dir: bool, - /// Visible row at press time. Release validates its path before toggling, - /// preventing a throttled tree refresh from acting on a different row. - pub source_row: usize, - pub active: bool, -} - -impl FileDrag { - pub fn new( - path: PathBuf, - name: String, - is_dir: bool, - source_row: usize, - origin: (f32, f32), - ) -> Self { - Self { path, name, origin, pos: origin, is_dir, source_row, active: false } - } - - /// Update the ghost position and cross the small click/drag threshold. - /// Once active, a drag never falls back to an expand/collapse click. - pub fn update_position(&mut self, pos: (f32, f32)) { - self.pos = pos; - if !self.active { - let (ox, oy) = self.origin; - if (pos.0 - ox).abs() >= 8.0 || (pos.1 - oy).abs() >= 8.0 { - self.active = true; - } - } - } - - /// Bytes pasted on a valid terminal drop. This intentionally preserves - /// the existing cross-shell compatibility boundary: whitespace paths use - /// double quotes, but no single literal syntax can safely encode every - /// special character for PowerShell, CMD, Bash and NuShell at once. - pub fn terminal_drop_text(&self, over_terminal: bool) -> Option> { - if !self.active || !over_terminal { - return None; - } - let mut text = self.path.display().to_string(); - // Unix permits control characters (including CR/LF) in file names. - // Sending those bytes to a PTY could execute input despite the drop - // contract explicitly requiring paste-only behaviour. - if text.chars().any(char::is_control) { - return None; - } - if text.contains(char::is_whitespace) { - text = format!("\"{text}\""); - } - text.push(' '); - Some(text.into_bytes()) - } -} - -/// Re-run the (throttled) refresh at most this often while the panel is open. -const REFRESH_EVERY: Duration = Duration::from_secs(4); -/// Hard cap on flattened tree rows, bounding both fs walking and rendering. -const MAX_ROWS: usize = 400; -/// Hard cap on entries listed per directory. -const MAX_PER_DIR: usize = 200; -/// Total directory entries the filter index may VISIT while being built. -/// This bounds the walk itself (a `target/` or `node_modules/` tree has -/// hundreds of thousands of entries — walking it per keystroke froze the UI), -/// not just the matches kept. -const SEARCH_VISIT_BUDGET: usize = 20_000; -/// Entries kept in the filter index. -const SEARCH_INDEX_CAP: usize = 10_000; -/// Directories that are all bulk and no signal — never indexed for filtering. -const SEARCH_SKIP_DIRS: &[&str] = - &["target", "node_modules", ".git", ".cache", ".gradle", "build", "trellis"]; - -pub struct SidePanel { - pub open: bool, - pub view: PanelView, - /// Root the tree/git snapshot was built from (the focused pane's cwd). - root: Option, - /// Latest focused pane cwd, retained while a custom root is active so the - /// panel can resume following immediately without persisting any setting. - followed_cwd: Option, - /// Window-local override selected from the Files view. - custom_root: Option, - /// Visible feedback for an invalid/disappeared custom root. - root_notice: Option, - /// Flattened visible tree rows for the Files view. - rows: Vec, - /// Directories the user expanded (persists across refreshes). - expanded: HashSet, - /// Git snapshot, `None` when the root isn't inside a work tree. - git: Option, - /// Scroll offset in rows. - pub scroll: usize, - /// Files-view filter query; non-empty switches the tree to a flat list of - /// deep matches (VS Code's explorer filter). - pub search: String, - /// Whether the filter box owns the keyboard. - pub search_focus: bool, - search_selection: super::text_input::SelectAllState, - /// Flat, budget-bounded index of the tree used by the filter. Built ONCE - /// on the first filtering keystroke and reused for the rest of the query - /// (each keystroke then only string-matches in memory); dropped whenever - /// the root changes or a refresh rebuilds the snapshot. - search_index: Option>, - /// Commit-message input (Git view): buffer + focus, same modal keyboard - /// contract as the Files filter box. - pub commit_msg: String, - pub commit_focus: bool, - commit_selection: super::text_input::SelectAllState, - /// Last clicked file row (path + when), for double-click-to-open. - pub last_file_click: Option<(PathBuf, Instant)>, - /// In-progress drag of a file or directory row toward the terminal. - pub drag_file: Option, - /// Persistently selected file (row highlight). Cleared by clicking off - /// the panel, closing the drawer, or the root changing. - pub selected: Option, - /// What the pointer currently hovers (rows/buttons/header tabs light up). - pub hover: PanelHit, - /// Pointer position of the last hover update — disambiguates WHICH git - /// action button is under the pointer inside the shared strip. - pub hover_pos: (f32, f32), - /// A git mutation (add/commit/push) is running on a worker thread; the - /// action buttons gray out and re-arm when it lands. - op_running: std::sync::Arc, - /// Set by the worker when it finishes — `sync` folds it into a refresh. - op_done: std::sync::Arc, - /// Last operation's error (empty = success), shown on the summary line. - op_error: std::sync::Arc>, - last_refresh: Option, - needs_refresh: bool, -} - -fn git_pull_args() -> Vec { - vec!["pull".into(), "--ff-only".into()] -} - -impl SidePanel { - pub fn new() -> Self { - Self { - open: false, - view: PanelView::Files, - root: None, - followed_cwd: None, - custom_root: None, - root_notice: None, - rows: Vec::new(), - expanded: HashSet::new(), - git: None, - scroll: 0, - search: String::new(), - search_focus: false, - search_selection: Default::default(), - search_index: None, - commit_msg: String::new(), - commit_focus: false, - commit_selection: Default::default(), - last_file_click: None, - drag_file: None, - selected: None, - hover: PanelHit::None, - hover_pos: (0.0, 0.0), - op_running: Default::default(), - op_done: Default::default(), - op_error: Default::default(), - last_refresh: None, - needs_refresh: false, - } - } - - /// Toggle the drawer. Re-invoking with the *other* view while open only - /// switches views (VS Code sidebar behaviour) instead of closing. - pub fn toggle(&mut self, view: PanelView) { - if self.open && self.view == view { - self.open = false; - self.selected = None; - self.drag_file = None; - return; - } - self.open = true; - self.view = view; - self.scroll = 0; - self.needs_refresh = true; - } - - /// Adopt the focused pane's cwd, refreshing when the root changed, a - /// refresh was requested (toggle), or the throttle window has elapsed. - /// Called once per drawn frame from the window context; cheap when nothing - /// changed. Returns whether the snapshot was rebuilt (i.e. needs redraw). - pub fn sync(&mut self, cwd: Option) -> bool { - if !self.open { - return false; - } - // 聚焦 pane 报不出本地有效目录时(SSH 远端路径、未映射的 WSL 路 - // 径、shell 还没发 OSC)保持现状:跟随的语义是"最后一个已知有效 - // 目录",清空只会让切到远程 tab 的瞬间目录树闪成空白。 - if cwd.is_some() { - self.followed_cwd = cwd; - } - let custom_invalidated = self.custom_root.as_ref().is_some_and(|root| !root.is_dir()); - if custom_invalidated { - self.custom_root = None; - self.root_notice = Some("所选目录不可用,已跟随当前目录".to_owned()); - } - let next_root = self.custom_root.clone().or_else(|| self.followed_cwd.clone()); - let root_changed = next_root != self.root; - // While a filter query is live, skip the periodic re-snapshot: it - // would drop and rebuild the search index under the user's fingers. - let stale = self.search.trim().is_empty() - && self.last_refresh.is_none_or(|t| t.elapsed() >= REFRESH_EVERY); - // A finished git mutation forces a refresh so the new state (staged - // list, ahead count) shows on the next frame. - if self.op_done.swap(false, std::sync::atomic::Ordering::Relaxed) { - self.needs_refresh = true; - } - if !(root_changed || custom_invalidated || stale || self.needs_refresh) { - return false; - } - if root_changed { - self.root = next_root; - self.expanded.clear(); - self.scroll = 0; - self.selected = None; - } - self.refresh(); - true - } - - /// Override the focused pane cwd for this panel instance only. `SidePanel` - /// belongs to one window and this field is deliberately never serialized. - pub fn set_custom_root(&mut self, root: PathBuf) -> bool { - if !root.is_dir() { - self.root_notice = Some("所选目录不可用".to_owned()); - return false; - } - let changed = self.custom_root.as_ref() != Some(&root) || self.root.as_ref() != Some(&root); - self.custom_root = Some(root.clone()); - self.root_notice = None; - if self.root.as_ref() != Some(&root) { - self.root = Some(root); - self.expanded.clear(); - self.scroll = 0; - self.selected = None; - self.refresh(); - } - changed - } - - /// Resume following the most recently observed focused pane cwd. - pub fn clear_custom_root(&mut self) -> bool { - if self.custom_root.take().is_none() { - return false; - } - self.root_notice = None; - let next_root = self.followed_cwd.clone(); - if self.root != next_root { - self.root = next_root; - self.expanded.clear(); - self.scroll = 0; - self.selected = None; - self.refresh(); - } - true - } - - pub fn custom_root_active(&self) -> bool { - self.custom_root.is_some() - } - - pub fn root_notice(&self) -> Option<&str> { - self.root_notice.as_deref() - } - - /// Only real Git file rows are interactive. Section headers and the blank - /// area below the snapshot must never produce a full-width hover pill. - pub fn git_row_is_file(&self, visible_row: usize) -> bool { - if self.view != PanelView::Git { - return false; - } - let Some(git) = self.git.as_ref() else { return false }; - let absolute = self.scroll + visible_row; - if git.unstaged.is_empty() && git.staged.is_empty() { - return false; - } - let unstaged = 1..1 + git.unstaged.len(); - let staged_start = git.unstaged.len() + 2; - let staged = staged_start..staged_start + git.staged.len(); - unstaged.contains(&absolute) || staged.contains(&absolute) - } - - /// 外部(右键菜单删除等文件系统变更)请求下一帧重建快照。 - pub fn request_refresh(&mut self) { - self.needs_refresh = true; - } - - /// 面板顶部的一句话提示(复用根目录不可用的同一条 UI)。 - pub fn set_notice(&mut self, message: String) { - self.root_notice = Some(message); - } - - /// Rebuild the tree and git snapshot from `root`. - fn refresh(&mut self) { - self.needs_refresh = false; - self.last_refresh = Some(Instant::now()); - // New snapshot → the filter index is stale; rebuild lazily on demand. - self.search_index = None; - self.rebuild_rows(); - self.git = None; - if let Some(root) = self.root.clone() { - self.git = read_git(&root); - } - } - - /// Rebuild only the flattened rows (tree shape / filter changes; the git - /// snapshot stays). - fn rebuild_rows(&mut self) { - self.rows.clear(); - let Some(root) = self.root.clone() else { return }; - let needle = self.search.trim().to_lowercase(); - if needle.is_empty() { - if let Some(parent) = root.parent() { - self.rows.push(FileRow { - path: parent.to_path_buf(), - name: "..".to_owned(), - depth: 0, - is_dir: true, - expanded: false, - is_parent: true, - }); - } - self.flatten_dir(&root, 0); - return; - } - // Filter mode: string-match against the cached flat index. The index - // is built at most once per snapshot (budget-bounded walk); each - // keystroke after that is pure in-memory filtering — walking the tree - // per keystroke froze the UI on big checkouts. - if self.search_index.is_none() { - let mut index = Vec::new(); - let mut budget = SEARCH_VISIT_BUDGET; - build_search_index(&root, 0, &mut index, &mut budget); - self.search_index = Some(index); - } - let index = self.search_index.as_ref().unwrap(); - self.rows.extend( - index - .iter() - .filter(|row| row.name.to_lowercase().contains(&needle)) - .take(MAX_ROWS) - .cloned(), - ); - } - - /// Append typed text to the filter query and re-derive the rows. - pub fn search_input(&mut self, text: &str) { - self.search_selection.insert(&mut self.search, text); - self.scroll = 0; - self.rebuild_rows(); - } - - pub fn search_backspace(&mut self) { - self.search_selection.backspace(&mut self.search); - self.scroll = 0; - self.rebuild_rows(); - } - - pub fn search_select_all(&mut self) { - self.search_selection.select(&self.search); - } - - pub fn search_selected_text(&self) -> Option { - self.search_selection.selected_text(&self.search) - } - - pub fn search_all_selected(&self) -> bool { - self.search_selection.is_selected() - } - - /// Leave the filter box; `clear` also resets the query (Esc). - pub fn search_unfocus(&mut self, clear: bool) { - self.search_focus = false; - self.search_selection.clear(); - if clear && !self.search.is_empty() { - self.search.clear(); - self.scroll = 0; - self.rebuild_rows(); - } - } - - // ---- git mutations (add / commit / pull / push) ---- - - /// Whether a git mutation is in flight (buttons gray out). - pub fn op_running(&self) -> bool { - self.op_running.load(std::sync::atomic::Ordering::Relaxed) - } - - /// Last mutation's error, if any (cleared by the next successful op). - pub fn op_error(&self) -> Option { - let e = self.op_error.lock().ok()?; - (!e.is_empty()).then(|| e.clone()) - } - - /// Run `git ` on a worker thread; UI stays live (a push can take - /// seconds over the network). Completion flips `op_done`, which the next - /// drawn frame folds into a refresh. - fn spawn_git(&mut self, args: Vec) { - use std::sync::atomic::Ordering; - let Some(root) = self.root.clone() else { return }; - if self.op_running.swap(true, Ordering::Relaxed) { - return; // one at a time - } - let running = self.op_running.clone(); - let done = self.op_done.clone(); - let error = self.op_error.clone(); - std::thread::Builder::new() - .name("nebula-git-op".into()) - .spawn(move || { - let mut cmd = std::process::Command::new("git"); - cmd.args(&args).current_dir(&root); - #[cfg(windows)] - { - use std::os::windows::process::CommandExt; - cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW - } - let msg = match cmd.output() { - Ok(out) if out.status.success() => String::new(), - Ok(out) => { - let err = String::from_utf8_lossy(&out.stderr); - // First meaningful line is enough for a status strip. - err.lines().find(|l| !l.trim().is_empty()).unwrap_or("git 失败").to_string() - }, - Err(e) => format!("git: {e}"), - }; - if let Ok(mut slot) = error.lock() { - *slot = msg; - } - running.store(false, Ordering::Relaxed); - done.store(true, Ordering::Relaxed); - }) - .ok(); - } - - /// `git add -A`: stage everything (the ⊕ button). - pub fn git_stage_all(&mut self) { - if self.git.as_ref().is_some_and(|g| !g.unstaged.is_empty()) && !self.op_running() { - self.spawn_git(vec!["add".into(), "-A".into()]); - } - } - - /// Commit button: with staged changes, open the message input (Enter then - /// commits via [`Self::git_commit_submit`]). - pub fn git_begin_commit(&mut self) { - if self.git.as_ref().is_some_and(|g| !g.staged.is_empty()) && !self.op_running() { - self.commit_focus = true; - self.commit_selection.clear(); - } - } - - pub fn commit_input(&mut self, text: &str) { - self.commit_selection.insert(&mut self.commit_msg, text); - } - - pub fn commit_backspace(&mut self) { - self.commit_selection.backspace(&mut self.commit_msg); - } - - pub fn commit_select_all(&mut self) { - self.commit_selection.select(&self.commit_msg); - } - - pub fn commit_selected_text(&self) -> Option { - self.commit_selection.selected_text(&self.commit_msg) - } - - pub fn commit_all_selected(&self) -> bool { - self.commit_selection.is_selected() - } - - pub fn commit_cancel(&mut self) { - self.commit_focus = false; - self.commit_msg.clear(); - self.commit_selection.clear(); - } - - pub fn commit_unfocus(&mut self) { - self.commit_focus = false; - self.commit_selection.clear(); - } - - /// Enter in the message box: run `git commit -m `. - pub fn git_commit_submit(&mut self) { - let msg = self.commit_msg.trim().to_string(); - if msg.is_empty() || self.op_running() { - return; - } - self.commit_focus = false; - self.commit_msg.clear(); - self.commit_selection.clear(); - self.spawn_git(vec!["commit".into(), "-m".into(), msg]); - } - - /// Push button — only enabled with committed-but-unpushed work (`ahead`). - pub fn git_push(&mut self) { - if self.git.as_ref().is_some_and(|g| g.ahead > 0) && !self.op_running() { - self.spawn_git(vec!["push".into()]); - } - } - - /// Pull only fast-forward updates, never creating an implicit merge commit. - pub fn git_pull(&mut self) { - if self.git.is_some() && !self.op_running() { - self.spawn_git(git_pull_args()); - } - } - - /// Depth-first flatten of `dir` into `rows`, following `expanded`. - fn flatten_dir(&mut self, dir: &Path, depth: usize) { - if self.rows.len() >= MAX_ROWS { - return; - } - let Ok(read) = std::fs::read_dir(dir) else { return }; - let mut entries: Vec<(bool, String, PathBuf)> = read - .flatten() - .take(MAX_PER_DIR) - .filter_map(|e| { - let name = e.file_name().to_string_lossy().into_owned(); - // `.git` is noise in a file tree; everything else shows. - if name == ".git" { - return None; - } - let is_dir = e.file_type().ok()?.is_dir(); - Some((is_dir, name, e.path())) - }) - .collect(); - // Directories first, then case-insensitive alphabetical (Explorer/ - // VS Code convention). - entries.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.to_lowercase().cmp(&b.1.to_lowercase()))); - for (is_dir, name, path) in entries { - if self.rows.len() >= MAX_ROWS { - return; - } - let expanded = is_dir && self.expanded.contains(&path); - self.rows.push(FileRow { - path: path.clone(), - name, - depth, - is_dir, - expanded, - is_parent: false, - }); - if expanded { - self.flatten_dir(&path, depth + 1); - } - } - } - - /// Click on visible row `index` (post-scroll). Directories toggle their - /// expansion; files are inert (v1). Returns whether anything changed. - pub fn click_row(&mut self, index: usize) -> bool { - if self.view != PanelView::Files || !self.search.trim().is_empty() { - return false; - } - let Some(row) = self.rows.get(self.scroll + index) else { return false }; - if !row.is_dir { - return false; - } - let path = row.path.clone(); - if row.is_parent { - // 返回上级只改变窗口内的目录树根节点,不能连带修改终端 cwd; - // 用户仍可通过现有“跟随”操作回到当前终端目录。 - return self.set_custom_root(path); - } - if !self.expanded.remove(&path) { - self.expanded.insert(path); - } - // Re-flatten only (no git re-run): tree shape changed, content didn't. - self.rebuild_rows(); - true - } - - /// Complete the plain-click half of a pending directory drag. The source - /// path must still occupy the pressed row; otherwise a refresh/scroll - /// change could expand an unrelated directory after mouse release. - pub fn click_drag_source(&mut self, drag: &FileDrag) -> bool { - if drag.active || !drag.is_dir { - return false; - } - let matches_source = self - .visible_row(drag.source_row) - .is_some_and(|row| row.is_dir && !row.is_parent && row.path == drag.path); - matches_source && self.click_row(drag.source_row) - } - - /// Scroll by `delta` rows (positive = down), clamped to the list length. - pub fn scroll_by(&mut self, delta: i32, visible_rows: usize) { - let len = match self.view { - PanelView::Files => self.rows.len(), - // Two section headers + both file lists. - PanelView::Git => { - self.git.as_ref().map_or(0, |g| g.unstaged.len() + g.staged.len() + 2) - }, - }; - let max = len.saturating_sub(visible_rows); - self.scroll = (self.scroll as i64 + delta as i64).clamp(0, max as i64) as usize; - } - - pub fn file_rows(&self) -> &[FileRow] { - &self.rows - } - - /// The tree row currently shown at visible index `idx` (post-scroll). - pub fn visible_row(&self, idx: usize) -> Option<&FileRow> { - if self.view != PanelView::Files { - return None; - } - self.rows.get(self.scroll + idx) - } - - pub fn git(&self) -> Option<&GitInfo> { - self.git.as_ref() - } - - pub fn root(&self) -> Option<&Path> { - self.root.as_deref() - } -} - -/// Resting drawer width in logical pixels (clamped to 42% of the window in -/// `panel_layout`). Shared with the grid-padding reserve and the terminal -/// card's right edge, so the drawer genuinely occupies layout space (the grid -/// reflows around it) instead of floating over the terminal. -pub const PANEL_W_LOGICAL: f32 = 300.0; - -/// Panel geometry, physical pixels: `(x, y, w, h)` of the drawer, plus the -/// header strip height and one list row height. -pub struct PanelLayout { - pub panel: (f32, f32, f32, f32), - pub header_h: f32, - pub row_h: f32, - /// Files-view filter input box (between the summary line and the list). - pub search: (f32, f32, f32, f32), - /// Y of the first list row (below header, summary line and search box). - pub list_y: f32, - pub max_rows: usize, -} - -/// Drawer layout: a floating panel pinned to the SAME vertical band as the -/// left tab sidebar (`chrome_tab_layout`) — top at `margin + bar_h + 12`, -/// bottom at `win_h - margin - 12` — and inset from the right window edge by -/// `margin`, so both chrome panels share one height, one baseline, and float -/// with all four corners in open space (a flush edge squares off the corners). -/// The `_top`/`_bottom` chrome reserves the caller passes are no longer used -/// for the band: the constants here are locked to the sidebar's so the two can -/// never drift. `slide` is the open-animation progress (0 = fully off-screen -/// right, 1 = resting position); the whole drawer rides it. -pub fn panel_layout( - win_w: f32, - win_h: f32, - _top: f32, - _bottom: f32, - scale: f32, - slide: f32, -) -> PanelLayout { - let s = |v: f32| v * scale; - // Same margin / bar height / breathing gap as `chrome_tab_layout`. - let margin = s(8.0); - let bar_h = s(40.0); - let gap = s(12.0); - let w = s(PANEL_W_LOGICAL).min(win_w * 0.42); - // Motion Runtime already provides the physical response. Applying another - // curve here would double-ease the drawer and make its ending feel sticky. - let eased = slide.clamp(0.0, 1.0); - // Resting x is inset by `margin` (mirroring the left panel's left inset); - // closed, it rides fully off the right edge. Travel = the panel width plus - // its margin so nothing peeks while closed. - let rest_x = win_w - margin - w; - let x = rest_x + (1.0 - eased) * (w + margin); - let y = margin + bar_h + gap; - let h = (win_h - margin - gap - y).max(0.0); - let header_h = s(40.0); - let row_h = s(34.0); - let search = (x + s(14.0), y + header_h + s(34.0), w - s(28.0), s(34.0)); - let list_y = search.1 + search.3 + s(16.0); // header + summary + filter box - let max_rows = (((y + h) - list_y) / row_h).max(0.0) as usize; - PanelLayout { panel: (x, y, w, h), header_h, row_h, search, list_y, max_rows } -} - -/// Hit-test a pixel against the open drawer (`layout` from [`panel_layout`]). -pub fn panel_hit(layout: &PanelLayout, x: f32, y: f32) -> PanelHit { - let (px, py, pw, ph) = layout.panel; - if x < px || x >= px + pw || y < py || y >= py + ph { - return PanelHit::None; - } - if y < py + layout.header_h { - // Header: two half-width view tabs. - return if x < px + pw * 0.5 { PanelHit::ViewFiles } else { PanelHit::ViewGit }; - } - let (sx, sy, sw, sh) = layout.search; - if x >= sx && x < sx + sw && y >= sy && y < sy + sh { - return PanelHit::Search; - } - if y >= layout.list_y { - let row = ((y - layout.list_y) / layout.row_h) as usize; - if row < layout.max_rows { - return PanelHit::Row(row); - } - } - PanelHit::Inside -} - -pub fn panel_action_rects( - layout: &PanelLayout, - custom_root: bool, - has_root: bool, -) -> impl Iterator { - let scale = layout.header_h / 40.0; - let s = |value: f32| value * scale; - let (px, py, pw, _) = layout.panel; - let height = s(26.0); - let y = py + layout.header_h + s(4.0); - let right = px + pw - s(10.0); - let icon_width = s(30.0); - let gap = s(4.0); - let open_x = right - icon_width; - let terminal_x = open_x - gap - icon_width; - let leftmost_icon_x = if has_root { terminal_x } else { open_x }; - let follow_width = s(62.0); - let follow_x = leftmost_icon_x - gap - follow_width; - - // Fixed-size options keep per-frame draw and pointer hit-testing free of - // tiny heap allocations while still allowing root-dependent actions. - [ - Some((PanelHit::OpenDirectory, (open_x, y, icon_width, height))), - has_root.then_some((PanelHit::NewTerminalHere, (terminal_x, y, icon_width, height))), - (custom_root && has_root) - .then_some((PanelHit::FollowCurrentDirectory, (follow_x, y, follow_width, height))), - ] - .into_iter() - .flatten() -} - -pub fn panel_interactive_hit( - layout: &PanelLayout, - view: PanelView, - custom_root: bool, - has_root: bool, - x: f32, - y: f32, -) -> PanelHit { - if view == PanelView::Files { - for (hit, (rx, ry, rw, rh)) in panel_action_rects(layout, custom_root, has_root) { - if x >= rx && x < rx + rw && y >= ry && y < ry + rh { - return hit; - } - } - } - panel_hit(layout, x, y) -} - -/// Budget-bounded deep walk building the flat filter index. `budget` counts -/// every entry VISITED (not kept), so a huge build tree can't stall the UI; -/// bulk directories (`target/`, `node_modules/`, …) are skipped outright, and -/// symlinks/junctions are never followed (cycle safety). -fn build_search_index(dir: &Path, depth: usize, index: &mut Vec, budget: &mut usize) { - if *budget == 0 || depth > 8 || index.len() >= SEARCH_INDEX_CAP { - return; - } - let Ok(read) = std::fs::read_dir(dir) else { return }; - for entry in read.flatten() { - if *budget == 0 || index.len() >= SEARCH_INDEX_CAP { - return; - } - *budget -= 1; - let Ok(ft) = entry.file_type() else { continue }; - if ft.is_symlink() { - continue; - } - let name = entry.file_name().to_string_lossy().into_owned(); - let is_dir = ft.is_dir(); - if is_dir && (name.starts_with('.') || SEARCH_SKIP_DIRS.contains(&name.as_str())) { - continue; - } - let path = entry.path(); - index.push(FileRow { - path: path.clone(), - name, - depth: 0, - is_dir, - expanded: false, - is_parent: false, - }); - if is_dir { - build_search_index(&path, depth + 1, index, budget); - } - } -} - -/// Snapshot git state for `root`: branch, ±line counts, changed files./// `None` when git is missing or `root` isn't inside a work tree. Runs -/// synchronously — callers throttle (see [`SidePanel::sync`]). -fn read_git(root: &Path) -> Option { - use std::process::Command; - let run = |args: &[&str]| -> Option { - let mut cmd = Command::new("git"); - cmd.arg("--no-optional-locks").args(args).current_dir(root); - // Suppress the console window that `Command` flashes on Windows GUI apps. - #[cfg(windows)] - { - use std::os::windows::process::CommandExt; - cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW - } - let out = cmd.output().ok()?; - out.status.success().then(|| String::from_utf8_lossy(&out.stdout).into_owned()) - }; - - // `-b --porcelain` yields `## branch...upstream [ahead N]` + one `XY path` - // per change, X = index (staged) status, Y = worktree status. - let status = run(&["status", "--porcelain", "-b"])?; - let mut info = GitInfo::default(); - for line in status.lines() { - if let Some(head) = line.strip_prefix("## ") { - // `main...origin/main [ahead 1]` → `main`; detached prints as-is. - info.branch = head.split("...").next().unwrap_or(head).to_string(); - if let Some(idx) = head.find("ahead ") { - info.ahead = head[idx + 6..] - .chars() - .take_while(char::is_ascii_digit) - .collect::() - .parse() - .unwrap_or(0); - } - } else if line.len() > 3 { - let x = line.as_bytes()[0] as char; - let y = line.as_bytes()[1] as char; - let path = line[3..].trim().to_string(); - if x == '?' || y == '?' { - info.unstaged.push(('?', path)); - continue; - } - // One file can be in BOTH lists (partially staged). - if x != ' ' { - info.staged.push((x, path.clone())); - } - if y != ' ' { - info.unstaged.push((y, path)); - } - } - } - - // `x files changed, 140 insertions(+), 69 deletions(-)` → (140, 69). - if let Some(stat) = run(&["diff", "--shortstat", "HEAD"]) { - for part in stat.split(',') { - let num: u64 = part.trim().split(' ').next().and_then(|n| n.parse().ok()).unwrap_or(0); - if part.contains("insertion") { - info.plus = num; - } else if part.contains("deletion") { - info.minus = num; - } - } - } - Some(info) -} - -// ---- rendering (mirrors the `settings.rs` split: the parent `display::mod` -// hands in a snapshot + renderer; this module owns the drawer's pixels) ---- - -use crate::display::color::Rgb; -use crate::renderer::ui::{Rgba, UiQuad}; -use crate::renderer::{GlyphCache, Renderer}; - -use super::{NebulaTheme, SizeInfo, UI_CORNER_RADIUS_LOGICAL}; - -// Codicon glyphs (same family as the chrome's sidebar/settings icons). -pub(super) const ICON_FOLDER: &str = "\u{ea83}"; -pub(super) const ICON_FOLDER_OPEN: &str = "\u{eaf7}"; -const ICON_TERMINAL: &str = "\u{ea85}"; -const ICON_FILE: &str = "\u{ea7b}"; -pub(super) const ICON_CHEVRON_RIGHT: &str = "\u{eab6}"; -const ICON_CHEVRON_DOWN: &str = "\u{eab4}"; -const ICON_BRANCH: &str = "\u{ea68}"; -const ICON_SEARCH: &str = "\u{ea6d}"; - -/// File-type icon for a tree row, keyed by extension (dotfile names like -/// `.gitignore` count as their own family). The glyph carries the type; the -/// ink stays the tree's neutral scheme — no per-type colors in the chrome. -/// Every codepoint here is verified present in the bundled Maple Mono NF CN -/// (codicon/seti/devicon/octicon blocks), so nothing can render as tofu. -pub(super) fn file_type_icon(name: &str) -> &'static str { - let lower = name.to_ascii_lowercase(); - if lower.starts_with(".git") { - return "\u{e65d}"; // seti-git: .gitignore/.gitattributes/.gitmodules - } - let ext = lower.rsplit_once('.').map(|(_, ext)| ext).unwrap_or(""); - match ext { - "md" | "markdown" => "\u{eb1d}", // cod-markdown - "json" | "jsonl" | "ndjson" => "\u{eb0f}", // cod-json - "toml" => "\u{e6b2}", - "yml" | "yaml" => "\u{e6a8}", - "xml" => "\u{e619}", - "rs" => "\u{e68b}", - "py" => "\u{e606}", - "js" | "mjs" | "cjs" | "jsx" => "\u{e60c}", - "ts" | "tsx" => "\u{e628}", - "html" | "htm" => "\u{e60e}", - "css" | "scss" | "less" => "\u{e614}", - "c" | "h" => "\u{e61e}", - "cpp" | "cc" | "cxx" | "hpp" => "\u{e61d}", - "cs" => "\u{e648}", - "java" => "\u{e66d}", - "go" => "\u{e627}", - "sh" | "bash" | "zsh" => "\u{e691}", - "ps1" | "psm1" | "psd1" => "\u{e683}", - "bat" | "cmd" => "\u{ea85}", // cod-terminal - "sql" | "db" | "sqlite" => "\u{e64d}", - "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "ico" | "svg" => "\u{e60d}", - "zip" | "7z" | "rar" | "gz" | "tar" | "xz" | "zst" => "\u{f1c6}", - "pdf" => "\u{f1c1}", - "lock" => "\u{e672}", - "log" => "\u{f4ed}", - "txt" => "\u{f0f6}", - _ => ICON_FILE, - } -} - -/// Git status colors (GitHub Primer hues), picked per theme brightness so -/// they hold contrast on both surface families. -fn status_color(status: char, is_light: bool) -> Option { - Some(match (status, is_light) { - ('M' | 'R' | 'C', false) => Rgb::new(210, 153, 34), - ('M' | 'R' | 'C', true) => Rgb::new(154, 103, 0), - ('A', false) => Rgb::new(63, 185, 80), - ('A', true) => Rgb::new(26, 127, 55), - ('D', false) => Rgb::new(248, 81, 73), - ('D', true) => Rgb::new(207, 34, 46), - _ => None?, // '?' and friends fall back to dim ink. - }) -} - -/// The terminal palette colors the tree rows share with `ls` (Nebula-List -/// paints dirs with ANSI Blue and executables with ANSI Green — the drawer -/// must agree with what the user sees in the grid, including theme switches). -#[derive(Clone, Copy)] -pub struct LsColors { - pub dir: Rgb, - pub exec: Rgb, -} - -/// Executable extensions, matching Nebula-List's green set. -fn is_executable(name: &str) -> bool { - let lower = name.to_lowercase(); - ["exe", "dll", "bat", "cmd", "ps1", "com", "msi", "sh"] - .iter() - .any(|ext| lower.rsplit('.').next() == Some(*ext) && lower.contains('.')) -} - -/// Push the drawer's background quads: the flat panel surface (same 底色 as -/// the left tab sidebar), the active header view-tab pill, and the filter input -/// box — all curved with the shared chrome radius. -/// Display columns of a drag-chip label (CJK counts 2) — shared by the quad -/// pass (chip width; it has no cell metrics) and the text pass, so the label -/// and its chip agree on the same width. -fn drag_chip_cols(name: &str) -> usize { - use unicode_width::UnicodeWidthChar; - name.chars().map(|c| c.width().unwrap_or(0).max(1)).sum() -} - -pub(super) fn push_quads( - panel: &SidePanel, - layout: &PanelLayout, - theme: &NebulaTheme, - quads: &mut Vec, - scale: f32, - cell_w: f32, -) { - let s = |v: f32| v * scale; - let palette = theme.palette(); - let sk = theme.skin(); - let (px, py, pw, ph) = layout.panel; - // Shared chrome radius + the tab sidebar's accent (edge_r) — so the drawer - // curves and lights up exactly like the left vertical tabs. - let radius = s(UI_CORNER_RADIUS_LOGICAL); - let accent = palette.edge_r; - - // Panel surface: the SAME flat 底色 as the left tab sidebar (`palette.panel`, - // not a gradient — the gradient budget belongs to the brand art, chrome - // stays flat). - quads.push(UiQuad::solid(px, py, pw, ph, radius, palette.panel)); - - // Header: two half-width view tabs. The active one wears the left - // sidebar's floating-pill language — an accent halo, the tab 底色, and a - // soft accent wash — no accent bar (state is brightness, per the sheet). - // A hovered inactive tab gets the quiet hover wash. - let tab_w = pw * 0.5 - s(8.0); - let tab_h = layout.header_h - s(8.0); - let (fx, gx) = (px + s(6.0), px + pw * 0.5 + s(2.0)); - let active_x = match panel.view { - PanelView::Files => fx, - PanelView::Git => gx, - }; - let ty = py + s(4.0); - quads.push(UiQuad::solid( - active_x - s(1.0), - ty - s(1.0), - tab_w + s(2.0), - tab_h + s(2.0), - radius + s(1.0), - Rgba::new(accent.r, accent.g, accent.b, 40), - )); - quads.push(UiQuad::solid(active_x, ty, tab_w, tab_h, radius, palette.tab_bg_l)); - quads.push(UiQuad::solid( - active_x, - ty, - tab_w, - tab_h, - radius, - Rgba::new(accent.r, accent.g, accent.b, 26), - )); - let hovered_tab_x = match panel.hover { - PanelHit::ViewFiles if panel.view != PanelView::Files => Some(fx), - PanelHit::ViewGit if panel.view != PanelView::Git => Some(gx), - _ => None, - }; - if let Some(hx) = hovered_tab_x { - quads.push(UiQuad::solid(hx, ty, tab_w, tab_h, radius, sk.hover)); - } - - if panel.view == PanelView::Files { - for (hit, (x, y, w, h)) in - panel_action_rects(layout, panel.custom_root_active(), panel.root().is_some()) - { - let fill = if panel.hover == hit { sk.hover_strong } else { sk.input }; - quads.push(UiQuad::solid(x, y, w, h, radius, fill)); - } - } - - // Hovered list row: a quiet wash under the pointer (never on top of the - // selected pill — selection outranks hover). - if let PanelHit::Row(i) = panel.hover { - if i < layout.max_rows { - let hover_ok = match panel.view { - PanelView::Files => panel - .file_rows() - .get(panel.scroll + i) - .is_some_and(|row| panel.selected.as_ref() != Some(&row.path)), - PanelView::Git => panel.git_row_is_file(i), - }; - if hover_ok { - let ry = layout.list_y + i as f32 * layout.row_h; - quads.push(UiQuad::solid( - px + s(10.0), - ry - s(1.0), - pw - s(20.0), - layout.row_h - s(4.0), - radius, - sk.hover, - )); - } - } - } - - // Files-view filter box (input surface; accent ring while focused). - if panel.view == PanelView::Files { - let (sx, sy, sw, sh) = layout.search; - if panel.search_focus { - let a = sk.accent; - quads.push(UiQuad::solid( - sx - s(1.0), - sy - s(1.0), - sw + s(2.0), - sh + s(2.0), - radius + s(1.0), - Rgba::new(a.r, a.g, a.b, 200), - )); - } - quads.push(UiQuad::solid(sx, sy, sw, sh, radius, sk.input)); - if panel.search_all_selected() && !panel.search.is_empty() { - let columns: usize = panel.search.chars().map(|c| c.width().unwrap_or(0)).sum(); - let selection_x = sx + s(8.0) + cell_w * 1.8; - let selection_w = (columns as f32 * cell_w).min(sw - (selection_x - sx) - s(8.0)); - quads.push(UiQuad::solid( - selection_x - s(2.0), - sy + s(6.0), - selection_w + s(4.0), - sh - s(12.0), - s(4.0), - sk.accent_soft, - )); - } - - // The selected file row wears the tab's floating-pill language: an - // accent halo + the tab 底色 + a soft accent wash — the same treatment - // the left sidebar's active tab and the header view-tab use, so a - // picked row reads as "selected" identically across the whole chrome. - // The dragged row shares it, so the drag has a visible subject from press. - let marked = panel.drag_file.as_ref().map(|d| &d.path).or(panel.selected.as_ref()); - if let Some(mark) = marked { - if let Some(i) = panel - .file_rows() - .iter() - .skip(panel.scroll) - .take(layout.max_rows) - .position(|row| &row.path == mark) - { - let ry = layout.list_y + i as f32 * layout.row_h - s(1.0); - let (px, _, pw, _) = layout.panel; - let rx = px + s(10.0); - let rw = pw - s(20.0); - let rh = layout.row_h - s(2.0); - quads.push(UiQuad::solid( - rx - s(1.0), - ry - s(1.0), - rw + s(2.0), - rh + s(2.0), - radius + s(1.0), - Rgba::new(accent.r, accent.g, accent.b, 40), - )); - quads.push(UiQuad::solid(rx, ry, rw, rh, radius, palette.tab_bg_l)); - quads.push(UiQuad::solid( - rx, - ry, - rw, - rh, - radius, - Rgba::new(accent.r, accent.g, accent.b, 26), - )); - } - } - - // Drag ghost: a floating chip beside the pointer while a file is in - // flight — the pointer alone was invisible feedback. - if let Some(drag) = panel.drag_file.as_ref().filter(|d| d.active) { - let (mx, my) = drag.pos; - let chip_w = (drag_chip_cols(&drag.name) as f32 * s(8.0) + s(32.0)).min(s(220.0)); - quads.push(UiQuad::solid( - mx + s(12.0), - my + s(14.0), - chip_w, - s(26.0), - s(8.0), - sk.accent_soft, - )); - quads.push(UiQuad::solid( - mx + s(12.0), - my + s(14.0), - s(2.0), - s(26.0), - s(1.0), - Rgba::new(sk.accent.r, sk.accent.g, sk.accent.b, 190), - )); - } - } else if panel.git().is_some() { - // Git view: the strip is either the commit-message input (accent - // ring) or the three action buttons (暂存 / 提交 / 推送). Outside a - // repository there is nothing to act on — no strip at all. - let (sx, sy, sw, sh) = layout.search; - if panel.commit_focus { - let a = sk.accent; - quads.push(UiQuad::solid( - sx - s(1.0), - sy - s(1.0), - sw + s(2.0), - sh + s(2.0), - radius + s(1.0), - Rgba::new(a.r, a.g, a.b, 200), - )); - quads.push(UiQuad::solid(sx, sy, sw, sh, radius, sk.input)); - if panel.commit_all_selected() && !panel.commit_msg.is_empty() { - let columns: usize = panel.commit_msg.chars().map(|c| c.width().unwrap_or(0)).sum(); - let selection_w = (columns as f32 * cell_w).min(sw - s(16.0)); - quads.push(UiQuad::solid( - sx + s(6.0), - sy + s(6.0), - selection_w + s(4.0), - sh - s(12.0), - s(4.0), - sk.accent_soft, - )); - } - } else { - for (bx, bw) in git_button_rects(sx, sw, s(6.0)) { - quads.push(UiQuad::solid(bx, sy, bw, sh, radius, sk.input)); - } - // Hovered action button brightens (hover wash over the pill). - if panel.hover == PanelHit::Search { - let (hx, _) = panel.hover_pos; - for (bx, bw) in git_button_rects(sx, sw, s(6.0)) { - if hx >= bx && hx < bx + bw { - quads.push(UiQuad::solid(bx, sy, bw, sh, radius, sk.hover)); - } - } - } - } - } -} - -/// The four git action buttons' `(x, w)` spans inside `sx..sx+sw`. -pub fn git_button_rects(sx: f32, sw: f32, gap: f32) -> [(f32, f32); 4] { - let bw = (sw - 3.0 * gap) / 4.0; - [(sx, bw), (sx + bw + gap, bw), (sx + 2.0 * (bw + gap), bw), (sx + 3.0 * (bw + gap), bw)] -} - -/// Draw the drawer's text: header tabs, the summary line (cwd tail or the -/// branch ± counts), the filter box content, then the visible rows. -pub(super) fn draw_text( - panel: &SidePanel, - layout: &PanelLayout, - theme: &NebulaTheme, - ls: LsColors, - r: &mut Renderer, - gc: &mut GlyphCache, - size: &SizeInfo, - scale: f32, -) { - let s = |v: f32| v * scale; - let cell_w = size.cell_width(); - let cell_h = size.cell_height(); - let sk = theme.skin(); - let is_light = theme.palette().is_light; - let (px, py, pw, _) = layout.panel; - let text_pad = s(12.0); - // Truncation budgets are in display COLUMNS (CJK counts 2), matching - // draw_chrome_text's advance — a char-count budget lets a CJK name run - // twice as wide as intended, straight across the hover wash. - // Paths left-truncate (`…tail` — the discriminating end stays visible); - // file names right-truncate (`name…`, see `truncate_tab_label`). - let clip_tail = |t: &str, budget_cols: usize| -> String { - use unicode_width::UnicodeWidthChar; - let budget = budget_cols.max(4); - let total: usize = t.chars().map(|c| c.width().unwrap_or(0).max(1)).sum(); - if total <= budget { - return t.to_string(); - } - // Walk from the end, keeping the widest tail that fits after the `…`. - let mut used = 1usize; // the ellipsis column - let mut tail = std::collections::VecDeque::new(); - for ch in t.chars().rev() { - let w = ch.width().unwrap_or(0).max(1); - if used + w > budget { - break; - } - used += w; - tail.push_front(ch); - } - format!("…{}", tail.iter().collect::()) - }; - // Right edge every row's text must stop before: the hover wash ends at - // `px + pw - s(10)`, keep a small inset inside it. - let row_text_right = px + pw - s(18.0); - - // Header tabs: icon + label. - let header_ty = py + (layout.header_h - cell_h) / 2.0; - let files_hover = panel.hover == PanelHit::ViewFiles; - let git_hover = panel.hover == PanelHit::ViewGit; - let files_lift = if files_hover && panel.view != PanelView::Files { -s(1.0) } else { 0.0 }; - let git_lift = if git_hover && panel.view != PanelView::Git { -s(1.0) } else { 0.0 }; - let (files_ink, git_ink) = match panel.view { - PanelView::Files => (sk.ink_strong, sk.ink_dim), - PanelView::Git => (sk.ink_dim, sk.ink_strong), - }; - let fx = px + s(6.0) + s(12.0); - r.draw_chrome_text(size, fx, header_ty + files_lift, files_ink, ICON_FOLDER, gc); - r.draw_chrome_text(size, fx + cell_w * 1.8, header_ty + files_lift, files_ink, "文件", gc); - let gx = px + pw * 0.5 + s(2.0) + s(12.0); - r.draw_chrome_text(size, gx, header_ty + git_lift, git_ink, ICON_BRANCH, gc); - r.draw_chrome_text(size, gx + cell_w * 1.8, header_ty + git_lift, git_ink, "Git", gc); - - let summary_y = py + layout.header_h + (s(30.0) - cell_h) / 2.0; - let scroll = panel.scroll; - let row_ty = |i: usize| layout.list_y + i as f32 * layout.row_h + (layout.row_h - cell_h) / 2.0; - - match panel.view { - PanelView::Files => { - let action_x = - panel_action_rects(layout, panel.custom_root_active(), panel.root().is_some()) - .map(|(_, rect)| rect.0) - .fold(px + pw - text_pad, f32::min); - let summary_cols = - (((action_x - px - 2.0 * text_pad) / cell_w).floor() as usize).max(4); - let (summary, summary_ink) = if let Some(notice) = panel.root_notice() { - (clip_tail(notice, summary_cols), Rgb::new(sk.danger.r, sk.danger.g, sk.danger.b)) - } else if let Some(hint) = match panel.hover { - PanelHit::NewTerminalHere => Some("在此新建终端"), - PanelHit::OpenDirectory => Some("选择文件树目录"), - PanelHit::FollowCurrentDirectory => Some("跟随当前终端"), - _ => None, - } { - (clip_tail(hint, summary_cols), sk.ink_strong) - } else { - ( - panel - .root() - .map(|root| clip_tail(&root.display().to_string(), summary_cols)) - .unwrap_or_else(|| "(无目录)".into()), - sk.ink_dim, - ) - }; - r.draw_chrome_text(size, px + text_pad, summary_y, summary_ink, &summary, gc); - - for (hit, (x, y, w, h)) in - panel_action_rects(layout, panel.custom_root_active(), panel.root().is_some()) - { - let ink = if panel.hover == hit { sk.ink_strong } else { sk.ink_dim }; - let (label, columns) = match hit { - PanelHit::OpenDirectory => (ICON_FOLDER_OPEN, 1.0), - PanelHit::NewTerminalHere => (ICON_TERMINAL, 1.0), - PanelHit::FollowCurrentDirectory => ("跟随", 4.0), - _ => continue, - }; - let tx = x + ((w - cell_w * columns) / 2.0).max(0.0); - let ty = y + (h - cell_h) / 2.0; - r.draw_chrome_text(size, tx, ty, ink, label, gc); - } - - // Filter box: magnifier + query (caret while focused) or hint. - let (sx, sy, _, sh) = layout.search; - let search_ty = sy + (sh - cell_h) / 2.0; - r.draw_chrome_text(size, sx + s(8.0), search_ty, sk.ink_faint, ICON_SEARCH, gc); - let qx = sx + s(8.0) + cell_w * 1.8; - if panel.search.is_empty() && !panel.search_focus { - r.draw_chrome_text(size, qx, search_ty, sk.ink_faint, "筛选文件…", gc); - } else { - let shown = if panel.search_focus - && !panel.search_all_selected() - && super::caret_blink_on() - { - format!("{}▏", panel.search) - } else { - panel.search.clone() - }; - r.draw_chrome_text(size, qx, search_ty, sk.ink_strong, &shown, gc); - } - - // Tree rows: chevron (dirs, tree mode only) + folder/file icon + name. - let filtering = !panel.search.trim().is_empty(); - for (i, row) in panel.file_rows().iter().skip(scroll).take(layout.max_rows).enumerate() - { - let hovered = matches!(panel.hover, PanelHit::Row(h) if h == i); - let selected = panel.selected.as_ref() == Some(&row.path) - || panel.drag_file.as_ref().is_some_and(|d| d.path == row.path); - let lift_x = if hovered || selected { s(1.0) } else { 0.0 }; - let lift_y = if hovered { -s(1.0) } else { 0.0 }; - let ry = row_ty(i) + lift_y; - let mut x = px + text_pad + row.depth as f32 * cell_w * 2.4 + lift_x; - if !filtering { - if row.is_dir && !row.is_parent { - let chev = - if row.expanded { ICON_CHEVRON_DOWN } else { ICON_CHEVRON_RIGHT }; - r.draw_chrome_text(size, x, ry, sk.ink_faint, chev, gc); - } - x += cell_w * 1.9; - } - let (icon, icon_ink, name_ink) = if row.is_dir { - // `ls` parity: directories in the terminal's ANSI blue. - (if row.expanded { ICON_FOLDER_OPEN } else { ICON_FOLDER }, ls.dir, ls.dir) - } else if is_executable(&row.name) { - // Executables in ANSI green, same as Nebula-List. - (file_type_icon(&row.name), ls.exec, ls.exec) - } else { - (file_type_icon(&row.name), sk.ink_dim, sk.ink) - }; - r.draw_chrome_text(size, x, ry, icon_ink, icon, gc); - // Name budget from its REAL pixel start (indent + chevron + - // icon) to the hover wash's right edge — a long name ends in - // `…` exactly inside the wash instead of bleeding past it. - let name_x = x + cell_w * 2.2; - let name_cols = (((row_text_right - name_x) / cell_w).floor() as usize).max(2); - let name = super::truncate_tab_label(&row.name, name_cols); - r.draw_chrome_text(size, name_x, ry, name_ink, &name, gc); - } - let has_real_rows = panel.file_rows().iter().any(|row| !row.is_parent); - if !has_real_rows { - let empty = if filtering { - crate::ux::EmptyState::new( - "没有匹配文件", - "当前筛选词未匹配工作区内容。", - "修改筛选词,或按 Esc 清空筛选。", - ) - } else if panel.root.is_none() { - crate::ux::EmptyState::new( - "没有可浏览的目录", - "当前终端尚未报告工作目录。", - "在终端中进入一个目录后点击刷新。", - ) - } else { - crate::ux::EmptyState::new( - "此目录为空", - "当前工作目录中没有可显示的文件。", - "在终端创建文件,或选择其他目录。", - ) - }; - let parent_row_offset = panel - .file_rows() - .first() - .is_some_and(|row| row.is_parent) - .then_some(layout.row_h) - .unwrap_or(0.0); - let y = layout.list_y + parent_row_offset + s(8.0); - r.draw_chrome_text(size, px + text_pad, y, sk.ink_strong, &empty.title, gc); - r.draw_chrome_text( - size, - px + text_pad, - y + s(20.0), - sk.ink_dim, - &super::truncate_tab_label(&empty.reason, 32), - gc, - ); - r.draw_chrome_text( - size, - px + text_pad, - y + s(40.0), - sk.accent, - &super::truncate_tab_label(&empty.action, 32), - gc, - ); - } - - // Drag ghost label, riding the chip pushed by `push_quads`. - // Same chip-width formula as there (that pass has no cell_w), then - // truncated against the REAL glyph advance so the label always - // ends inside the chip. - if let Some(drag) = panel.drag_file.as_ref().filter(|d| d.active) { - let (mx, my) = drag.pos; - let ty = my + s(12.0) + (s(26.0) - cell_h) / 2.0; - let chip_w = (drag_chip_cols(&drag.name) as f32 * s(8.0) + s(32.0)).min(s(220.0)); - let max_cols = (((chip_w - s(26.0)) / cell_w).floor() as usize).max(2); - r.draw_chrome_text( - size, - mx + s(10.0) + s(12.0), - ty, - sk.ink_strong, - &super::truncate_tab_label(&drag.name, max_cols), - gc, - ); - } - }, - PanelView::Git => match panel.git() { - Some(git) => { - // Branch line: icon + name strong; ↑ahead + line counts on the - // right (an op error takes the line over instead). - let bx = px + text_pad; - r.draw_chrome_text(size, bx, summary_y, sk.ink_dim, ICON_BRANCH, gc); - let branch = - clip_tail(if git.branch.is_empty() { "(no branch)" } else { &git.branch }, 18); - r.draw_chrome_text(size, bx + cell_w * 1.8, summary_y, sk.ink_strong, &branch, gc); - if let Some(err) = panel.op_error() { - let msg = clip_tail(&err, branch.chars().count() + 4); - let ex = px + pw - text_pad - msg.chars().count() as f32 * cell_w; - let c_del = status_color('D', is_light).unwrap(); - r.draw_chrome_text(size, ex, summary_y, c_del, &msg, gc); - } else { - let c_add = status_color('A', is_light).unwrap(); - let c_del = status_color('D', is_light).unwrap(); - let minus = format!("\u{2212}{}", git.minus); - let plus = format!("+{}", git.plus); - let ahead = - if git.ahead > 0 { format!("↑{} ", git.ahead) } else { String::new() }; - let minus_x = px + pw - text_pad - minus.chars().count() as f32 * cell_w; - let plus_x = minus_x - (plus.chars().count() + 1) as f32 * cell_w; - let ahead_x = plus_x - (ahead.chars().count() + 1) as f32 * cell_w; - if !ahead.is_empty() { - r.draw_chrome_text(size, ahead_x, summary_y, sk.accent, &ahead, gc); - } - r.draw_chrome_text(size, plus_x, summary_y, c_add, &plus, gc); - r.draw_chrome_text(size, minus_x, summary_y, c_del, &minus, gc); - } - - // Action strip: commit-message input while composing, else the - // 暂存 / 提交 / 推送 buttons (disabled = dim ink). - let (sx, sy, sw, sh) = layout.search; - let strip_ty = sy + (sh - cell_h) / 2.0; - if panel.commit_focus { - let caret = if !panel.commit_all_selected() && super::caret_blink_on() { - "▏" - } else { - "" - }; - let shown = format!("{}{caret}", panel.commit_msg); - let hint = if panel.commit_msg.is_empty() { - "提交信息… Enter 提交 · Esc 取消" - } else { - "" - }; - if hint.is_empty() { - r.draw_chrome_text(size, sx + s(8.0), strip_ty, sk.ink_strong, &shown, gc); - } else { - r.draw_chrome_text(size, sx + s(8.0), strip_ty, sk.ink_faint, hint, gc); - } - } else { - let busy = panel.op_running(); - let stage_on = !busy && !git.unstaged.is_empty(); - let commit_on = !busy && !git.staged.is_empty(); - let pull_on = !busy; - let push_on = !busy && git.ahead > 0; - let push_label = if git.ahead > 0 { - format!("推送 ↑{}", git.ahead) - } else { - "推送".to_string() - }; - let labels: [(&str, bool); 4] = [ - (if busy { "…" } else { "暂存" }, stage_on), - ("提交", commit_on), - ("拉取", pull_on), - (&push_label, push_on), - ]; - for ((bx, bw), (label, enabled)) in - git_button_rects(sx, sw, s(6.0)).into_iter().zip(labels) - { - let hovered = panel.hover == PanelHit::Search - && panel.hover_pos.0 >= bx - && panel.hover_pos.0 < bx + bw; - let cols: usize = - label.chars().map(|c| if c.is_ascii() { 1 } else { 2 }).sum(); - let lx = bx + (bw - cols as f32 * cell_w).max(0.0) / 2.0; - let ink = if enabled { sk.ink_strong } else { sk.ink_faint }; - r.draw_chrome_text( - size, - lx, - strip_ty + if hovered { -s(1.0) } else { 0.0 }, - ink, - label, - gc, - ); - } - } - - // Sectioned rows: 未暂存 header, its files, 已暂存 header, its - // files — one flat scroll space. - enum GLine<'a> { - Header(String), - File(char, &'a String), - } - let mut lines: Vec> = Vec::new(); - if git.unstaged.is_empty() && git.staged.is_empty() { - lines.push(GLine::Header("工作区干净".into())); - } else { - lines.push(GLine::Header(format!("未暂存 ({})", git.unstaged.len()))); - for (c, p) in &git.unstaged { - lines.push(GLine::File(*c, p)); - } - lines.push(GLine::Header(format!("已暂存 ({})", git.staged.len()))); - for (c, p) in &git.staged { - lines.push(GLine::File(*c, p)); - } - } - for (i, line) in lines.iter().skip(scroll).take(layout.max_rows).enumerate() { - let ry = row_ty(i); - match line { - GLine::Header(t) => { - r.draw_chrome_text(size, px + text_pad, ry, sk.ink_dim, t, gc) - }, - GLine::File(status, path) => { - let sc = status_color(*status, is_light).unwrap_or(sk.ink_dim); - r.draw_chrome_text( - size, - px + text_pad, - ry, - sc, - &status.to_string(), - gc, - ); - let path_x = px + text_pad + cell_w * 2.0; - let path_cols = - (((row_text_right - path_x) / cell_w).floor() as usize).max(4); - let text = clip_tail(path, path_cols); - r.draw_chrome_text(size, path_x, ry, sk.ink, &text, gc); - }, - } - } - }, - None => { - r.draw_chrome_text( - size, - px + text_pad, - summary_y, - sk.ink_dim, - "不在 git 仓库中", - gc, - ); - }, - }, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn toggle_switches_views_without_closing() { - let mut p = SidePanel::new(); - p.toggle(PanelView::Files); - assert!(p.open); - p.toggle(PanelView::Git); - assert!(p.open, "switching views keeps the drawer open"); - assert_eq!(p.view, PanelView::Git); - p.toggle(PanelView::Git); - assert!(!p.open, "re-toggling the current view closes"); - } - - #[test] - fn sync_noops_while_closed() { - let mut p = SidePanel::new(); - assert!(!p.sync(Some(std::env::temp_dir()))); - } - - #[test] - fn custom_root_is_window_local_and_ignores_cwd_sync_until_cleared() { - let base = - std::env::temp_dir().join(format!("nebula-panel-root-test-{}", std::process::id())); - let cwd = base.join("cwd"); - let custom = base.join("custom"); - let next_cwd = base.join("next-cwd"); - std::fs::create_dir_all(&cwd).unwrap(); - std::fs::create_dir_all(&custom).unwrap(); - std::fs::create_dir_all(&next_cwd).unwrap(); - - let mut panel = SidePanel::new(); - panel.toggle(PanelView::Files); - assert!(panel.sync(Some(cwd))); - assert!(panel.set_custom_root(custom.clone())); - assert!(panel.custom_root_active()); - - panel.sync(Some(next_cwd.clone())); - assert_eq!(panel.root(), Some(custom.as_path())); - - assert!(panel.clear_custom_root()); - assert!(!panel.custom_root_active()); - assert_eq!(panel.root(), Some(next_cwd.as_path())); - - std::fs::remove_dir_all(&base).unwrap(); - } - - #[test] - fn missing_custom_root_returns_to_latest_cwd_with_visible_feedback() { - let base = std::env::temp_dir() - .join(format!("nebula-panel-missing-root-test-{}", std::process::id())); - let cwd = base.join("cwd"); - let custom = base.join("custom"); - std::fs::create_dir_all(&cwd).unwrap(); - std::fs::create_dir_all(&custom).unwrap(); - - let mut panel = SidePanel::new(); - panel.toggle(PanelView::Files); - panel.sync(Some(cwd.clone())); - assert!(panel.set_custom_root(custom.clone())); - std::fs::remove_dir_all(&custom).unwrap(); - - assert!(panel.sync(Some(cwd.clone()))); - assert!(!panel.custom_root_active()); - assert_eq!(panel.root(), Some(cwd.as_path())); - assert_eq!(panel.root_notice(), Some("所选目录不可用,已跟随当前目录")); - - std::fs::remove_dir_all(&base).unwrap(); - } - - #[test] - fn invalid_custom_root_refreshes_notice_when_followed_cwd_is_the_same_path() { - let root = std::env::temp_dir() - .join(format!("nebula-panel-same-missing-root-test-{}", std::process::id())); - std::fs::create_dir_all(&root).unwrap(); - - let mut panel = SidePanel::new(); - panel.toggle(PanelView::Files); - assert!(panel.sync(Some(root.clone()))); - assert!(panel.set_custom_root(root.clone())); - std::fs::remove_dir_all(&root).unwrap(); - - assert!(panel.sync(Some(root.clone()))); - assert!(!panel.custom_root_active()); - assert_eq!(panel.root(), Some(root.as_path())); - assert_eq!(panel.root_notice(), Some("所选目录不可用,已跟随当前目录")); - } - - #[test] - fn tree_lists_dirs_first_and_expands_on_click() { - let base = std::env::temp_dir().join(format!("nebula-panel-test-{}", std::process::id())); - let sub = base.join("sub"); - std::fs::create_dir_all(&sub).unwrap(); - std::fs::write(base.join("a.txt"), "x").unwrap(); - std::fs::write(sub.join("inner.txt"), "y").unwrap(); - - let mut p = SidePanel::new(); - p.toggle(PanelView::Files); - assert!(p.sync(Some(base.clone()))); - let rows = p.file_rows(); - assert_eq!(rows[0].name, "..", "parent navigation stays at the top"); - assert!(rows[0].is_parent); - assert_eq!(rows[1].name, "sub", "directory sorts before file"); - assert!(rows[1].is_dir); - assert_eq!(rows.len(), 3, "collapsed dir hides children"); - - assert!(p.click_row(1), "clicking a dir toggles expansion"); - let rows = p.file_rows(); - assert_eq!(rows.len(), 4); - assert_eq!(rows[2].name, "inner.txt"); - assert_eq!(rows[2].depth, 1); - - std::fs::remove_dir_all(&base).unwrap(); - } - - #[test] - fn parent_row_navigates_the_tree_root_without_becoming_draggable() { - let base = std::env::temp_dir() - .join(format!("nebula-panel-parent-row-test-{}", std::process::id())); - let child = base.join("child"); - std::fs::create_dir_all(&child).unwrap(); - - let mut panel = SidePanel::new(); - panel.toggle(PanelView::Files); - assert!(panel.sync(Some(child.clone()))); - let parent = panel.file_rows().first().expect("parent row").clone(); - assert_eq!(parent.name, ".."); - assert!(parent.is_parent); - assert!(parent.is_dir); - assert_eq!(parent.path, base); - - let drag = FileDrag::new(parent.path.clone(), parent.name, true, 0, (10.0, 10.0)); - assert!(!panel.click_drag_source(&drag), "the parent row never enters drag dispatch"); - assert!(panel.click_row(0)); - assert_eq!(panel.root(), Some(base.as_path())); - assert!(panel.custom_root_active(), "upward navigation is window-local"); - - std::fs::remove_dir_all(&base).unwrap(); - } - - #[test] - fn directory_drag_defers_and_validates_the_plain_click() { - let base = std::env::temp_dir() - .join(format!("nebula-panel-directory-drag-test-{}", std::process::id())); - let sub = base.join("sub"); - std::fs::create_dir_all(&sub).unwrap(); - - let mut panel = SidePanel::new(); - panel.toggle(PanelView::Files); - assert!(panel.sync(Some(base.clone()))); - let drag = FileDrag::new(sub, "sub".into(), true, 1, (10.0, 10.0)); - - assert!(panel.click_drag_source(&drag), "a non-drag release keeps directory click"); - assert!(panel.file_rows()[1].expanded); - - let mut active = drag.clone(); - active.update_position((18.0, 10.0)); - assert!(active.active, "eight physical pixels arm the drag"); - assert!(!panel.click_drag_source(&active), "an active drag must not toggle the tree"); - assert!(panel.file_rows()[1].expanded); - - let mut stale = drag; - stale.source_row = 2; - assert!(!panel.click_drag_source(&stale), "a changed source row must be ignored"); - - std::fs::remove_dir_all(&base).unwrap(); - } - - #[test] - fn terminal_drop_text_requires_an_active_terminal_drop_and_quotes_unicode_whitespace() { - let path = PathBuf::from("D:/项目 空间"); - let mut drag = FileDrag::new(path, "项目 空间".into(), true, 0, (0.0, 0.0)); - - assert_eq!(drag.terminal_drop_text(true), None, "plain clicks never paste"); - drag.update_position((7.0, 7.0)); - assert!(!drag.active, "diagonal motion below each axis threshold remains a click"); - drag.update_position((8.0, 7.0)); - assert!(drag.active); - assert_eq!(drag.terminal_drop_text(false), None, "dropping back on the drawer is inert"); - assert_eq!( - String::from_utf8(drag.terminal_drop_text(true).unwrap()).unwrap(), - "\"D:/项目 空间\" " - ); - - let mut control = - FileDrag::new(PathBuf::from("unsafe\npath"), "unsafe".into(), true, 0, (0.0, 0.0)); - control.update_position((8.0, 0.0)); - assert_eq!(control.terminal_drop_text(true), None, "a drop must never inject Enter"); - } - - #[test] - fn hit_test_maps_header_and_rows() { - let l = panel_layout(1000.0, 800.0, 40.0, 30.0, 1.0, 1.0); - let (px, py, pw, _) = l.panel; - assert_eq!(panel_hit(&l, px - 1.0, py + 5.0), PanelHit::None); - assert_eq!(panel_hit(&l, px + 5.0, py + 5.0), PanelHit::ViewFiles); - assert_eq!(panel_hit(&l, px + pw - 5.0, py + 5.0), PanelHit::ViewGit); - assert_eq!(panel_hit(&l, px + 5.0, l.list_y + l.row_h * 1.5), PanelHit::Row(1)); - } - - #[test] - fn git_hover_only_accepts_real_file_rows() { - let mut panel = SidePanel::new(); - panel.view = PanelView::Git; - panel.git = Some(GitInfo { - branch: "main".into(), - plus: 0, - minus: 0, - ahead: 0, - unstaged: vec![('?', "one.txt".into()), ('M', "two.txt".into())], - staged: vec![('A', "three.txt".into())], - }); - - assert!(!panel.git_row_is_file(0), "未暂存标题"); - assert!(panel.git_row_is_file(1)); - assert!(panel.git_row_is_file(2)); - assert!(!panel.git_row_is_file(3), "已暂存标题"); - assert!(panel.git_row_is_file(4)); - assert!(!panel.git_row_is_file(5), "列表末尾空白行"); - - panel.scroll = 2; - assert!(panel.git_row_is_file(0), "滚动后的真实文件行"); - assert!(!panel.git_row_is_file(1), "滚动后的已暂存标题"); - } - - #[test] - fn files_summary_actions_have_distinct_exact_hit_targets() { - let layout = panel_layout(1000.0, 800.0, 40.0, 30.0, 1.0, 1.0); - let actions: Vec<_> = panel_action_rects(&layout, true, true).collect(); - let open = actions - .iter() - .find(|(hit, _)| *hit == PanelHit::OpenDirectory) - .expect("open-directory action"); - let follow = actions - .iter() - .find(|(hit, _)| *hit == PanelHit::FollowCurrentDirectory) - .expect("follow-current-directory action"); - let terminal = actions - .iter() - .find(|(hit, _)| *hit == PanelHit::NewTerminalHere) - .expect("new-terminal-here action"); - let center = |rect: (f32, f32, f32, f32)| (rect.0 + rect.2 / 2.0, rect.1 + rect.3 / 2.0); - let (open_x, open_y) = center(open.1); - let (follow_x, follow_y) = center(follow.1); - let (terminal_x, terminal_y) = center(terminal.1); - - assert_eq!( - panel_interactive_hit(&layout, PanelView::Files, true, true, open_x, open_y), - PanelHit::OpenDirectory - ); - assert_eq!( - panel_interactive_hit(&layout, PanelView::Files, true, true, terminal_x, terminal_y), - PanelHit::NewTerminalHere - ); - assert_eq!( - panel_interactive_hit(&layout, PanelView::Files, true, true, follow_x, follow_y), - PanelHit::FollowCurrentDirectory - ); - assert_eq!( - panel_interactive_hit(&layout, PanelView::Files, false, true, follow_x, follow_y), - PanelHit::Inside, - "the reset action must not exist while following the terminal cwd" - ); - assert_eq!( - panel_interactive_hit(&layout, PanelView::Files, false, false, terminal_x, terminal_y), - PanelHit::Inside, - "the terminal action must not exist without a tree root" - ); - assert_eq!( - panel_interactive_hit(&layout, PanelView::Git, true, true, open_x, open_y), - PanelHit::Inside, - "Files-only actions must not create invisible Git hit targets" - ); - - for (index, (_, a)) in actions.iter().enumerate() { - for (_, b) in actions.iter().skip(index + 1) { - let overlaps = - a.0 < b.0 + b.2 && a.0 + a.2 > b.0 && a.1 < b.1 + b.3 && a.1 + a.3 > b.1; - assert!(!overlaps, "summary action hit targets must not overlap"); - } - } - } - - #[test] - fn self_drawn_fields_replace_select_all_on_paste() { - let mut panel = SidePanel::new(); - panel.search_input("old"); - panel.search_select_all(); - assert_eq!(panel.search_selected_text().as_deref(), Some("old")); - panel.search_input("new\nvalue"); - assert_eq!(panel.search, "newvalue"); - - panel.commit_input("old commit"); - panel.commit_select_all(); - assert_eq!(panel.commit_selected_text().as_deref(), Some("old commit")); - panel.commit_input("new commit"); - assert_eq!(panel.commit_msg, "new commit"); - } - - #[test] - fn git_action_strip_has_four_equal_buttons() { - let rects = git_button_rects(10.0, 430.0, 10.0); - assert_eq!(rects.len(), 4); - assert!(rects.windows(2).all(|pair| (pair[0].1 - pair[1].1).abs() < f32::EPSILON)); - let last = rects.last().expect("at least one git action"); - assert!((last.0 + last.1 - 440.0).abs() < f32::EPSILON); - } - - #[test] - fn git_pull_is_fast_forward_only() { - assert_eq!(git_pull_args(), vec!["pull", "--ff-only"]); - } -} +//! Right-side drawer: directory tree / git status for the focused pane's cwd +//! This module owns only the *model* — tree flattening, git +//! parsing, layout maths, and hit-testing. Rendering lives in `display::mod` +//! (mirroring the command palette split), and input dispatch in `input::mod`. +//! +//! The panel is an overlay drawer: it floats above the terminal's right edge +//! instead of reflowing the PTY, so toggling it never resizes the shell. +//! +//! Refresh model: cheap and synchronous, but *only* on toggle, on a cwd/root +//! change, or when the throttle window (a few seconds) has elapsed — never on +//! every frame. `git --no-optional-locks` keeps the status call from touching +//! the index lock, so it can't corrupt or stall a concurrent git operation. + +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; +use unicode_width::UnicodeWidthChar; + +/// Which view the drawer shows. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PanelView { + /// Directory tree of the focused pane's cwd. + Files, + /// Git branch + working-tree changes of the enclosing repository. + Git, +} + +/// One flattened row of the directory tree. +#[derive(Debug, Clone)] +pub struct FileRow { + pub path: PathBuf, + pub name: String, + pub depth: usize, + pub is_dir: bool, + pub expanded: bool, + /// 用于切换到上级根目录的合成 `..` 行。必须显式区分,避免普通目录的 + /// 展开、拖拽和双击逻辑把导航项误认为真实文件系统条目。 + pub is_parent: bool, +} + +/// Parsed `git status` snapshot. +#[derive(Debug, Clone, Default)] +pub struct GitInfo { + /// Current branch (or short detached-HEAD description). + pub branch: String, + /// Working-tree line insertions/deletions (unstaged + staged). + pub plus: u64, + pub minus: u64, + /// Commits ahead of upstream — what a push would publish. 0 = nothing to + /// push (the push button keys off this: only committed work is pushable). + pub ahead: u32, + /// Worktree changes not yet staged (`??` counts here as `?`). + pub unstaged: Vec<(char, String)>, + /// Index changes ready to commit. + pub staged: Vec<(char, String)>, +} + +/// Result of hit-testing a pixel against the open drawer. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PanelHit { + None, + /// The "文件" view tab in the header. + ViewFiles, + /// The "Git" view tab in the header. + ViewGit, + /// The Files view's filter input box. + Search, + /// Choose a window-local tree root with the native folder picker. + OpenDirectory, + /// Open a fresh terminal tab whose PTY starts at the current tree root. + NewTerminalHere, + /// Clear the window-local root and resume following the focused pane cwd. + FollowCurrentDirectory, + /// A list row (index into the *visible* rows of the current view). + Row(usize), + /// Inside the panel but on nothing interactive. + Inside, +} + +/// An in-progress drag of a tree entry toward the terminal (drop = paste the +/// full path into the shell, like dropping an entry from Explorer). +#[derive(Debug, Clone)] +pub struct FileDrag { + pub path: PathBuf, + /// Display name for the drag ghost that follows the pointer. + pub name: String, + /// Pointer position at press; the drag activates past a small threshold + /// so plain clicks (and double-clicks) don't count as drags. + pub origin: (f32, f32), + /// Latest pointer position (physical px) — anchors the drag ghost. + pub pos: (f32, f32), + /// Directories defer their normal expand/collapse click until release, so + /// crossing the drag threshold never mutates the tree as a side effect. + pub is_dir: bool, + /// Visible row at press time. Release validates its path before toggling, + /// preventing a throttled tree refresh from acting on a different row. + pub source_row: usize, + pub active: bool, +} + +impl FileDrag { + pub fn new( + path: PathBuf, + name: String, + is_dir: bool, + source_row: usize, + origin: (f32, f32), + ) -> Self { + Self { path, name, origin, pos: origin, is_dir, source_row, active: false } + } + + /// Update the ghost position and cross the small click/drag threshold. + /// Once active, a drag never falls back to an expand/collapse click. + pub fn update_position(&mut self, pos: (f32, f32)) { + self.pos = pos; + if !self.active { + let (ox, oy) = self.origin; + if (pos.0 - ox).abs() >= 8.0 || (pos.1 - oy).abs() >= 8.0 { + self.active = true; + } + } + } + + /// Bytes pasted on a valid terminal drop. This intentionally preserves + /// the existing cross-shell compatibility boundary: whitespace paths use + /// double quotes, but no single literal syntax can safely encode every + /// special character for PowerShell, CMD, Bash and NuShell at once. + pub fn terminal_drop_text(&self, over_terminal: bool) -> Option> { + if !self.active || !over_terminal { + return None; + } + let mut text = self.path.display().to_string(); + // Unix permits control characters (including CR/LF) in file names. + // Sending those bytes to a PTY could execute input despite the drop + // contract explicitly requiring paste-only behaviour. + if text.chars().any(char::is_control) { + return None; + } + if text.contains(char::is_whitespace) { + text = format!("\"{text}\""); + } + text.push(' '); + Some(text.into_bytes()) + } +} + +/// Re-run the (throttled) refresh at most this often while the panel is open. +const REFRESH_EVERY: Duration = Duration::from_secs(4); +/// Hard cap on flattened tree rows, bounding both fs walking and rendering. +const MAX_ROWS: usize = 400; +/// Hard cap on entries listed per directory. +const MAX_PER_DIR: usize = 200; +/// Total directory entries the filter index may VISIT while being built. +/// This bounds the walk itself (a `target/` or `node_modules/` tree has +/// hundreds of thousands of entries — walking it per keystroke froze the UI), +/// not just the matches kept. +const SEARCH_VISIT_BUDGET: usize = 20_000; +/// Entries kept in the filter index. +const SEARCH_INDEX_CAP: usize = 10_000; +/// Directories that are all bulk and no signal — never indexed for filtering. +const SEARCH_SKIP_DIRS: &[&str] = + &["target", "node_modules", ".git", ".cache", ".gradle", "build", "trellis"]; + +pub struct SidePanel { + pub open: bool, + pub view: PanelView, + /// Root the tree/git snapshot was built from (the focused pane's cwd). + root: Option, + /// Latest focused pane cwd, retained while a custom root is active so the + /// panel can resume following immediately without persisting any setting. + followed_cwd: Option, + /// Window-local override selected from the Files view. + custom_root: Option, + /// Visible feedback for an invalid/disappeared custom root. + root_notice: Option, + /// Flattened visible tree rows for the Files view. + rows: Vec, + /// Directories the user expanded (persists across refreshes). + expanded: HashSet, + /// Git snapshot, `None` when the root isn't inside a work tree. + git: Option, + /// Scroll offset in rows. + pub scroll: usize, + /// Files-view filter query; non-empty switches the tree to a flat list of + /// deep matches (VS Code's explorer filter). + pub search: String, + /// Whether the filter box owns the keyboard. + pub search_focus: bool, + search_selection: super::text_input::SelectAllState, + /// Flat, budget-bounded index of the tree used by the filter. Built ONCE + /// on the first filtering keystroke and reused for the rest of the query + /// (each keystroke then only string-matches in memory); dropped whenever + /// the root changes or a refresh rebuilds the snapshot. + search_index: Option>, + /// Commit-message input (Git view): buffer + focus, same modal keyboard + /// contract as the Files filter box. + pub commit_msg: String, + pub commit_focus: bool, + commit_selection: super::text_input::SelectAllState, + /// Last clicked file row (path + when), for double-click-to-open. + pub last_file_click: Option<(PathBuf, Instant)>, + /// In-progress drag of a file or directory row toward the terminal. + pub drag_file: Option, + /// Persistently selected file (row highlight). Cleared by clicking off + /// the panel, closing the drawer, or the root changing. + pub selected: Option, + /// What the pointer currently hovers (rows/buttons/header tabs light up). + pub hover: PanelHit, + /// Pointer position of the last hover update — disambiguates WHICH git + /// action button is under the pointer inside the shared strip. + pub hover_pos: (f32, f32), + /// A git mutation (add/commit/push) is running on a worker thread; the + /// action buttons gray out and re-arm when it lands. + op_running: std::sync::Arc, + /// Set by the worker when it finishes — `sync` folds it into a refresh. + op_done: std::sync::Arc, + /// Last operation's error (empty = success), shown on the summary line. + op_error: std::sync::Arc>, + last_refresh: Option, + needs_refresh: bool, +} + +fn git_pull_args() -> Vec { + vec!["pull".into(), "--ff-only".into()] +} + +impl SidePanel { + pub fn new() -> Self { + Self { + open: false, + view: PanelView::Files, + root: None, + followed_cwd: None, + custom_root: None, + root_notice: None, + rows: Vec::new(), + expanded: HashSet::new(), + git: None, + scroll: 0, + search: String::new(), + search_focus: false, + search_selection: Default::default(), + search_index: None, + commit_msg: String::new(), + commit_focus: false, + commit_selection: Default::default(), + last_file_click: None, + drag_file: None, + selected: None, + hover: PanelHit::None, + hover_pos: (0.0, 0.0), + op_running: Default::default(), + op_done: Default::default(), + op_error: Default::default(), + last_refresh: None, + needs_refresh: false, + } + } + + /// Toggle the drawer. Re-invoking with the *other* view while open only + /// switches views (VS Code sidebar behaviour) instead of closing. + pub fn toggle(&mut self, view: PanelView) { + if self.open && self.view == view { + self.open = false; + self.selected = None; + self.drag_file = None; + return; + } + self.open = true; + self.view = view; + self.scroll = 0; + self.needs_refresh = true; + } + + /// Adopt the focused pane's cwd, refreshing when the root changed, a + /// refresh was requested (toggle), or the throttle window has elapsed. + /// Called once per drawn frame from the window context; cheap when nothing + /// changed. Returns whether the snapshot was rebuilt (i.e. needs redraw). + pub fn sync(&mut self, cwd: Option) -> bool { + if !self.open { + return false; + } + // 聚焦 pane 报不出本地有效目录时(SSH 远端路径、未映射的 WSL 路 + // 径、shell 还没发 OSC)保持现状:跟随的语义是"最后一个已知有效 + // 目录",清空只会让切到远程 tab 的瞬间目录树闪成空白。 + if cwd.is_some() { + self.followed_cwd = cwd; + } + let custom_invalidated = self.custom_root.as_ref().is_some_and(|root| !root.is_dir()); + if custom_invalidated { + self.custom_root = None; + self.root_notice = Some("所选目录不可用,已跟随当前目录".to_owned()); + } + let next_root = self.custom_root.clone().or_else(|| self.followed_cwd.clone()); + let root_changed = next_root != self.root; + // While a filter query is live, skip the periodic re-snapshot: it + // would drop and rebuild the search index under the user's fingers. + let stale = self.search.trim().is_empty() + && self.last_refresh.is_none_or(|t| t.elapsed() >= REFRESH_EVERY); + // A finished git mutation forces a refresh so the new state (staged + // list, ahead count) shows on the next frame. + if self.op_done.swap(false, std::sync::atomic::Ordering::Relaxed) { + self.needs_refresh = true; + } + if !(root_changed || custom_invalidated || stale || self.needs_refresh) { + return false; + } + if root_changed { + self.root = next_root; + self.expanded.clear(); + self.scroll = 0; + self.selected = None; + } + self.refresh(); + true + } + + /// Override the focused pane cwd for this panel instance only. `SidePanel` + /// belongs to one window and this field is deliberately never serialized. + pub fn set_custom_root(&mut self, root: PathBuf) -> bool { + if !root.is_dir() { + self.root_notice = Some("所选目录不可用".to_owned()); + return false; + } + let changed = self.custom_root.as_ref() != Some(&root) || self.root.as_ref() != Some(&root); + self.custom_root = Some(root.clone()); + self.root_notice = None; + if self.root.as_ref() != Some(&root) { + self.root = Some(root); + self.expanded.clear(); + self.scroll = 0; + self.selected = None; + self.refresh(); + } + changed + } + + /// Resume following the most recently observed focused pane cwd. + pub fn clear_custom_root(&mut self) -> bool { + if self.custom_root.take().is_none() { + return false; + } + self.root_notice = None; + let next_root = self.followed_cwd.clone(); + if self.root != next_root { + self.root = next_root; + self.expanded.clear(); + self.scroll = 0; + self.selected = None; + self.refresh(); + } + true + } + + pub fn custom_root_active(&self) -> bool { + self.custom_root.is_some() + } + + pub fn root_notice(&self) -> Option<&str> { + self.root_notice.as_deref() + } + + /// Only real Git file rows are interactive. Section headers and the blank + /// area below the snapshot must never produce a full-width hover pill. + pub fn git_row_is_file(&self, visible_row: usize) -> bool { + if self.view != PanelView::Git { + return false; + } + let Some(git) = self.git.as_ref() else { return false }; + let absolute = self.scroll + visible_row; + if git.unstaged.is_empty() && git.staged.is_empty() { + return false; + } + let unstaged = 1..1 + git.unstaged.len(); + let staged_start = git.unstaged.len() + 2; + let staged = staged_start..staged_start + git.staged.len(); + unstaged.contains(&absolute) || staged.contains(&absolute) + } + + /// 外部(右键菜单删除等文件系统变更)请求下一帧重建快照。 + pub fn request_refresh(&mut self) { + self.needs_refresh = true; + } + + /// 面板顶部的一句话提示(复用根目录不可用的同一条 UI)。 + pub fn set_notice(&mut self, message: String) { + self.root_notice = Some(message); + } + + /// Rebuild the tree and git snapshot from `root`. + fn refresh(&mut self) { + self.needs_refresh = false; + self.last_refresh = Some(Instant::now()); + // New snapshot → the filter index is stale; rebuild lazily on demand. + self.search_index = None; + self.rebuild_rows(); + self.git = None; + if let Some(root) = self.root.clone() { + self.git = read_git(&root); + } + } + + /// Rebuild only the flattened rows (tree shape / filter changes; the git + /// snapshot stays). + fn rebuild_rows(&mut self) { + self.rows.clear(); + let Some(root) = self.root.clone() else { return }; + let needle = self.search.trim().to_lowercase(); + if needle.is_empty() { + if let Some(parent) = root.parent() { + self.rows.push(FileRow { + path: parent.to_path_buf(), + name: "..".to_owned(), + depth: 0, + is_dir: true, + expanded: false, + is_parent: true, + }); + } + self.flatten_dir(&root, 0); + return; + } + // Filter mode: string-match against the cached flat index. The index + // is built at most once per snapshot (budget-bounded walk); each + // keystroke after that is pure in-memory filtering — walking the tree + // per keystroke froze the UI on big checkouts. + if self.search_index.is_none() { + let mut index = Vec::new(); + let mut budget = SEARCH_VISIT_BUDGET; + build_search_index(&root, 0, &mut index, &mut budget); + self.search_index = Some(index); + } + let index = self.search_index.as_ref().unwrap(); + self.rows.extend( + index + .iter() + .filter(|row| row.name.to_lowercase().contains(&needle)) + .take(MAX_ROWS) + .cloned(), + ); + } + + /// Append typed text to the filter query and re-derive the rows. + pub fn search_input(&mut self, text: &str) { + self.search_selection.insert(&mut self.search, text); + self.scroll = 0; + self.rebuild_rows(); + } + + pub fn search_backspace(&mut self) { + self.search_selection.backspace(&mut self.search); + self.scroll = 0; + self.rebuild_rows(); + } + + pub fn search_select_all(&mut self) { + self.search_selection.select(&self.search); + } + + pub fn search_selected_text(&self) -> Option { + self.search_selection.selected_text(&self.search) + } + + pub fn search_all_selected(&self) -> bool { + self.search_selection.is_selected() + } + + /// Leave the filter box; `clear` also resets the query (Esc). + pub fn search_unfocus(&mut self, clear: bool) { + self.search_focus = false; + self.search_selection.clear(); + if clear && !self.search.is_empty() { + self.search.clear(); + self.scroll = 0; + self.rebuild_rows(); + } + } + + // ---- git mutations (add / commit / pull / push) ---- + + /// Whether a git mutation is in flight (buttons gray out). + pub fn op_running(&self) -> bool { + self.op_running.load(std::sync::atomic::Ordering::Relaxed) + } + + /// Last mutation's error, if any (cleared by the next successful op). + pub fn op_error(&self) -> Option { + let e = self.op_error.lock().ok()?; + (!e.is_empty()).then(|| e.clone()) + } + + /// Run `git ` on a worker thread; UI stays live (a push can take + /// seconds over the network). Completion flips `op_done`, which the next + /// drawn frame folds into a refresh. + fn spawn_git(&mut self, args: Vec) { + use std::sync::atomic::Ordering; + let Some(root) = self.root.clone() else { return }; + if self.op_running.swap(true, Ordering::Relaxed) { + return; // one at a time + } + let running = self.op_running.clone(); + let done = self.op_done.clone(); + let error = self.op_error.clone(); + std::thread::Builder::new() + .name("nebula-git-op".into()) + .spawn(move || { + let mut cmd = std::process::Command::new("git"); + cmd.args(&args).current_dir(&root); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW + } + let msg = match cmd.output() { + Ok(out) if out.status.success() => String::new(), + Ok(out) => { + let err = String::from_utf8_lossy(&out.stderr); + // First meaningful line is enough for a status strip. + err.lines().find(|l| !l.trim().is_empty()).unwrap_or("git 失败").to_string() + }, + Err(e) => format!("git: {e}"), + }; + if let Ok(mut slot) = error.lock() { + *slot = msg; + } + running.store(false, Ordering::Relaxed); + done.store(true, Ordering::Relaxed); + }) + .ok(); + } + + /// `git add -A`: stage everything (the ⊕ button). + pub fn git_stage_all(&mut self) { + if self.git.as_ref().is_some_and(|g| !g.unstaged.is_empty()) && !self.op_running() { + self.spawn_git(vec!["add".into(), "-A".into()]); + } + } + + /// Commit button: with staged changes, open the message input (Enter then + /// commits via [`Self::git_commit_submit`]). + pub fn git_begin_commit(&mut self) { + if self.git.as_ref().is_some_and(|g| !g.staged.is_empty()) && !self.op_running() { + self.commit_focus = true; + self.commit_selection.clear(); + } + } + + pub fn commit_input(&mut self, text: &str) { + self.commit_selection.insert(&mut self.commit_msg, text); + } + + pub fn commit_backspace(&mut self) { + self.commit_selection.backspace(&mut self.commit_msg); + } + + pub fn commit_select_all(&mut self) { + self.commit_selection.select(&self.commit_msg); + } + + pub fn commit_selected_text(&self) -> Option { + self.commit_selection.selected_text(&self.commit_msg) + } + + pub fn commit_all_selected(&self) -> bool { + self.commit_selection.is_selected() + } + + pub fn commit_cancel(&mut self) { + self.commit_focus = false; + self.commit_msg.clear(); + self.commit_selection.clear(); + } + + pub fn commit_unfocus(&mut self) { + self.commit_focus = false; + self.commit_selection.clear(); + } + + /// Enter in the message box: run `git commit -m `. + pub fn git_commit_submit(&mut self) { + let msg = self.commit_msg.trim().to_string(); + if msg.is_empty() || self.op_running() { + return; + } + self.commit_focus = false; + self.commit_msg.clear(); + self.commit_selection.clear(); + self.spawn_git(vec!["commit".into(), "-m".into(), msg]); + } + + /// Push button — only enabled with committed-but-unpushed work (`ahead`). + pub fn git_push(&mut self) { + if self.git.as_ref().is_some_and(|g| g.ahead > 0) && !self.op_running() { + self.spawn_git(vec!["push".into()]); + } + } + + /// Pull only fast-forward updates, never creating an implicit merge commit. + pub fn git_pull(&mut self) { + if self.git.is_some() && !self.op_running() { + self.spawn_git(git_pull_args()); + } + } + + /// Depth-first flatten of `dir` into `rows`, following `expanded`. + fn flatten_dir(&mut self, dir: &Path, depth: usize) { + if self.rows.len() >= MAX_ROWS { + return; + } + let Ok(read) = std::fs::read_dir(dir) else { return }; + let mut entries: Vec<(bool, String, PathBuf)> = read + .flatten() + .take(MAX_PER_DIR) + .filter_map(|e| { + let name = e.file_name().to_string_lossy().into_owned(); + // `.git` is noise in a file tree; everything else shows. + if name == ".git" { + return None; + } + let is_dir = e.file_type().ok()?.is_dir(); + Some((is_dir, name, e.path())) + }) + .collect(); + // Directories first, then case-insensitive alphabetical (Explorer/ + // VS Code convention). + entries.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.to_lowercase().cmp(&b.1.to_lowercase()))); + for (is_dir, name, path) in entries { + if self.rows.len() >= MAX_ROWS { + return; + } + let expanded = is_dir && self.expanded.contains(&path); + self.rows.push(FileRow { + path: path.clone(), + name, + depth, + is_dir, + expanded, + is_parent: false, + }); + if expanded { + self.flatten_dir(&path, depth + 1); + } + } + } + + /// Click on visible row `index` (post-scroll). Directories toggle their + /// expansion; files are inert (v1). Returns whether anything changed. + pub fn click_row(&mut self, index: usize) -> bool { + if self.view != PanelView::Files || !self.search.trim().is_empty() { + return false; + } + let Some(row) = self.rows.get(self.scroll + index) else { return false }; + if !row.is_dir { + return false; + } + let path = row.path.clone(); + if row.is_parent { + // 返回上级只改变窗口内的目录树根节点,不能连带修改终端 cwd; + // 用户仍可通过现有“跟随”操作回到当前终端目录。 + return self.set_custom_root(path); + } + if !self.expanded.remove(&path) { + self.expanded.insert(path); + } + // Re-flatten only (no git re-run): tree shape changed, content didn't. + self.rebuild_rows(); + true + } + + /// Complete the plain-click half of a pending directory drag. The source + /// path must still occupy the pressed row; otherwise a refresh/scroll + /// change could expand an unrelated directory after mouse release. + pub fn click_drag_source(&mut self, drag: &FileDrag) -> bool { + if drag.active || !drag.is_dir { + return false; + } + let matches_source = self + .visible_row(drag.source_row) + .is_some_and(|row| row.is_dir && !row.is_parent && row.path == drag.path); + matches_source && self.click_row(drag.source_row) + } + + /// Scroll by `delta` rows (positive = down), clamped to the list length. + pub fn scroll_by(&mut self, delta: i32, visible_rows: usize) { + let len = match self.view { + PanelView::Files => self.rows.len(), + // Two section headers + both file lists. + PanelView::Git => { + self.git.as_ref().map_or(0, |g| g.unstaged.len() + g.staged.len() + 2) + }, + }; + let max = len.saturating_sub(visible_rows); + self.scroll = (self.scroll as i64 + delta as i64).clamp(0, max as i64) as usize; + } + + pub fn file_rows(&self) -> &[FileRow] { + &self.rows + } + + /// The tree row currently shown at visible index `idx` (post-scroll). + pub fn visible_row(&self, idx: usize) -> Option<&FileRow> { + if self.view != PanelView::Files { + return None; + } + self.rows.get(self.scroll + idx) + } + + pub fn git(&self) -> Option<&GitInfo> { + self.git.as_ref() + } + + pub fn root(&self) -> Option<&Path> { + self.root.as_deref() + } +} + +/// Resting drawer width in logical pixels (clamped to 42% of the window in +/// `panel_layout`). Shared with the grid-padding reserve and the terminal +/// card's right edge, so the drawer genuinely occupies layout space (the grid +/// reflows around it) instead of floating over the terminal. +pub const PANEL_W_LOGICAL: f32 = 300.0; + +/// Panel geometry, physical pixels: `(x, y, w, h)` of the drawer, plus the +/// header strip height and one list row height. +pub struct PanelLayout { + pub panel: (f32, f32, f32, f32), + pub header_h: f32, + pub row_h: f32, + /// Files-view filter input box (between the summary line and the list). + pub search: (f32, f32, f32, f32), + /// Y of the first list row (below header, summary line and search box). + pub list_y: f32, + pub max_rows: usize, +} + +/// Drawer layout: a floating panel pinned to the SAME vertical band as the +/// left tab sidebar (`chrome_tab_layout`) — top at `margin + bar_h + 12`, +/// bottom at `win_h - margin - 12` — and inset from the right window edge by +/// `margin`, so both chrome panels share one height, one baseline, and float +/// with all four corners in open space (a flush edge squares off the corners). +/// The `_top`/`_bottom` chrome reserves the caller passes are no longer used +/// for the band: the constants here are locked to the sidebar's so the two can +/// never drift. `slide` is the open-animation progress (0 = fully off-screen +/// right, 1 = resting position); the whole drawer rides it. +pub fn panel_layout( + win_w: f32, + win_h: f32, + _top: f32, + _bottom: f32, + scale: f32, + slide: f32, + panel_w: f32, +) -> PanelLayout { + let s = |v: f32| v * scale; + // Same margin / bar height / breathing gap as `chrome_tab_layout`. + let margin = s(8.0); + let bar_h = s(40.0); + let gap = s(12.0); + // `panel_w` 是(可能被拖拽调过的)逻辑宽;[`PANEL_W_LOGICAL`] 只是默认值。 + let w = s(panel_w).min(win_w * 0.42); + // Motion Runtime already provides the physical response. Applying another + // curve here would double-ease the drawer and make its ending feel sticky. + let eased = slide.clamp(0.0, 1.0); + // Resting x is inset by `margin` (mirroring the left panel's left inset); + // closed, it rides fully off the right edge. Travel = the panel width plus + // its margin so nothing peeks while closed. + let rest_x = win_w - margin - w; + let x = rest_x + (1.0 - eased) * (w + margin); + let y = margin + bar_h + gap; + let h = (win_h - margin - gap - y).max(0.0); + let header_h = s(40.0); + let row_h = s(34.0); + let search = (x + s(14.0), y + header_h + s(34.0), w - s(28.0), s(34.0)); + let list_y = search.1 + search.3 + s(16.0); // header + summary + filter box + let max_rows = (((y + h) - list_y) / row_h).max(0.0) as usize; + PanelLayout { panel: (x, y, w, h), header_h, row_h, search, list_y, max_rows } +} + +/// Hit-test a pixel against the open drawer (`layout` from [`panel_layout`]). +pub fn panel_hit(layout: &PanelLayout, x: f32, y: f32) -> PanelHit { + let (px, py, pw, ph) = layout.panel; + if x < px || x >= px + pw || y < py || y >= py + ph { + return PanelHit::None; + } + if y < py + layout.header_h { + // Header: two half-width view tabs. + return if x < px + pw * 0.5 { PanelHit::ViewFiles } else { PanelHit::ViewGit }; + } + let (sx, sy, sw, sh) = layout.search; + if x >= sx && x < sx + sw && y >= sy && y < sy + sh { + return PanelHit::Search; + } + if y >= layout.list_y { + let row = ((y - layout.list_y) / layout.row_h) as usize; + if row < layout.max_rows { + return PanelHit::Row(row); + } + } + PanelHit::Inside +} + +pub fn panel_action_rects( + layout: &PanelLayout, + custom_root: bool, + has_root: bool, +) -> impl Iterator { + let scale = layout.header_h / 40.0; + let s = |value: f32| value * scale; + let (px, py, pw, _) = layout.panel; + let height = s(26.0); + let y = py + layout.header_h + s(4.0); + let right = px + pw - s(10.0); + let icon_width = s(30.0); + let gap = s(4.0); + let open_x = right - icon_width; + let terminal_x = open_x - gap - icon_width; + let leftmost_icon_x = if has_root { terminal_x } else { open_x }; + let follow_width = s(62.0); + let follow_x = leftmost_icon_x - gap - follow_width; + + // Fixed-size options keep per-frame draw and pointer hit-testing free of + // tiny heap allocations while still allowing root-dependent actions. + [ + Some((PanelHit::OpenDirectory, (open_x, y, icon_width, height))), + has_root.then_some((PanelHit::NewTerminalHere, (terminal_x, y, icon_width, height))), + (custom_root && has_root) + .then_some((PanelHit::FollowCurrentDirectory, (follow_x, y, follow_width, height))), + ] + .into_iter() + .flatten() +} + +pub fn panel_interactive_hit( + layout: &PanelLayout, + view: PanelView, + custom_root: bool, + has_root: bool, + x: f32, + y: f32, +) -> PanelHit { + if view == PanelView::Files { + for (hit, (rx, ry, rw, rh)) in panel_action_rects(layout, custom_root, has_root) { + if x >= rx && x < rx + rw && y >= ry && y < ry + rh { + return hit; + } + } + } + panel_hit(layout, x, y) +} + +/// Budget-bounded deep walk building the flat filter index. `budget` counts +/// every entry VISITED (not kept), so a huge build tree can't stall the UI; +/// bulk directories (`target/`, `node_modules/`, …) are skipped outright, and +/// symlinks/junctions are never followed (cycle safety). +fn build_search_index(dir: &Path, depth: usize, index: &mut Vec, budget: &mut usize) { + if *budget == 0 || depth > 8 || index.len() >= SEARCH_INDEX_CAP { + return; + } + let Ok(read) = std::fs::read_dir(dir) else { return }; + for entry in read.flatten() { + if *budget == 0 || index.len() >= SEARCH_INDEX_CAP { + return; + } + *budget -= 1; + let Ok(ft) = entry.file_type() else { continue }; + if ft.is_symlink() { + continue; + } + let name = entry.file_name().to_string_lossy().into_owned(); + let is_dir = ft.is_dir(); + if is_dir && (name.starts_with('.') || SEARCH_SKIP_DIRS.contains(&name.as_str())) { + continue; + } + let path = entry.path(); + index.push(FileRow { + path: path.clone(), + name, + depth: 0, + is_dir, + expanded: false, + is_parent: false, + }); + if is_dir { + build_search_index(&path, depth + 1, index, budget); + } + } +} + +/// Snapshot git state for `root`: branch, ±line counts, changed files./// `None` when git is missing or `root` isn't inside a work tree. Runs +/// synchronously — callers throttle (see [`SidePanel::sync`]). +fn read_git(root: &Path) -> Option { + use std::process::Command; + let run = |args: &[&str]| -> Option { + let mut cmd = Command::new("git"); + cmd.arg("--no-optional-locks").args(args).current_dir(root); + // Suppress the console window that `Command` flashes on Windows GUI apps. + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW + } + let out = cmd.output().ok()?; + out.status.success().then(|| String::from_utf8_lossy(&out.stdout).into_owned()) + }; + + // `-b --porcelain` yields `## branch...upstream [ahead N]` + one `XY path` + // per change, X = index (staged) status, Y = worktree status. + let status = run(&["status", "--porcelain", "-b"])?; + let mut info = GitInfo::default(); + for line in status.lines() { + if let Some(head) = line.strip_prefix("## ") { + // `main...origin/main [ahead 1]` → `main`; detached prints as-is. + info.branch = head.split("...").next().unwrap_or(head).to_string(); + if let Some(idx) = head.find("ahead ") { + info.ahead = head[idx + 6..] + .chars() + .take_while(char::is_ascii_digit) + .collect::() + .parse() + .unwrap_or(0); + } + } else if line.len() > 3 { + let x = line.as_bytes()[0] as char; + let y = line.as_bytes()[1] as char; + let path = line[3..].trim().to_string(); + if x == '?' || y == '?' { + info.unstaged.push(('?', path)); + continue; + } + // One file can be in BOTH lists (partially staged). + if x != ' ' { + info.staged.push((x, path.clone())); + } + if y != ' ' { + info.unstaged.push((y, path)); + } + } + } + + // `x files changed, 140 insertions(+), 69 deletions(-)` → (140, 69). + if let Some(stat) = run(&["diff", "--shortstat", "HEAD"]) { + for part in stat.split(',') { + let num: u64 = part.trim().split(' ').next().and_then(|n| n.parse().ok()).unwrap_or(0); + if part.contains("insertion") { + info.plus = num; + } else if part.contains("deletion") { + info.minus = num; + } + } + } + Some(info) +} + +// ---- rendering (mirrors the `settings.rs` split: the parent `display::mod` +// hands in a snapshot + renderer; this module owns the drawer's pixels) ---- + +use crate::display::color::Rgb; +use crate::renderer::ui::{Rgba, UiQuad}; +use crate::renderer::{GlyphCache, Renderer}; + +use super::{NebulaTheme, SizeInfo, UI_CORNER_RADIUS_LOGICAL}; + +// Codicon glyphs (same family as the chrome's sidebar/settings icons). +pub(super) const ICON_FOLDER: &str = "\u{ea83}"; +pub(super) const ICON_FOLDER_OPEN: &str = "\u{eaf7}"; +const ICON_TERMINAL: &str = "\u{ea85}"; +const ICON_FILE: &str = "\u{ea7b}"; +pub(super) const ICON_CHEVRON_RIGHT: &str = "\u{eab6}"; +const ICON_CHEVRON_DOWN: &str = "\u{eab4}"; +const ICON_BRANCH: &str = "\u{ea68}"; +const ICON_SEARCH: &str = "\u{ea6d}"; + +/// File-type icon for a tree row, keyed by extension (dotfile names like +/// `.gitignore` count as their own family). The glyph carries the type; the +/// ink stays the tree's neutral scheme — no per-type colors in the chrome. +/// Every codepoint here is verified present in the bundled Maple Mono NF CN +/// (codicon/seti/devicon/octicon blocks), so nothing can render as tofu. +pub(super) fn file_type_icon(name: &str) -> &'static str { + let lower = name.to_ascii_lowercase(); + if lower.starts_with(".git") { + return "\u{e65d}"; // seti-git: .gitignore/.gitattributes/.gitmodules + } + let ext = lower.rsplit_once('.').map(|(_, ext)| ext).unwrap_or(""); + match ext { + "md" | "markdown" => "\u{eb1d}", // cod-markdown + "json" | "jsonl" | "ndjson" => "\u{eb0f}", // cod-json + "toml" => "\u{e6b2}", + "yml" | "yaml" => "\u{e6a8}", + "xml" => "\u{e619}", + "rs" => "\u{e68b}", + "py" => "\u{e606}", + "js" | "mjs" | "cjs" | "jsx" => "\u{e60c}", + "ts" | "tsx" => "\u{e628}", + "html" | "htm" => "\u{e60e}", + "css" | "scss" | "less" => "\u{e614}", + "c" | "h" => "\u{e61e}", + "cpp" | "cc" | "cxx" | "hpp" => "\u{e61d}", + "cs" => "\u{e648}", + "java" => "\u{e66d}", + "go" => "\u{e627}", + "sh" | "bash" | "zsh" => "\u{e691}", + "ps1" | "psm1" | "psd1" => "\u{e683}", + "bat" | "cmd" => "\u{ea85}", // cod-terminal + "sql" | "db" | "sqlite" => "\u{e64d}", + "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "ico" | "svg" => "\u{e60d}", + "zip" | "7z" | "rar" | "gz" | "tar" | "xz" | "zst" => "\u{f1c6}", + "pdf" => "\u{f1c1}", + "lock" => "\u{e672}", + "log" => "\u{f4ed}", + "txt" => "\u{f0f6}", + _ => ICON_FILE, + } +} + +/// Git status colors (GitHub Primer hues), picked per theme brightness so +/// they hold contrast on both surface families. +fn status_color(status: char, is_light: bool) -> Option { + Some(match (status, is_light) { + ('M' | 'R' | 'C', false) => Rgb::new(210, 153, 34), + ('M' | 'R' | 'C', true) => Rgb::new(154, 103, 0), + ('A', false) => Rgb::new(63, 185, 80), + ('A', true) => Rgb::new(26, 127, 55), + ('D', false) => Rgb::new(248, 81, 73), + ('D', true) => Rgb::new(207, 34, 46), + _ => None?, // '?' and friends fall back to dim ink. + }) +} + +/// The terminal palette colors the tree rows share with `ls` (Nebula-List +/// paints dirs with ANSI Blue and executables with ANSI Green — the drawer +/// must agree with what the user sees in the grid, including theme switches). +#[derive(Clone, Copy)] +pub struct LsColors { + pub dir: Rgb, + pub exec: Rgb, +} + +/// Executable extensions, matching Nebula-List's green set. +fn is_executable(name: &str) -> bool { + let lower = name.to_lowercase(); + ["exe", "dll", "bat", "cmd", "ps1", "com", "msi", "sh"] + .iter() + .any(|ext| lower.rsplit('.').next() == Some(*ext) && lower.contains('.')) +} + +/// Push the drawer's background quads: the flat panel surface (same 底色 as +/// the left tab sidebar), the active header view-tab pill, and the filter input +/// box — all curved with the shared chrome radius. +/// Display columns of a drag-chip label (CJK counts 2) — shared by the quad +/// pass (chip width; it has no cell metrics) and the text pass, so the label +/// and its chip agree on the same width. +fn drag_chip_cols(name: &str) -> usize { + use unicode_width::UnicodeWidthChar; + name.chars().map(|c| c.width().unwrap_or(0).max(1)).sum() +} + +pub(super) fn push_quads( + panel: &SidePanel, + layout: &PanelLayout, + theme: &NebulaTheme, + quads: &mut Vec, + scale: f32, + cell_w: f32, +) { + let s = |v: f32| v * scale; + let palette = theme.palette(); + let sk = theme.skin(); + let (px, py, pw, ph) = layout.panel; + // Shared chrome radius + the tab sidebar's accent (edge_r) — so the drawer + // curves and lights up exactly like the left vertical tabs. + let radius = s(UI_CORNER_RADIUS_LOGICAL); + let accent = palette.edge_r; + + // Panel surface: the SAME flat 底色 as the left tab sidebar (`palette.panel`, + // not a gradient — the gradient budget belongs to the brand art, chrome + // stays flat). + quads.push(UiQuad::solid(px, py, pw, ph, radius, palette.panel)); + + // Header: two half-width view tabs. The active one wears the left + // sidebar's floating-pill language — an accent halo, the tab 底色, and a + // soft accent wash — no accent bar (state is brightness, per the sheet). + // A hovered inactive tab gets the quiet hover wash. + let tab_w = pw * 0.5 - s(8.0); + let tab_h = layout.header_h - s(8.0); + let (fx, gx) = (px + s(6.0), px + pw * 0.5 + s(2.0)); + let active_x = match panel.view { + PanelView::Files => fx, + PanelView::Git => gx, + }; + let ty = py + s(4.0); + quads.push(UiQuad::solid( + active_x - s(1.0), + ty - s(1.0), + tab_w + s(2.0), + tab_h + s(2.0), + radius + s(1.0), + Rgba::new(accent.r, accent.g, accent.b, 40), + )); + quads.push(UiQuad::solid(active_x, ty, tab_w, tab_h, radius, palette.tab_bg_l)); + quads.push(UiQuad::solid( + active_x, + ty, + tab_w, + tab_h, + radius, + Rgba::new(accent.r, accent.g, accent.b, 26), + )); + let hovered_tab_x = match panel.hover { + PanelHit::ViewFiles if panel.view != PanelView::Files => Some(fx), + PanelHit::ViewGit if panel.view != PanelView::Git => Some(gx), + _ => None, + }; + if let Some(hx) = hovered_tab_x { + quads.push(UiQuad::solid(hx, ty, tab_w, tab_h, radius, sk.hover)); + } + + if panel.view == PanelView::Files { + for (hit, (x, y, w, h)) in + panel_action_rects(layout, panel.custom_root_active(), panel.root().is_some()) + { + let fill = if panel.hover == hit { sk.hover_strong } else { sk.input }; + quads.push(UiQuad::solid(x, y, w, h, radius, fill)); + } + } + + // Hovered list row: a quiet wash under the pointer (never on top of the + // selected pill — selection outranks hover). + if let PanelHit::Row(i) = panel.hover { + if i < layout.max_rows { + let hover_ok = match panel.view { + PanelView::Files => panel + .file_rows() + .get(panel.scroll + i) + .is_some_and(|row| panel.selected.as_ref() != Some(&row.path)), + PanelView::Git => panel.git_row_is_file(i), + }; + if hover_ok { + let ry = layout.list_y + i as f32 * layout.row_h; + quads.push(UiQuad::solid( + px + s(10.0), + ry - s(1.0), + pw - s(20.0), + layout.row_h - s(4.0), + radius, + sk.hover, + )); + } + } + } + + // Files-view filter box (input surface; accent ring while focused). + if panel.view == PanelView::Files { + let (sx, sy, sw, sh) = layout.search; + if panel.search_focus { + let a = sk.accent; + quads.push(UiQuad::solid( + sx - s(1.0), + sy - s(1.0), + sw + s(2.0), + sh + s(2.0), + radius + s(1.0), + Rgba::new(a.r, a.g, a.b, 200), + )); + } + quads.push(UiQuad::solid(sx, sy, sw, sh, radius, sk.input)); + if panel.search_all_selected() && !panel.search.is_empty() { + let columns: usize = panel.search.chars().map(|c| c.width().unwrap_or(0)).sum(); + let selection_x = sx + s(8.0) + cell_w * 1.8; + let selection_w = (columns as f32 * cell_w).min(sw - (selection_x - sx) - s(8.0)); + quads.push(UiQuad::solid( + selection_x - s(2.0), + sy + s(6.0), + selection_w + s(4.0), + sh - s(12.0), + s(4.0), + sk.accent_soft, + )); + } + + // The selected file row wears the tab's floating-pill language: an + // accent halo + the tab 底色 + a soft accent wash — the same treatment + // the left sidebar's active tab and the header view-tab use, so a + // picked row reads as "selected" identically across the whole chrome. + // The dragged row shares it, so the drag has a visible subject from press. + let marked = panel.drag_file.as_ref().map(|d| &d.path).or(panel.selected.as_ref()); + if let Some(mark) = marked { + if let Some(i) = panel + .file_rows() + .iter() + .skip(panel.scroll) + .take(layout.max_rows) + .position(|row| &row.path == mark) + { + let ry = layout.list_y + i as f32 * layout.row_h - s(1.0); + let (px, _, pw, _) = layout.panel; + let rx = px + s(10.0); + let rw = pw - s(20.0); + let rh = layout.row_h - s(2.0); + quads.push(UiQuad::solid( + rx - s(1.0), + ry - s(1.0), + rw + s(2.0), + rh + s(2.0), + radius + s(1.0), + Rgba::new(accent.r, accent.g, accent.b, 40), + )); + quads.push(UiQuad::solid(rx, ry, rw, rh, radius, palette.tab_bg_l)); + quads.push(UiQuad::solid( + rx, + ry, + rw, + rh, + radius, + Rgba::new(accent.r, accent.g, accent.b, 26), + )); + } + } + + // Drag ghost: a floating chip beside the pointer while a file is in + // flight — the pointer alone was invisible feedback. + if let Some(drag) = panel.drag_file.as_ref().filter(|d| d.active) { + let (mx, my) = drag.pos; + let chip_w = (drag_chip_cols(&drag.name) as f32 * s(8.0) + s(32.0)).min(s(220.0)); + quads.push(UiQuad::solid( + mx + s(12.0), + my + s(14.0), + chip_w, + s(26.0), + s(8.0), + sk.accent_soft, + )); + quads.push(UiQuad::solid( + mx + s(12.0), + my + s(14.0), + s(2.0), + s(26.0), + s(1.0), + Rgba::new(sk.accent.r, sk.accent.g, sk.accent.b, 190), + )); + } + } else if panel.git().is_some() { + // Git view: the strip is either the commit-message input (accent + // ring) or the three action buttons (暂存 / 提交 / 推送). Outside a + // repository there is nothing to act on — no strip at all. + let (sx, sy, sw, sh) = layout.search; + if panel.commit_focus { + let a = sk.accent; + quads.push(UiQuad::solid( + sx - s(1.0), + sy - s(1.0), + sw + s(2.0), + sh + s(2.0), + radius + s(1.0), + Rgba::new(a.r, a.g, a.b, 200), + )); + quads.push(UiQuad::solid(sx, sy, sw, sh, radius, sk.input)); + if panel.commit_all_selected() && !panel.commit_msg.is_empty() { + let columns: usize = panel.commit_msg.chars().map(|c| c.width().unwrap_or(0)).sum(); + let selection_w = (columns as f32 * cell_w).min(sw - s(16.0)); + quads.push(UiQuad::solid( + sx + s(6.0), + sy + s(6.0), + selection_w + s(4.0), + sh - s(12.0), + s(4.0), + sk.accent_soft, + )); + } + } else { + for (bx, bw) in git_button_rects(sx, sw, s(6.0)) { + quads.push(UiQuad::solid(bx, sy, bw, sh, radius, sk.input)); + } + // Hovered action button brightens (hover wash over the pill). + if panel.hover == PanelHit::Search { + let (hx, _) = panel.hover_pos; + for (bx, bw) in git_button_rects(sx, sw, s(6.0)) { + if hx >= bx && hx < bx + bw { + quads.push(UiQuad::solid(bx, sy, bw, sh, radius, sk.hover)); + } + } + } + } + } +} + +/// The four git action buttons' `(x, w)` spans inside `sx..sx+sw`. +pub fn git_button_rects(sx: f32, sw: f32, gap: f32) -> [(f32, f32); 4] { + let bw = (sw - 3.0 * gap) / 4.0; + [(sx, bw), (sx + bw + gap, bw), (sx + 2.0 * (bw + gap), bw), (sx + 3.0 * (bw + gap), bw)] +} + +/// Draw the drawer's text: header tabs, the summary line (cwd tail or the +/// branch ± counts), the filter box content, then the visible rows. +pub(super) fn draw_text( + panel: &SidePanel, + layout: &PanelLayout, + theme: &NebulaTheme, + ls: LsColors, + r: &mut Renderer, + gc: &mut GlyphCache, + size: &SizeInfo, + scale: f32, +) { + let s = |v: f32| v * scale; + let cell_w = size.cell_width(); + let cell_h = size.cell_height(); + let sk = theme.skin(); + let is_light = theme.palette().is_light; + let (px, py, pw, _) = layout.panel; + let text_pad = s(12.0); + // Truncation budgets are in display COLUMNS (CJK counts 2), matching + // draw_chrome_text's advance — a char-count budget lets a CJK name run + // twice as wide as intended, straight across the hover wash. + // Paths left-truncate (`…tail` — the discriminating end stays visible); + // file names right-truncate (`name…`, see `truncate_tab_label`). + let clip_tail = |t: &str, budget_cols: usize| -> String { + use unicode_width::UnicodeWidthChar; + let budget = budget_cols.max(4); + let total: usize = t.chars().map(|c| c.width().unwrap_or(0).max(1)).sum(); + if total <= budget { + return t.to_string(); + } + // Walk from the end, keeping the widest tail that fits after the `…`. + let mut used = 1usize; // the ellipsis column + let mut tail = std::collections::VecDeque::new(); + for ch in t.chars().rev() { + let w = ch.width().unwrap_or(0).max(1); + if used + w > budget { + break; + } + used += w; + tail.push_front(ch); + } + format!("…{}", tail.iter().collect::()) + }; + // Right edge every row's text must stop before: the hover wash ends at + // `px + pw - s(10)`, keep a small inset inside it. + let row_text_right = px + pw - s(18.0); + + // Header tabs: icon + label. + let header_ty = py + (layout.header_h - cell_h) / 2.0; + let files_hover = panel.hover == PanelHit::ViewFiles; + let git_hover = panel.hover == PanelHit::ViewGit; + let files_lift = if files_hover && panel.view != PanelView::Files { -s(1.0) } else { 0.0 }; + let git_lift = if git_hover && panel.view != PanelView::Git { -s(1.0) } else { 0.0 }; + let (files_ink, git_ink) = match panel.view { + PanelView::Files => (sk.ink_strong, sk.ink_dim), + PanelView::Git => (sk.ink_dim, sk.ink_strong), + }; + let fx = px + s(6.0) + s(12.0); + r.draw_chrome_text(size, fx, header_ty + files_lift, files_ink, ICON_FOLDER, gc); + r.draw_chrome_text(size, fx + cell_w * 1.8, header_ty + files_lift, files_ink, "文件", gc); + let gx = px + pw * 0.5 + s(2.0) + s(12.0); + r.draw_chrome_text(size, gx, header_ty + git_lift, git_ink, ICON_BRANCH, gc); + r.draw_chrome_text(size, gx + cell_w * 1.8, header_ty + git_lift, git_ink, "Git", gc); + + let summary_y = py + layout.header_h + (s(30.0) - cell_h) / 2.0; + let scroll = panel.scroll; + let row_ty = |i: usize| layout.list_y + i as f32 * layout.row_h + (layout.row_h - cell_h) / 2.0; + + match panel.view { + PanelView::Files => { + let action_x = + panel_action_rects(layout, panel.custom_root_active(), panel.root().is_some()) + .map(|(_, rect)| rect.0) + .fold(px + pw - text_pad, f32::min); + let summary_cols = + (((action_x - px - 2.0 * text_pad) / cell_w).floor() as usize).max(4); + let (summary, summary_ink) = if let Some(notice) = panel.root_notice() { + (clip_tail(notice, summary_cols), Rgb::new(sk.danger.r, sk.danger.g, sk.danger.b)) + } else if let Some(hint) = match panel.hover { + PanelHit::NewTerminalHere => Some("在此新建终端"), + PanelHit::OpenDirectory => Some("选择文件树目录"), + PanelHit::FollowCurrentDirectory => Some("跟随当前终端"), + _ => None, + } { + (clip_tail(hint, summary_cols), sk.ink_strong) + } else { + ( + panel + .root() + .map(|root| clip_tail(&root.display().to_string(), summary_cols)) + .unwrap_or_else(|| "(无目录)".into()), + sk.ink_dim, + ) + }; + r.draw_chrome_text(size, px + text_pad, summary_y, summary_ink, &summary, gc); + + for (hit, (x, y, w, h)) in + panel_action_rects(layout, panel.custom_root_active(), panel.root().is_some()) + { + let ink = if panel.hover == hit { sk.ink_strong } else { sk.ink_dim }; + let (label, columns) = match hit { + PanelHit::OpenDirectory => (ICON_FOLDER_OPEN, 1.0), + PanelHit::NewTerminalHere => (ICON_TERMINAL, 1.0), + PanelHit::FollowCurrentDirectory => ("跟随", 4.0), + _ => continue, + }; + let tx = x + ((w - cell_w * columns) / 2.0).max(0.0); + let ty = y + (h - cell_h) / 2.0; + r.draw_chrome_text(size, tx, ty, ink, label, gc); + } + + // Filter box: magnifier + query (caret while focused) or hint. + let (sx, sy, _, sh) = layout.search; + let search_ty = sy + (sh - cell_h) / 2.0; + r.draw_chrome_text(size, sx + s(8.0), search_ty, sk.ink_faint, ICON_SEARCH, gc); + let qx = sx + s(8.0) + cell_w * 1.8; + if panel.search.is_empty() && !panel.search_focus { + r.draw_chrome_text(size, qx, search_ty, sk.ink_faint, "筛选文件…", gc); + } else { + let shown = if panel.search_focus + && !panel.search_all_selected() + && super::caret_blink_on() + { + format!("{}▏", panel.search) + } else { + panel.search.clone() + }; + r.draw_chrome_text(size, qx, search_ty, sk.ink_strong, &shown, gc); + } + + // Tree rows: chevron (dirs, tree mode only) + folder/file icon + name. + let filtering = !panel.search.trim().is_empty(); + for (i, row) in panel.file_rows().iter().skip(scroll).take(layout.max_rows).enumerate() + { + let hovered = matches!(panel.hover, PanelHit::Row(h) if h == i); + let selected = panel.selected.as_ref() == Some(&row.path) + || panel.drag_file.as_ref().is_some_and(|d| d.path == row.path); + let lift_x = if hovered || selected { s(1.0) } else { 0.0 }; + let lift_y = if hovered { -s(1.0) } else { 0.0 }; + let ry = row_ty(i) + lift_y; + let mut x = px + text_pad + row.depth as f32 * cell_w * 2.4 + lift_x; + if !filtering { + if row.is_dir && !row.is_parent { + let chev = + if row.expanded { ICON_CHEVRON_DOWN } else { ICON_CHEVRON_RIGHT }; + r.draw_chrome_text(size, x, ry, sk.ink_faint, chev, gc); + } + x += cell_w * 1.9; + } + let (icon, icon_ink, name_ink) = if row.is_dir { + // `ls` parity: directories in the terminal's ANSI blue. + (if row.expanded { ICON_FOLDER_OPEN } else { ICON_FOLDER }, ls.dir, ls.dir) + } else if is_executable(&row.name) { + // Executables in ANSI green, same as Nebula-List. + (file_type_icon(&row.name), ls.exec, ls.exec) + } else { + (file_type_icon(&row.name), sk.ink_dim, sk.ink) + }; + r.draw_chrome_text(size, x, ry, icon_ink, icon, gc); + // Name budget from its REAL pixel start (indent + chevron + + // icon) to the hover wash's right edge — a long name ends in + // `…` exactly inside the wash instead of bleeding past it. + let name_x = x + cell_w * 2.2; + let name_cols = (((row_text_right - name_x) / cell_w).floor() as usize).max(2); + let name = super::truncate_tab_label(&row.name, name_cols); + r.draw_chrome_text(size, name_x, ry, name_ink, &name, gc); + } + let has_real_rows = panel.file_rows().iter().any(|row| !row.is_parent); + if !has_real_rows { + let empty = if filtering { + crate::ux::EmptyState::new( + "没有匹配文件", + "当前筛选词未匹配工作区内容。", + "修改筛选词,或按 Esc 清空筛选。", + ) + } else if panel.root.is_none() { + crate::ux::EmptyState::new( + "没有可浏览的目录", + "当前终端尚未报告工作目录。", + "在终端中进入一个目录后点击刷新。", + ) + } else { + crate::ux::EmptyState::new( + "此目录为空", + "当前工作目录中没有可显示的文件。", + "在终端创建文件,或选择其他目录。", + ) + }; + let parent_row_offset = panel + .file_rows() + .first() + .is_some_and(|row| row.is_parent) + .then_some(layout.row_h) + .unwrap_or(0.0); + let y = layout.list_y + parent_row_offset + s(8.0); + r.draw_chrome_text(size, px + text_pad, y, sk.ink_strong, &empty.title, gc); + r.draw_chrome_text( + size, + px + text_pad, + y + s(20.0), + sk.ink_dim, + &super::truncate_tab_label(&empty.reason, 32), + gc, + ); + r.draw_chrome_text( + size, + px + text_pad, + y + s(40.0), + sk.accent, + &super::truncate_tab_label(&empty.action, 32), + gc, + ); + } + + // Drag ghost label, riding the chip pushed by `push_quads`. + // Same chip-width formula as there (that pass has no cell_w), then + // truncated against the REAL glyph advance so the label always + // ends inside the chip. + if let Some(drag) = panel.drag_file.as_ref().filter(|d| d.active) { + let (mx, my) = drag.pos; + let ty = my + s(12.0) + (s(26.0) - cell_h) / 2.0; + let chip_w = (drag_chip_cols(&drag.name) as f32 * s(8.0) + s(32.0)).min(s(220.0)); + let max_cols = (((chip_w - s(26.0)) / cell_w).floor() as usize).max(2); + r.draw_chrome_text( + size, + mx + s(10.0) + s(12.0), + ty, + sk.ink_strong, + &super::truncate_tab_label(&drag.name, max_cols), + gc, + ); + } + }, + PanelView::Git => match panel.git() { + Some(git) => { + // Branch line: icon + name strong; ↑ahead + line counts on the + // right (an op error takes the line over instead). + let bx = px + text_pad; + r.draw_chrome_text(size, bx, summary_y, sk.ink_dim, ICON_BRANCH, gc); + let branch = + clip_tail(if git.branch.is_empty() { "(no branch)" } else { &git.branch }, 18); + r.draw_chrome_text(size, bx + cell_w * 1.8, summary_y, sk.ink_strong, &branch, gc); + if let Some(err) = panel.op_error() { + let msg = clip_tail(&err, branch.chars().count() + 4); + let ex = px + pw - text_pad - msg.chars().count() as f32 * cell_w; + let c_del = status_color('D', is_light).unwrap(); + r.draw_chrome_text(size, ex, summary_y, c_del, &msg, gc); + } else { + let c_add = status_color('A', is_light).unwrap(); + let c_del = status_color('D', is_light).unwrap(); + let minus = format!("\u{2212}{}", git.minus); + let plus = format!("+{}", git.plus); + let ahead = + if git.ahead > 0 { format!("↑{} ", git.ahead) } else { String::new() }; + let minus_x = px + pw - text_pad - minus.chars().count() as f32 * cell_w; + let plus_x = minus_x - (plus.chars().count() + 1) as f32 * cell_w; + let ahead_x = plus_x - (ahead.chars().count() + 1) as f32 * cell_w; + if !ahead.is_empty() { + r.draw_chrome_text(size, ahead_x, summary_y, sk.accent, &ahead, gc); + } + r.draw_chrome_text(size, plus_x, summary_y, c_add, &plus, gc); + r.draw_chrome_text(size, minus_x, summary_y, c_del, &minus, gc); + } + + // Action strip: commit-message input while composing, else the + // 暂存 / 提交 / 推送 buttons (disabled = dim ink). + let (sx, sy, sw, sh) = layout.search; + let strip_ty = sy + (sh - cell_h) / 2.0; + if panel.commit_focus { + let caret = if !panel.commit_all_selected() && super::caret_blink_on() { + "▏" + } else { + "" + }; + let shown = format!("{}{caret}", panel.commit_msg); + let hint = if panel.commit_msg.is_empty() { + "提交信息… Enter 提交 · Esc 取消" + } else { + "" + }; + if hint.is_empty() { + r.draw_chrome_text(size, sx + s(8.0), strip_ty, sk.ink_strong, &shown, gc); + } else { + r.draw_chrome_text(size, sx + s(8.0), strip_ty, sk.ink_faint, hint, gc); + } + } else { + let busy = panel.op_running(); + let stage_on = !busy && !git.unstaged.is_empty(); + let commit_on = !busy && !git.staged.is_empty(); + let pull_on = !busy; + let push_on = !busy && git.ahead > 0; + let push_label = if git.ahead > 0 { + format!("推送 ↑{}", git.ahead) + } else { + "推送".to_string() + }; + let labels: [(&str, bool); 4] = [ + (if busy { "…" } else { "暂存" }, stage_on), + ("提交", commit_on), + ("拉取", pull_on), + (&push_label, push_on), + ]; + for ((bx, bw), (label, enabled)) in + git_button_rects(sx, sw, s(6.0)).into_iter().zip(labels) + { + let hovered = panel.hover == PanelHit::Search + && panel.hover_pos.0 >= bx + && panel.hover_pos.0 < bx + bw; + let cols: usize = + label.chars().map(|c| if c.is_ascii() { 1 } else { 2 }).sum(); + let lx = bx + (bw - cols as f32 * cell_w).max(0.0) / 2.0; + let ink = if enabled { sk.ink_strong } else { sk.ink_faint }; + r.draw_chrome_text( + size, + lx, + strip_ty + if hovered { -s(1.0) } else { 0.0 }, + ink, + label, + gc, + ); + } + } + + // Sectioned rows: 未暂存 header, its files, 已暂存 header, its + // files — one flat scroll space. + enum GLine<'a> { + Header(String), + File(char, &'a String), + } + let mut lines: Vec> = Vec::new(); + if git.unstaged.is_empty() && git.staged.is_empty() { + lines.push(GLine::Header("工作区干净".into())); + } else { + lines.push(GLine::Header(format!("未暂存 ({})", git.unstaged.len()))); + for (c, p) in &git.unstaged { + lines.push(GLine::File(*c, p)); + } + lines.push(GLine::Header(format!("已暂存 ({})", git.staged.len()))); + for (c, p) in &git.staged { + lines.push(GLine::File(*c, p)); + } + } + for (i, line) in lines.iter().skip(scroll).take(layout.max_rows).enumerate() { + let ry = row_ty(i); + match line { + GLine::Header(t) => { + r.draw_chrome_text(size, px + text_pad, ry, sk.ink_dim, t, gc) + }, + GLine::File(status, path) => { + let sc = status_color(*status, is_light).unwrap_or(sk.ink_dim); + r.draw_chrome_text( + size, + px + text_pad, + ry, + sc, + &status.to_string(), + gc, + ); + let path_x = px + text_pad + cell_w * 2.0; + let path_cols = + (((row_text_right - path_x) / cell_w).floor() as usize).max(4); + let text = clip_tail(path, path_cols); + r.draw_chrome_text(size, path_x, ry, sk.ink, &text, gc); + }, + } + } + }, + None => { + r.draw_chrome_text( + size, + px + text_pad, + summary_y, + sk.ink_dim, + "不在 git 仓库中", + gc, + ); + }, + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn toggle_switches_views_without_closing() { + let mut p = SidePanel::new(); + p.toggle(PanelView::Files); + assert!(p.open); + p.toggle(PanelView::Git); + assert!(p.open, "switching views keeps the drawer open"); + assert_eq!(p.view, PanelView::Git); + p.toggle(PanelView::Git); + assert!(!p.open, "re-toggling the current view closes"); + } + + #[test] + fn sync_noops_while_closed() { + let mut p = SidePanel::new(); + assert!(!p.sync(Some(std::env::temp_dir()))); + } + + #[test] + fn custom_root_is_window_local_and_ignores_cwd_sync_until_cleared() { + let base = + std::env::temp_dir().join(format!("nebula-panel-root-test-{}", std::process::id())); + let cwd = base.join("cwd"); + let custom = base.join("custom"); + let next_cwd = base.join("next-cwd"); + std::fs::create_dir_all(&cwd).unwrap(); + std::fs::create_dir_all(&custom).unwrap(); + std::fs::create_dir_all(&next_cwd).unwrap(); + + let mut panel = SidePanel::new(); + panel.toggle(PanelView::Files); + assert!(panel.sync(Some(cwd))); + assert!(panel.set_custom_root(custom.clone())); + assert!(panel.custom_root_active()); + + panel.sync(Some(next_cwd.clone())); + assert_eq!(panel.root(), Some(custom.as_path())); + + assert!(panel.clear_custom_root()); + assert!(!panel.custom_root_active()); + assert_eq!(panel.root(), Some(next_cwd.as_path())); + + std::fs::remove_dir_all(&base).unwrap(); + } + + #[test] + fn missing_custom_root_returns_to_latest_cwd_with_visible_feedback() { + let base = std::env::temp_dir() + .join(format!("nebula-panel-missing-root-test-{}", std::process::id())); + let cwd = base.join("cwd"); + let custom = base.join("custom"); + std::fs::create_dir_all(&cwd).unwrap(); + std::fs::create_dir_all(&custom).unwrap(); + + let mut panel = SidePanel::new(); + panel.toggle(PanelView::Files); + panel.sync(Some(cwd.clone())); + assert!(panel.set_custom_root(custom.clone())); + std::fs::remove_dir_all(&custom).unwrap(); + + assert!(panel.sync(Some(cwd.clone()))); + assert!(!panel.custom_root_active()); + assert_eq!(panel.root(), Some(cwd.as_path())); + assert_eq!(panel.root_notice(), Some("所选目录不可用,已跟随当前目录")); + + std::fs::remove_dir_all(&base).unwrap(); + } + + #[test] + fn invalid_custom_root_refreshes_notice_when_followed_cwd_is_the_same_path() { + let root = std::env::temp_dir() + .join(format!("nebula-panel-same-missing-root-test-{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + + let mut panel = SidePanel::new(); + panel.toggle(PanelView::Files); + assert!(panel.sync(Some(root.clone()))); + assert!(panel.set_custom_root(root.clone())); + std::fs::remove_dir_all(&root).unwrap(); + + assert!(panel.sync(Some(root.clone()))); + assert!(!panel.custom_root_active()); + assert_eq!(panel.root(), Some(root.as_path())); + assert_eq!(panel.root_notice(), Some("所选目录不可用,已跟随当前目录")); + } + + #[test] + fn tree_lists_dirs_first_and_expands_on_click() { + let base = std::env::temp_dir().join(format!("nebula-panel-test-{}", std::process::id())); + let sub = base.join("sub"); + std::fs::create_dir_all(&sub).unwrap(); + std::fs::write(base.join("a.txt"), "x").unwrap(); + std::fs::write(sub.join("inner.txt"), "y").unwrap(); + + let mut p = SidePanel::new(); + p.toggle(PanelView::Files); + assert!(p.sync(Some(base.clone()))); + let rows = p.file_rows(); + assert_eq!(rows[0].name, "..", "parent navigation stays at the top"); + assert!(rows[0].is_parent); + assert_eq!(rows[1].name, "sub", "directory sorts before file"); + assert!(rows[1].is_dir); + assert_eq!(rows.len(), 3, "collapsed dir hides children"); + + assert!(p.click_row(1), "clicking a dir toggles expansion"); + let rows = p.file_rows(); + assert_eq!(rows.len(), 4); + assert_eq!(rows[2].name, "inner.txt"); + assert_eq!(rows[2].depth, 1); + + std::fs::remove_dir_all(&base).unwrap(); + } + + #[test] + fn parent_row_navigates_the_tree_root_without_becoming_draggable() { + let base = std::env::temp_dir() + .join(format!("nebula-panel-parent-row-test-{}", std::process::id())); + let child = base.join("child"); + std::fs::create_dir_all(&child).unwrap(); + + let mut panel = SidePanel::new(); + panel.toggle(PanelView::Files); + assert!(panel.sync(Some(child.clone()))); + let parent = panel.file_rows().first().expect("parent row").clone(); + assert_eq!(parent.name, ".."); + assert!(parent.is_parent); + assert!(parent.is_dir); + assert_eq!(parent.path, base); + + let drag = FileDrag::new(parent.path.clone(), parent.name, true, 0, (10.0, 10.0)); + assert!(!panel.click_drag_source(&drag), "the parent row never enters drag dispatch"); + assert!(panel.click_row(0)); + assert_eq!(panel.root(), Some(base.as_path())); + assert!(panel.custom_root_active(), "upward navigation is window-local"); + + std::fs::remove_dir_all(&base).unwrap(); + } + + #[test] + fn directory_drag_defers_and_validates_the_plain_click() { + let base = std::env::temp_dir() + .join(format!("nebula-panel-directory-drag-test-{}", std::process::id())); + let sub = base.join("sub"); + std::fs::create_dir_all(&sub).unwrap(); + + let mut panel = SidePanel::new(); + panel.toggle(PanelView::Files); + assert!(panel.sync(Some(base.clone()))); + let drag = FileDrag::new(sub, "sub".into(), true, 1, (10.0, 10.0)); + + assert!(panel.click_drag_source(&drag), "a non-drag release keeps directory click"); + assert!(panel.file_rows()[1].expanded); + + let mut active = drag.clone(); + active.update_position((18.0, 10.0)); + assert!(active.active, "eight physical pixels arm the drag"); + assert!(!panel.click_drag_source(&active), "an active drag must not toggle the tree"); + assert!(panel.file_rows()[1].expanded); + + let mut stale = drag; + stale.source_row = 2; + assert!(!panel.click_drag_source(&stale), "a changed source row must be ignored"); + + std::fs::remove_dir_all(&base).unwrap(); + } + + #[test] + fn terminal_drop_text_requires_an_active_terminal_drop_and_quotes_unicode_whitespace() { + let path = PathBuf::from("D:/项目 空间"); + let mut drag = FileDrag::new(path, "项目 空间".into(), true, 0, (0.0, 0.0)); + + assert_eq!(drag.terminal_drop_text(true), None, "plain clicks never paste"); + drag.update_position((7.0, 7.0)); + assert!(!drag.active, "diagonal motion below each axis threshold remains a click"); + drag.update_position((8.0, 7.0)); + assert!(drag.active); + assert_eq!(drag.terminal_drop_text(false), None, "dropping back on the drawer is inert"); + assert_eq!( + String::from_utf8(drag.terminal_drop_text(true).unwrap()).unwrap(), + "\"D:/项目 空间\" " + ); + + let mut control = + FileDrag::new(PathBuf::from("unsafe\npath"), "unsafe".into(), true, 0, (0.0, 0.0)); + control.update_position((8.0, 0.0)); + assert_eq!(control.terminal_drop_text(true), None, "a drop must never inject Enter"); + } + + #[test] + fn hit_test_maps_header_and_rows() { + let l = panel_layout(1000.0, 800.0, 40.0, 30.0, 1.0, 1.0, PANEL_W_LOGICAL); + let (px, py, pw, _) = l.panel; + assert_eq!(panel_hit(&l, px - 1.0, py + 5.0), PanelHit::None); + assert_eq!(panel_hit(&l, px + 5.0, py + 5.0), PanelHit::ViewFiles); + assert_eq!(panel_hit(&l, px + pw - 5.0, py + 5.0), PanelHit::ViewGit); + assert_eq!(panel_hit(&l, px + 5.0, l.list_y + l.row_h * 1.5), PanelHit::Row(1)); + } + + #[test] + fn git_hover_only_accepts_real_file_rows() { + let mut panel = SidePanel::new(); + panel.view = PanelView::Git; + panel.git = Some(GitInfo { + branch: "main".into(), + plus: 0, + minus: 0, + ahead: 0, + unstaged: vec![('?', "one.txt".into()), ('M', "two.txt".into())], + staged: vec![('A', "three.txt".into())], + }); + + assert!(!panel.git_row_is_file(0), "未暂存标题"); + assert!(panel.git_row_is_file(1)); + assert!(panel.git_row_is_file(2)); + assert!(!panel.git_row_is_file(3), "已暂存标题"); + assert!(panel.git_row_is_file(4)); + assert!(!panel.git_row_is_file(5), "列表末尾空白行"); + + panel.scroll = 2; + assert!(panel.git_row_is_file(0), "滚动后的真实文件行"); + assert!(!panel.git_row_is_file(1), "滚动后的已暂存标题"); + } + + #[test] + fn files_summary_actions_have_distinct_exact_hit_targets() { + let layout = panel_layout(1000.0, 800.0, 40.0, 30.0, 1.0, 1.0, PANEL_W_LOGICAL); + let actions: Vec<_> = panel_action_rects(&layout, true, true).collect(); + let open = actions + .iter() + .find(|(hit, _)| *hit == PanelHit::OpenDirectory) + .expect("open-directory action"); + let follow = actions + .iter() + .find(|(hit, _)| *hit == PanelHit::FollowCurrentDirectory) + .expect("follow-current-directory action"); + let terminal = actions + .iter() + .find(|(hit, _)| *hit == PanelHit::NewTerminalHere) + .expect("new-terminal-here action"); + let center = |rect: (f32, f32, f32, f32)| (rect.0 + rect.2 / 2.0, rect.1 + rect.3 / 2.0); + let (open_x, open_y) = center(open.1); + let (follow_x, follow_y) = center(follow.1); + let (terminal_x, terminal_y) = center(terminal.1); + + assert_eq!( + panel_interactive_hit(&layout, PanelView::Files, true, true, open_x, open_y), + PanelHit::OpenDirectory + ); + assert_eq!( + panel_interactive_hit(&layout, PanelView::Files, true, true, terminal_x, terminal_y), + PanelHit::NewTerminalHere + ); + assert_eq!( + panel_interactive_hit(&layout, PanelView::Files, true, true, follow_x, follow_y), + PanelHit::FollowCurrentDirectory + ); + assert_eq!( + panel_interactive_hit(&layout, PanelView::Files, false, true, follow_x, follow_y), + PanelHit::Inside, + "the reset action must not exist while following the terminal cwd" + ); + assert_eq!( + panel_interactive_hit(&layout, PanelView::Files, false, false, terminal_x, terminal_y), + PanelHit::Inside, + "the terminal action must not exist without a tree root" + ); + assert_eq!( + panel_interactive_hit(&layout, PanelView::Git, true, true, open_x, open_y), + PanelHit::Inside, + "Files-only actions must not create invisible Git hit targets" + ); + + for (index, (_, a)) in actions.iter().enumerate() { + for (_, b) in actions.iter().skip(index + 1) { + let overlaps = + a.0 < b.0 + b.2 && a.0 + a.2 > b.0 && a.1 < b.1 + b.3 && a.1 + a.3 > b.1; + assert!(!overlaps, "summary action hit targets must not overlap"); + } + } + } + + #[test] + fn self_drawn_fields_replace_select_all_on_paste() { + let mut panel = SidePanel::new(); + panel.search_input("old"); + panel.search_select_all(); + assert_eq!(panel.search_selected_text().as_deref(), Some("old")); + panel.search_input("new\nvalue"); + assert_eq!(panel.search, "newvalue"); + + panel.commit_input("old commit"); + panel.commit_select_all(); + assert_eq!(panel.commit_selected_text().as_deref(), Some("old commit")); + panel.commit_input("new commit"); + assert_eq!(panel.commit_msg, "new commit"); + } + + #[test] + fn git_action_strip_has_four_equal_buttons() { + let rects = git_button_rects(10.0, 430.0, 10.0); + assert_eq!(rects.len(), 4); + assert!(rects.windows(2).all(|pair| (pair[0].1 - pair[1].1).abs() < f32::EPSILON)); + let last = rects.last().expect("at least one git action"); + assert!((last.0 + last.1 - 440.0).abs() < f32::EPSILON); + } + + #[test] + fn git_pull_is_fast_forward_only() { + assert_eq!(git_pull_args(), vec!["pull", "--ff-only"]); + } +} diff --git a/nebula_app/src/display/ssh_connect.rs b/nebula_app/src/display/ssh_connect.rs index 5127260..4ef3fb2 100644 --- a/nebula_app/src/display/ssh_connect.rs +++ b/nebula_app/src/display/ssh_connect.rs @@ -1,1206 +1,1217 @@ -//! SSH 连接卡片:星云轨道 + 粒子流。 -//! -//! 画在 pane 内的浮层,**不整页接管**——整页会盖掉 ssh 自己的输出,而 -//! host key 警告、banner、`Permission denied` 恰恰是失败时最值钱的信息。 -//! -//! 动画承载信息:已完成段的填充宽度就是进度,粒子只在当前段流动。这是它 -//! 与"转圈"的根本区别——一眼能看出卡在解析、连接、认证还是开 shell。 -//! -//! 有 [`REVEAL_DELAY`] 的延迟门槛:连接比它更快时卡片从不出现。局域网 SSH -//! 常在 200ms 内完成,闪一下的加载页比直接出 prompt 更难受;连接池命中时 -//! [`crate::ssh_session::authenticated_session`] 更是瞬时返回。 - -use std::time::{Duration, Instant}; - -use unicode_width::UnicodeWidthChar; - -use super::color::Rgb; -use super::i18n::UiLanguage; -use super::ui::icons; -use super::ui::tokens::{radius, space, type_scale}; -use super::{NebulaTheme, SizeInfo}; -use crate::renderer::ui::{Gradient, Rgba, UiQuad}; -use crate::renderer::{GlyphCache, Renderer}; -use crate::ssh_session::SshStage; -use nebula_terminal::term::cell::Flags; - -/// 低于这个耗时的连接完全不显示卡片。 -const REVEAL_DELAY: Duration = Duration::from_millis(350); - -/// 粒子绕完当前段一轮的周期。 -const PARTICLE_PERIOD: f32 = 1.100; -/// 主粒子数量。 -const PARTICLE_COUNT: usize = 5; -/// 每颗粒子回溯的拖尾节数。 -const TRAIL_LEN: usize = 5; -/// 拖尾相邻两点的相位差。 -const TRAIL_GAP: f32 = 0.032; -/// 主粒子的相位。**刻意不等距**:等距排列读起来像传送带,不等距才像流体 -/// 的团簇。 -const PARTICLE_PHASE: [f32; PARTICLE_COUNT] = [0.0, 0.17, 0.31, 0.52, 0.63]; - -/// 卡片宽度(逻辑像素)。 -const CARD_W: f32 = 600.0; -/// 轨道节点直径。 -const NODE_D: f32 = 10.0; -/// 按钮高度。 -const BTN_H: f32 = 30.0; -/// 日志展开区显示的行数。 -const LOG_ROWS: usize = 6; - -/// 卡片上可点的部位。绘制与命中共用同一份 [`Layout`],所以图标位置和它的 -/// 命中区不可能漂移——这是 `icons.rs` 那条分层约定的延续。 -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub(crate) enum SshConnectHit { - #[default] - None, - /// 右上角的 Logs 折叠开关。 - Logs, - /// 连接中:放弃这次连接。 - Cancel, - /// 失败后:关掉这个 pane。 - Close, -} - -impl SshConnectHit { - pub(crate) fn is_none(self) -> bool { - matches!(self, SshConnectHit::None) - } -} - -/// 一个 pane 的连接进度。 -#[derive(Debug, Clone)] -pub(crate) struct SshConnectState { - /// 用户看到的目标,原样取自 tab 的启动身份。 - destination: String, - stage: SshStage, - started: Instant, - /// 粒子相位(0..1),按帧 delta 推进——与侧栏 spinner 同款做法,不读挂钟。 - phase: f32, - /// 填充条的当前进度(0..3 的连续值)。向 `stage_index` 插值,避免阶段 - /// 推进时轨道瞬移。 - fill: f32, - /// 失败原因;`Some` 时卡片停止动画并转为可读的错误态。 - failure: Option, - /// 当前悬停的部位。 - hover: SshConnectHit, - /// Logs 区是否展开。 - logs_open: bool, - /// 连接日志。**不伪造 `ssh -v` 的输出**——我们用 russh 自己实现客户端, - /// 每一行都对应一个真实发生过的调用,写成 OpenSSH 的样子只会在排障时 - /// 骗人。 - log: Vec, -} - -impl SshConnectState { - pub(crate) fn new(destination: String) -> Self { - let mut state = Self { - destination: destination.clone(), - stage: SshStage::Resolve, - started: Instant::now(), - phase: 0.0, - fill: 0.0, - failure: None, - hover: SshConnectHit::None, - logs_open: false, - log: Vec::new(), - }; - state.push_log(format!("resolve destination = {destination}")); - state - } - - fn push_log(&mut self, line: String) { - let at = self.started.elapsed().as_secs_f32(); - self.log.push(format!("{at:>6.2}s {line}")); - } - - /// 阶段推进。`Ready` 由调用方处理成"移除状态",不会存进来。 - pub(crate) fn set_stage(&mut self, stage: SshStage) { - // 同一阶段重复上报时不再记一行,否则日志会被刷屏。 - if self.stage != stage { - match &stage { - SshStage::Resolve => {}, - SshStage::Connect => self.push_log("connect tcp handshake + kex".to_owned()), - SshStage::Authenticate => self.push_log("auth authenticating".to_owned()), - SshStage::OpenShell => { - self.push_log("shell channel + pty + shell".to_owned()) - }, - SshStage::Ready => self.push_log("ready session established".to_owned()), - SshStage::Failed(message) => self.push_log(format!("error {message}")), - } - } - if let SshStage::Failed(message) = &stage { - self.failure = Some(message.clone()); - // 失败的原因是最该被看见的东西,直接把 Logs 展开,不让用户再点 - // 一次才发现真正的线索。 - self.logs_open = true; - } - self.stage = stage; - } - - /// 更新悬停部位;返回是否需要重绘。 - pub(crate) fn set_hover(&mut self, hit: SshConnectHit) -> bool { - let changed = self.hover != hit; - self.hover = hit; - changed - } - - pub(crate) fn toggle_logs(&mut self) { - self.logs_open = !self.logs_open; - } - - pub(crate) fn destination(&self) -> &str { - &self.destination - } - - pub(crate) fn failed(&self) -> bool { - self.failure.is_some() - } - - /// 卡片是否该出现。失败态无视门槛立刻显示——用户已经在等结果了。 - pub(crate) fn visible(&self) -> bool { - self.failed() || self.started.elapsed() >= REVEAL_DELAY - } - - /// 当前阶段在四格轨道上的下标。 - fn stage_index(&self) -> usize { - match self.stage { - SshStage::Resolve => 0, - SshStage::Connect => 1, - SshStage::Authenticate => 2, - SshStage::OpenShell | SshStage::Ready => 3, - // 失败停在它失败的那一格;解析阶段就失败的情况归到第 0 格。 - SshStage::Failed(_) => self.fill.round().clamp(0.0, 3.0) as usize, - } - } - - /// 推进动画。`delta` 来自共享的 motion frame,所以慢帧不会让粒子跳步。 - pub(crate) fn step(&mut self, delta: Duration) { - if self.failed() { - return; - } - let dt = delta.as_secs_f32(); - self.phase = (self.phase + dt / PARTICLE_PERIOD).fract(); - // 填充向目标阶段插值:指数趋近,320ms 量级的观感与原型一致。 - let target = self.stage_index() as f32; - let k = 1.0 - (-dt * 8.0).exp(); - self.fill += (target - self.fill) * k; - } - - fn elapsed_text(&self) -> String { - format!("{:.1}s", self.started.elapsed().as_secs_f32()) - } -} - -/// 卡片的像素布局。所有矩形都在窗口像素坐标系里,与文字共用同一套坐标 —— -/// resize HUD 那次事故(框按窗口居中、文字按终端网格居中)的教训。 -struct Layout { - card: (f32, f32, f32, f32), - /// 主机图标方块。 - icon: (f32, f32, f32, f32), - /// 身份文字的左边缘与两行基线。 - name_x: f32, - name_y: f32, - meta_y: f32, - /// 右上角 Logs 折叠开关。 - logs_btn: (f32, f32, f32, f32), - /// 轨道:左端节点中心、右端节点中心、纵向中心。 - rail_x0: f32, - rail_x1: f32, - rail_cy: f32, - /// 阶段标签基线。 - labels_y: f32, - /// 状态行基线。 - status_y: f32, - /// 失败详情基线(失败态才用)。 - detail_y: f32, - /// 日志区外框(展开时才有)。 - logs_area: Option<(f32, f32, f32, f32)>, - /// 底部按钮:矩形、语义、是否主按钮。绘制与命中同源。 - buttons: Vec<((f32, f32, f32, f32), SshConnectHit, bool)>, - pad: f32, -} - -/// 底部按钮的文案与语义,随状态切换:连接中只能放弃,失败后只剩收尾。 -/// -/// 这里**没有**「重试」——它要在同一个 tab 位上原子地重连,而 `Close` + -/// `NewSsh` 两个事件按序执行会先把最后一个 tab 连窗口一起关掉。宁可少一个 -/// 按钮,也不放一个按下去会出事的。 -fn button_specs(state: &SshConnectState, language: UiLanguage) -> Vec<(String, SshConnectHit, bool)> { - if state.failed() { - vec![(language.pick("关闭", "Close").to_owned(), SshConnectHit::Close, true)] - } else { - vec![(language.pick("取消", "Cancel").to_owned(), SshConnectHit::Cancel, false)] - } -} - -fn layout( - state: &SshConnectState, - size: &SizeInfo, - view: (f32, f32, f32, f32), - scale: f32, - language: UiLanguage, -) -> Layout { - let s = |v: f32| v * scale; - let pad = s(space::L); - let ch = size.cell_height(); - let cell_w = size.cell_width(); - let card_w = s(CARD_W).min(view.2 - s(space::L) * 2.0); - - // 内容从上往下堆,卡片高度由内容决定(内容自适应,不留空洞)。 - let icon_h = s(40.0); - let mut h = pad; - let icon_top = h; - h += icon_h + s(space::L); - let rail_top = h; - h += s(NODE_D) + s(space::XS) + ch * type_scale::SECTION_CAPTION + s(space::L); - let status_top = h; - h += ch; - - let detail_top = if state.failed() { - let top = h + s(space::XS); - h = top + ch * type_scale::SUPPORTING * 2.0; - top - } else { - 0.0 - }; - - let logs_h = if state.logs_open { - // 高度跟着真实行数走,最多 LOG_ROWS 行。固定高度会在只有三行日志时 - // 留下一大块空框,读起来像"还有内容没加载出来"。 - let rows = state.log.len().clamp(1, LOG_ROWS); - let inner = ch * type_scale::SUPPORTING * rows as f32 + s(space::S) * 2.0; - h += s(space::M) + inner; - Some(inner) - } else { - None - }; - - h += s(space::L); - let btn_top = h; - h += s(BTN_H) + pad; - - let card_x = (view.0 + (view.2 - card_w) * 0.5).round(); - let card_y = (view.1 + (view.3 - h) * 0.5).round(); - - let icon_x = card_x + pad; - let text_x = icon_x + icon_h + s(space::M); - // 两行身份文字在图标高度内垂直居中。 - let name_line = ch * type_scale::BODY; - let meta_line = ch * type_scale::SUPPORTING; - let text_block = name_line + s(2.0) + meta_line; - let text_top = card_y + icon_top + (icon_h - text_block) * 0.5; - - // Logs 按钮贴卡片右内缘,与图标同一条水平中线。 - let logs_label = language.pick("Logs", "Logs"); - let logs_w = text_w(logs_label, cell_w, type_scale::SUPPORTING) + s(space::M) * 2.0 + s(10.0); - let logs_btn = ( - (card_x + card_w - pad - logs_w).round(), - (card_y + icon_top + (icon_h - s(BTN_H)) * 0.5).round(), - logs_w.round(), - s(BTN_H), - ); - - // 底部按钮从右往左排,主按钮在左——与原型一致(提交 / 取消)。 - let mut buttons = Vec::new(); - let mut right = card_x + card_w - pad; - for (label, hit, primary) in button_specs(state, language).into_iter().rev() { - let w = (text_w(&label, cell_w, type_scale::BODY) + s(space::M) * 2.0).max(s(76.0)); - right -= w; - buttons.push(( - ((right).round(), (card_y + btn_top).round(), w.round(), s(BTN_H)), - hit, - primary, - )); - right -= s(space::S); - } - - Layout { - card: (card_x, card_y, card_w, h), - icon: (icon_x, card_y + icon_top, icon_h, icon_h), - name_x: text_x, - name_y: text_top.round(), - meta_y: (text_top + name_line + s(2.0)).round(), - logs_btn, - rail_x0: card_x + pad + s(NODE_D) * 0.5, - rail_x1: card_x + card_w - pad - s(NODE_D) * 0.5, - rail_cy: (card_y + rail_top + s(NODE_D) * 0.5).round(), - labels_y: (card_y + rail_top + s(NODE_D) + s(space::XS)).round(), - status_y: (card_y + status_top).round(), - detail_y: (card_y + detail_top).round(), - logs_area: logs_h.map(|inner| { - let top = card_y + btn_top - s(space::L) - inner; - ((card_x + pad).round(), top.round(), (card_w - pad * 2.0).round(), inner) - }), - buttons, - pad, - } -} - -fn contains(rect: (f32, f32, f32, f32), x: f32, y: f32) -> bool { - x >= rect.0 && x < rect.0 + rect.2 && y >= rect.1 && y < rect.1 + rect.3 -} - -/// 命中测试。卡片是模态浮层:**落在卡片内但不在任何控件上,也要吞掉事件**, -/// 否则点击会漏进下面的终端去拖选区——侧栏拖拽残影那个 bug 就是这么来的。 -pub(super) fn hit_test( - state: &SshConnectState, - size: &SizeInfo, - view: (f32, f32, f32, f32), - scale: f32, - language: UiLanguage, - x: f32, - y: f32, -) -> SshConnectHit { - let l = layout(state, size, view, scale, language); - if contains(l.logs_btn, x, y) { - return SshConnectHit::Logs; - } - for (rect, hit, _) in &l.buttons { - if contains(*rect, x, y) { - return *hit; - } - } - SshConnectHit::None -} - -/// 遮罩覆盖整个 pane,所以连接期间任何落在 pane 内的点击都不该穿透到终端。 -pub(super) fn covers(view: (f32, f32, f32, f32), x: f32, y: f32) -> bool { - contains(view, x, y) -} - -/// 节点中心的 x 坐标。 -fn node_x(l: &Layout, index: usize) -> f32 { - l.rail_x0 + (l.rail_x1 - l.rail_x0) * index as f32 / 3.0 -} - -/// 速度场:两端慢、中段快,与线性混合以保留端点的基础流速。 -/// -/// 拖尾位置也过这条曲线,于是快段自动拉长、慢段自动压缩——流体感就来自 -/// 这里,不是靠额外的装饰。 -fn ease(s: f32) -> f32 { - 0.35 * s + 0.65 * (s * s * (3.0 - 2.0 * s)) -} - -fn lerp_rgb(a: Rgb, b: Rgb, k: f32) -> Rgb { - let k = k.clamp(0.0, 1.0); - let mix = |x: u8, y: u8| (x as f32 + (y as f32 - x as f32) * k).round() as u8; - Rgb::new(mix(a.r, b.r), mix(a.g, b.g), mix(a.b, b.b)) -} - -#[allow(clippy::too_many_arguments)] -pub(super) fn push_quads( - state: &SshConnectState, - theme: &NebulaTheme, - quads: &mut Vec, - size: &SizeInfo, - view: (f32, f32, f32, f32), - scale: f32, - language: UiLanguage, - backdrop: Rgba, -) { - let l = layout(state, size, view, scale, language); - let s = |v: f32| v * scale; - let sk = theme.skin(); - let palette = theme.palette(); - let light = sk.is_light; - // 品牌色对:Nebula 主题自带的星云紫→青。浅色主题下压暗才够对比。 - let brand_l = if light { lerp_rgb(rgb_of(palette.edge_l), Rgb::new(0, 0, 0), 0.28) } else { rgb_of(palette.edge_l) }; - let brand_r = if light { lerp_rgb(rgb_of(palette.edge_r), Rgb::new(0, 0, 0), 0.28) } else { rgb_of(palette.edge_r) }; - - // ── 遮罩 ──────────────────────────────────────────────────── - // 连接期间 pane 里没有任何值得看的东西(grid 是空的,只有一个在 blink - // 的光标),却会跟着我们的持续重绘一起闪。用 pane 底色整块盖住:卡片 - // 由此成为真正的第一层,会话就绪后遮罩和卡片一起退场,露出真实终端。 - // ssh 自己的输出不会因此丢失——它们进了 Logs 区。 - quads.push(UiQuad::solid(view.0, view.1, view.2, view.3, 0.0, backdrop).pixel_snapped()); - - let (cx, cy, cw, chh) = l.card; - - // 阴影 + 面板:一个要求用户等待的浮层必须有真实高度。 - let shadow_alpha = if light { 26 } else { 56 }; - quads.push( - UiQuad::shadow( - cx, - cy, - cw, - chh, - s(radius::OVERLAY), - s(20.0), - s(7.0), - Rgba::new(0, 0, 0, shadow_alpha), - ) - .pixel_snapped(), - ); - quads.push(UiQuad::solid(cx, cy, cw, chh, s(radius::OVERLAY), sk.panel).pixel_snapped()); - // hairline 描边:与阴影二选一会显薄,浮层这一层两者都要(阴影给高度, - // 描边给边界),但描边只有 1px 且极淡。 - quads.push( - UiQuad::solid(cx, cy, cw, chh, s(radius::OVERLAY), sk.hairline).pixel_snapped(), - ); - quads.push( - UiQuad::solid( - cx + s(1.0), - cy + s(1.0), - cw - s(2.0), - chh - s(2.0), - s(radius::OVERLAY) - s(1.0), - sk.panel, - ) - .pixel_snapped(), - ); - - // 主机图标底:card 底 + hairline,不用彩色块。Termius 那种高饱和彩色 - // 方块排一屏时眼睛不知道看哪,而且颜色不承载任何可操作信息。 - let (ix, iy, iw, ih) = l.icon; - // 卡片底和图标底都带 alpha,直接拿它们当"挖空色"盖不住下面的墨迹—— - // 这正是 icons.rs 里 blend_over 存在的理由。先算出真实的不透明底色。 - let solid_panel = icons::blend_over(backdrop, sk.panel); - let solid_card = icons::blend_over(solid_panel, sk.card); - quads.push(UiQuad::solid(ix, iy, iw, ih, s(radius::CONTROL), solid_card).pixel_snapped()); - // 服务器机架墨迹:两层机箱 + 各一颗指示灯。字体私用区字形在分数 DPI 下 - // 会漂,所以和 icons.rs 一样走矢量,不借字形。 - { - let icx = ix + iw * 0.5; - let icy = iy + ih * 0.5; - let ink = Rgba::opaque(sk.ink_dim); - let bw = s(18.0); - let bh = s(7.5); - let gap = s(3.0); - let stroke = (s(1.4)).max(1.0); - let r = s(2.0); - for row in [-1.0f32, 1.0] { - let by = icy + row * (bh + gap) * 0.5 - bh * 0.5; - let bx = icx - bw * 0.5; - // 无 stroke 图元:ink 板 + 挖回底色的内芯。 - quads.push(UiQuad::solid(bx, by, bw, bh, r, ink).pixel_snapped()); - quads.push( - UiQuad::solid( - bx + stroke, - by + stroke, - bw - stroke * 2.0, - bh - stroke * 2.0, - (r - stroke).max(0.0), - solid_card, - ) - .pixel_snapped(), - ); - // 指示灯:靠左,与机箱同一条中线。 - let d = s(2.4); - quads.push(UiQuad::solid( - bx + stroke + s(2.2), - by + bh * 0.5 - d * 0.5, - d, - d, - d * 0.5, - ink, - )); - } - } - - // ── 星云轨道 ──────────────────────────────────────────────── - let rail_h = s(2.0); - let rail_y = (l.rail_cy - rail_h * 0.5).round(); - // 底轨:未完成部分。 - quads.push( - UiQuad::solid(l.rail_x0, rail_y, l.rail_x1 - l.rail_x0, rail_h, rail_h * 0.5, sk.hairline) - .pixel_snapped(), - ); - - let fill_to = node_x(&l, 0) + (l.rail_x1 - l.rail_x0) * (state.fill / 3.0); - let fill_w = (fill_to - l.rail_x0).max(0.0); - if fill_w > 0.5 { - // 已完成段:品牌紫→青,宽度即进度。这是全屏唯一一处渐变。 - let end = lerp_rgb(brand_l, brand_r, state.fill / 3.0); - if state.failed() { - quads.push( - UiQuad::solid(l.rail_x0, rail_y, fill_w, rail_h, rail_h * 0.5, sk.danger) - .pixel_snapped(), - ); - } else { - // 深色下给填充段极淡外溢;浅色主题 glow 一律关闭(theme.rs 把 - // 浅色 glow alpha 硬编码为 0:低 alpha 径向渐变在浅底上只落在 - // 极少数 8-bit 台阶,量化轮廓会读成模糊灰线)。 - if !light { - // brand-glow: 进度轨道的能量外溢是星云品牌位本身的画面。 - quads.push(UiQuad::glow( - l.rail_x0, - rail_y - s(3.0), - fill_w, - rail_h + s(6.0), - Rgba::opaque(end).with_alpha(0.20), - )); - } - quads.push( - UiQuad::gradient( - l.rail_x0, - rail_y, - fill_w, - rail_h, - rail_h * 0.5, - Rgba::opaque(brand_l), - Rgba::opaque(end), - Gradient::Horizontal, - ) - .pixel_snapped(), - ); - } - } - - // 粒子:只在当前段流动。失败后不画(动画停止)。 - if !state.failed() { - let seg = state.stage_index().min(2); - let from = node_x(&l, seg); - let to = node_x(&l, seg + 1); - let span = l.rail_x1 - l.rail_x0; - for i in 0..PARTICLE_COUNT { - let head = (state.phase + PARTICLE_PHASE[i]).fract(); - // 从尾画到头,头压在最上。 - for k in (0..=TRAIL_LEN).rev() { - let t = head - k as f32 * TRAIL_GAP; - if t < 0.0 || t > 1.0 { - continue; - } - let x = from + (to - from) * ease(t); - let fade = 1.0 - k as f32 / (TRAIL_LEN as f32 + 1.2); - // 进出两端淡入淡出,避免粒子在节点上凭空出现或撞停。 - let edge = (t / 0.14).min((1.0 - t) / 0.14).min(1.0); - let alpha = fade * fade * edge * if light { 0.55 } else { 0.9 }; - if alpha < 0.012 { - continue; - } - // 颜色按整条轨道的位置从品牌紫渐变到品牌青。 - let color = lerp_rgb(brand_l, brand_r, (x - l.rail_x0) / span.max(1.0)); - if !light { - let rad = if k == 0 { s(5.4) } else { s(4.4) * fade + s(1.2) }; - // brand-glow: 拖尾粒子就是发光体,不是在垫层级。 - quads.push(UiQuad::glow( - x - rad, - l.rail_cy - rad, - rad * 2.0, - rad * 2.0, - Rgba::opaque(color).with_alpha(alpha * 0.5), - )); - } - let cr = if k == 0 { s(1.55) } else { s(1.3) * fade + s(0.2) }; - quads.push(UiQuad::solid( - x - cr, - l.rail_cy - cr, - cr * 2.0, - cr * 2.0, - cr, - Rgba::opaque(color).with_alpha(alpha * if k == 0 { 1.0 } else { 0.78 }), - )); - } - } - } - - // 节点:三态。空心(未到)→ accent 实心带光环(正在做)→ 品牌青实心。 - let done_upto = state.fill.floor() as usize; - let active = state.stage_index(); - for i in 0..4 { - let d = s(NODE_D); - let x = node_x(&l, i) - d * 0.5; - let y = l.rail_cy - d * 0.5; - let failed_here = state.failed() && i == active; - if failed_here { - quads.push(UiQuad::solid(x, y, d, d, d * 0.5, sk.danger).pixel_snapped()); - } else if i <= done_upto && i < active { - quads.push(UiQuad::solid(x, y, d, d, d * 0.5, Rgba::opaque(brand_r)).pixel_snapped()); - } else if i == active { - if !light { - // brand-glow: 当前阶段节点的脉冲,和轨道粒子是同一套语言。 - quads.push(UiQuad::glow( - x - s(3.0), - y - s(3.0), - d + s(6.0), - d + s(6.0), - Rgba::opaque(sk.accent).with_alpha(0.45), - )); - } - quads.push(UiQuad::solid(x, y, d, d, d * 0.5, Rgba::opaque(sk.accent)).pixel_snapped()); - } else { - // 空心:外圈 hairline + 内芯挖回面板色。 - quads.push(UiQuad::solid(x, y, d, d, d * 0.5, sk.hairline).pixel_snapped()); - let inset = s(1.5); - quads.push( - UiQuad::solid( - x + inset, - y + inset, - d - inset * 2.0, - d - inset * 2.0, - (d - inset * 2.0) * 0.5, - sk.panel, - ) - .pixel_snapped(), - ); - } - } - - // ── Logs 折叠开关 ─────────────────────────────────────────── - // Tabby 那张连接图唯一真正值钱的东西就是它,所以它必须在失败之前就在 - // 场,而不是失败后才冒出来。 - push_button_frame(quads, l.logs_btn, s(radius::CONTROL), false, state.hover == SshConnectHit::Logs, sk); - { - let (bx, by, bw, bh) = l.logs_btn; - let cxx = bx + bw - s(space::M) - s(3.0); - let cyy = by + bh * 0.5; - let arm = s(3.2); - let stroke = (s(1.3)).max(1.0); - let ink = Rgba::opaque(sk.ink_dim); - // 展开时朝上,收起时朝下——方向指向"点下去会发生什么"。 - let dir = if state.logs_open { -1.0 } else { 1.0 }; - icons::push_segment( - quads, - (cxx - arm, cyy - arm * 0.5 * dir), - (cxx, cyy + arm * 0.5 * dir), - stroke, - ink, - ); - icons::push_segment( - quads, - (cxx, cyy + arm * 0.5 * dir), - (cxx + arm, cyy - arm * 0.5 * dir), - stroke, - ink, - ); - } - - // ── 日志区 ───────────────────────────────────────────────── - if let Some(area) = l.logs_area { - quads.push( - UiQuad::solid(area.0, area.1, area.2, area.3, s(radius::CONTROL), sk.hairline) - .pixel_snapped(), - ); - let inset = s(1.0); - quads.push( - UiQuad::solid( - area.0 + inset, - area.1 + inset, - area.2 - inset * 2.0, - area.3 - inset * 2.0, - s(radius::CONTROL) - inset, - sk.card, - ) - .pixel_snapped(), - ); - } - - // ── 底部按钮 ─────────────────────────────────────────────── - for (rect, hit, primary) in &l.buttons { - push_button_frame(quads, *rect, s(radius::CONTROL), *primary, state.hover == *hit, sk); - } -} - -/// 按钮底:主按钮实心 accent,次按钮 card 底 + hairline 描边。hover 只改 -/// 亮度,不改尺寸——动效纪律里"不加装饰动效"的直接后果。 -fn push_button_frame( - quads: &mut Vec, - rect: (f32, f32, f32, f32), - radius: f32, - primary: bool, - hovered: bool, - sk: crate::display::ui::theme::Skin, -) { - let (x, y, w, h) = rect; - let lift = |c: Rgba| { - if !hovered { - return c; - } - // 深色底往上提亮,浅色底往下压暗,两边都是"更靠近手指"的方向。 - let top = if sk.is_light { - Rgba::new(0, 0, 0, 20) - } else { - Rgba::new(255, 255, 255, 26) - }; - icons::blend_over(c, top) - }; - if primary { - quads.push(UiQuad::solid(x, y, w, h, radius, lift(Rgba::opaque(sk.accent))).pixel_snapped()); - } else { - quads.push(UiQuad::solid(x, y, w, h, radius, sk.hairline).pixel_snapped()); - let inset = (radius * 0.0 + 1.0).max(1.0); - quads.push( - UiQuad::solid( - x + inset, - y + inset, - w - inset * 2.0, - h - inset * 2.0, - (radius - inset).max(0.0), - lift(sk.card), - ) - .pixel_snapped(), - ); - } -} - -pub(super) fn rgb_of(c: Rgba) -> Rgb { - Rgb::new(c.r, c.g, c.b) -} - -/// 文字宽度(像素)。等宽字体下按列宽求和是精确的。 -fn text_w(text: &str, cell_w: f32, scale: f32) -> f32 { - let cols: usize = text.chars().map(|c| c.width().unwrap_or(0)).sum(); - cols as f32 * cell_w * scale -} - -/// 估算宽度与实际渲染宽度之间的安全系数。 -/// -/// UI 文字走 UI 字体,中文字形的实际推进宽度略大于"列数 × cell_w"的估算, -/// 按估算贴边排版会溢出卡片——失败详情第一次就是这么冲出右边界的。 -const TEXT_SAFETY: f32 = 0.94; - -/// 一块宽度能放下多少列。 -pub(super) fn cols_that_fit(width: f32, cell_w: f32, scale: f32) -> usize { - ((width * TEXT_SAFETY / (cell_w * scale)).floor() as usize).max(4) -} - -/// 按列宽截断,超出部分收成省略号。日志行里那条中文系统错误消息不截断就 -/// 会一路画到窗口外面去。 -pub(super) fn truncate_cols(text: &str, max_cols: usize) -> String { - let total: usize = text.chars().map(|c| c.width().unwrap_or(0)).sum(); - if total <= max_cols { - return text.to_owned(); - } - let budget = max_cols.saturating_sub(1); - let mut out = String::new(); - let mut cols = 0usize; - for c in text.chars() { - let w = c.width().unwrap_or(0); - if cols + w > budget { - break; - } - out.push(c); - cols += w; - } - out.push('…'); - out -} - -#[allow(clippy::too_many_arguments)] -pub(super) fn draw_text( - state: &SshConnectState, - theme: &NebulaTheme, - language: UiLanguage, - r: &mut Renderer, - gc: &mut GlyphCache, - size: &SizeInfo, - view: (f32, f32, f32, f32), - scale: f32, -) { - let l = layout(state, size, view, scale, language); - let sk = theme.skin(); - let cell_w = size.cell_width(); - - // 身份:标签用主墨,技术地址降一级——列表和卡片同一套读法。 - let name = short_name(&state.destination); - r.draw_ui_text( - size, - l.name_x, - l.name_y, - type_scale::BODY, - sk.ink_strong, - Flags::empty(), - &name, - gc, - ); - let meta = format!("SSH · {}", state.destination); - r.draw_ui_text( - size, - l.name_x, - l.meta_y, - type_scale::SUPPORTING, - sk.ink_dim, - Flags::empty(), - &meta, - gc, - ); - - // 阶段标签:已完成 ink_dim、当前 ink、未到 ink_faint。 - let labels = stage_labels(language); - let active = state.stage_index(); - for (i, label) in labels.iter().enumerate() { - let ink = if state.failed() && i == active { - rgb_of(sk.danger) - } else if i == active { - sk.ink - } else if i < active { - sk.ink_dim - } else { - sk.ink_faint - }; - let w = text_w(label, cell_w, type_scale::SECTION_CAPTION); - // 首尾贴齐轨道两端,中间的以节点为中心——标签是节点的名字,不是 - // 均分的四列。 - let cx = node_x(&l, i); - let x = match i { - 0 => l.rail_x0 - s_half(scale), - 3 => l.rail_x1 + s_half(scale) - w, - _ => cx - w * 0.5, - }; - r.draw_ui_text( - size, - x.round(), - l.labels_y, - type_scale::SECTION_CAPTION, - ink, - Flags::empty(), - label, - gc, - ); - } - - // 状态行:左边当前动作,右边计时(失败后计时停在失败时刻)。 - let (msg, msg_ink) = match &state.failure { - Some(_) => (failure_headline(language), rgb_of(sk.danger)), - None => (stage_message(&state.stage, language), sk.ink), - }; - r.draw_ui_text( - size, - l.card.0 + l.pad, - l.status_y, - type_scale::BODY, - msg_ink, - Flags::empty(), - &msg, - gc, - ); - if !state.failed() { - let elapsed = state.elapsed_text(); - let w = text_w(&elapsed, cell_w, type_scale::BODY); - r.draw_ui_text( - size, - (l.card.0 + l.card.2 - l.pad - w).round(), - l.status_y, - type_scale::BODY, - sk.ink_faint, - Flags::empty(), - &elapsed, - gc, - ); - } - - // 失败详情:指到位置的原因,最多两行,末行溢出收成省略号。 - if let Some(reason) = &state.failure { - let avail = l.card.2 - l.pad * 2.0; - let per_line = cols_that_fit(avail, cell_w, type_scale::SUPPORTING); - let ch = size.cell_height(); - for (i, line) in wrap(reason, per_line).into_iter().take(2).enumerate() { - r.draw_ui_text( - size, - l.card.0 + l.pad, - l.detail_y + i as f32 * ch * type_scale::SUPPORTING * 1.35, - type_scale::SUPPORTING, - sk.ink_dim, - Flags::empty(), - &truncate_cols(&line, per_line), - gc, - ); - } - } - - // Logs 开关的标签(箭头是矢量,在 push_quads 里)。 - r.draw_ui_text( - size, - (l.logs_btn.0 + space::M * scale).round(), - (l.logs_btn.1 + (l.logs_btn.3 - size.cell_height() * type_scale::SUPPORTING) * 0.5).round(), - type_scale::SUPPORTING, - sk.ink_dim, - Flags::empty(), - "Logs", - gc, - ); - - // 日志:只显示末尾若干行,最新的在最下——和终端一样往下长。每行按日志 - // 区宽度截断,否则那条中文系统错误会一路画到窗口外面。 - if let Some(area) = l.logs_area { - let line_h = size.cell_height() * type_scale::SUPPORTING; - let inner_pad = space::S * scale; - let max_cols = cols_that_fit(area.2 - inner_pad * 2.0, cell_w, type_scale::SUPPORTING); - let start = state.log.len().saturating_sub(LOG_ROWS); - for (i, line) in state.log[start..].iter().enumerate() { - let y = area.1 + inner_pad + i as f32 * line_h; - if y + line_h > area.1 + area.3 { - break; - } - let ink = if line.contains("error") { rgb_of(sk.danger) } else { sk.ink_dim }; - r.draw_ui_text( - size, - (area.0 + inner_pad).round(), - y.round(), - type_scale::SUPPORTING, - ink, - Flags::empty(), - &truncate_cols(line, max_cols), - gc, - ); - } - } - - // 底部按钮文字:居中。主按钮的文字色按 accent 亮度选黑或白,不能写死。 - for (rect, hit, primary) in &l.buttons { - let label = button_specs(state, language) - .into_iter() - .find(|(_, h, _)| h == hit) - .map(|(label, _, _)| label) - .unwrap_or_default(); - let w = text_w(&label, cell_w, type_scale::BODY); - let ink = if *primary { on_accent(sk.accent) } else { sk.ink }; - r.draw_ui_text( - size, - (rect.0 + (rect.2 - w) * 0.5).round(), - (rect.1 + (rect.3 - size.cell_height() * type_scale::BODY) * 0.5).round(), - type_scale::BODY, - ink, - Flags::empty(), - &label, - gc, - ); - } -} - -/// accent 上的文字色。accent 在浅色主题里是深靛蓝、在深色主题里是亮蓝, -/// 写死黑或白都会在另一边糊掉。 -fn on_accent(accent: Rgb) -> Rgb { - let lum = 0.299 * accent.r as f32 + 0.587 * accent.g as f32 + 0.114 * accent.b as f32; - if lum > 150.0 { Rgb::new(12, 14, 18) } else { Rgb::new(255, 255, 255) } -} - -fn s_half(scale: f32) -> f32 { - NODE_D * 0.5 * scale -} - -/// 列表和卡片共用的短名:有别名就用别名,否则去掉 user@ 只留主机。 -fn short_name(destination: &str) -> String { - let host = destination.rsplit('@').next().unwrap_or(destination); - host.to_owned() -} - -fn stage_labels(language: UiLanguage) -> [String; 4] { - [ - language.pick("解析", "Resolve").to_owned(), - language.pick("连接", "Connect").to_owned(), - language.pick("认证", "Auth").to_owned(), - language.pick("会话", "Shell").to_owned(), - ] -} - -fn stage_message(stage: &SshStage, language: UiLanguage) -> String { - match stage { - SshStage::Resolve => language.pick("正在解析主机…", "Resolving host…"), - SshStage::Connect => language.pick("正在建立连接…", "Connecting…"), - SshStage::Authenticate => language.pick("正在认证…", "Authenticating…"), - SshStage::OpenShell => language.pick("正在打开会话…", "Opening shell…"), - SshStage::Ready => language.pick("已连接", "Connected"), - SshStage::Failed(_) => language.pick("连接失败", "Connection failed"), - } - .to_owned() -} - -fn failure_headline(language: UiLanguage) -> String { - language.pick("连接失败", "Connection failed").to_owned() -} - -/// 按列宽换行。中日韩宽字符按 2 列计,所以不能按 char 数切。 -fn wrap(text: &str, per_line: usize) -> Vec { - let mut lines = Vec::new(); - let mut line = String::new(); - let mut cols = 0usize; - for c in text.chars() { - if c == '\n' { - lines.push(std::mem::take(&mut line)); - cols = 0; - continue; - } - let w = c.width().unwrap_or(0); - if cols + w > per_line && !line.is_empty() { - lines.push(std::mem::take(&mut line)); - cols = 0; - } - line.push(c); - cols += w; - } - if !line.is_empty() { - lines.push(line); - } - lines -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn card_stays_hidden_until_the_reveal_delay() { - let state = SshConnectState::new("root@example.com".into()); - assert!(!state.visible(), "快连接不该闪一下加载页"); - } - - #[test] - fn failure_shows_immediately_regardless_of_delay() { - let mut state = SshConnectState::new("root@example.com".into()); - state.set_stage(SshStage::Failed("Permission denied".into())); - assert!(state.visible()); - assert!(state.failed()); - } - - #[test] - fn stage_index_maps_every_stage_into_the_four_slot_rail() { - let mut state = SshConnectState::new("h".into()); - for (stage, want) in [ - (SshStage::Resolve, 0), - (SshStage::Connect, 1), - (SshStage::Authenticate, 2), - (SshStage::OpenShell, 3), - (SshStage::Ready, 3), - ] { - state.set_stage(stage); - assert_eq!(state.stage_index(), want); - } - } - - #[test] - fn fill_interpolates_toward_the_stage_without_overshooting() { - let mut state = SshConnectState::new("h".into()); - state.set_stage(SshStage::Authenticate); - for _ in 0..240 { - state.step(Duration::from_millis(16)); - } - assert!((state.fill - 2.0).abs() < 0.05, "fill={}", state.fill); - assert!(state.fill <= 2.0 + f32::EPSILON, "插值不该越过目标"); - } - - #[test] - fn particle_phase_wraps_and_never_leaves_unit_range() { - let mut state = SshConnectState::new("h".into()); - for _ in 0..500 { - state.step(Duration::from_millis(33)); - assert!((0.0..1.0).contains(&state.phase), "phase={}", state.phase); - } - } - - #[test] - fn failed_state_freezes_the_animation() { - let mut state = SshConnectState::new("h".into()); - state.step(Duration::from_millis(16)); - let phase = state.phase; - state.set_stage(SshStage::Failed("boom".into())); - state.step(Duration::from_millis(100)); - assert_eq!(state.phase, phase, "失败后不该继续跑粒子"); - } - - #[test] - fn speed_field_is_monotonic_and_pins_both_ends() { - assert!((ease(0.0)).abs() < 1e-6); - assert!((ease(1.0) - 1.0).abs() < 1e-6); - let mut prev = -1.0; - for i in 0..=100 { - let v = ease(i as f32 / 100.0); - assert!(v > prev, "速度场必须单调递增"); - prev = v; - } - // 中段比端点快:这正是拖尾被拉长的原因。 - let mid = ease(0.55) - ease(0.45); - let head = ease(0.10) - ease(0.0); - assert!(mid > head, "中段速度应高于起步段"); - } - - #[test] - fn wrap_counts_wide_characters_as_two_columns() { - let lines = wrap("认证失败拒绝", 4); - assert_eq!(lines, vec!["认证".to_owned(), "失败".to_owned(), "拒绝".to_owned()]); - } - - #[test] - fn short_name_drops_the_user_prefix() { - assert_eq!(short_name("root@192.168.200.150"), "192.168.200.150"); - assert_eq!(short_name("web-01"), "web-01"); - } - - fn probe_size() -> SizeInfo { - SizeInfo::new_fully_asymmetric(1000.0, 1000.0, 10.0, 20.0, 0.0, 0.0, 64.0, 16.0) - } - - const VIEW: (f32, f32, f32, f32) = (0.0, 0.0, 1000.0, 800.0); - - #[test] - fn failure_opens_the_log_so_the_reason_is_not_one_click_away() { - let mut state = SshConnectState::new("root@h".into()); - assert!(!state.logs_open); - state.set_stage(SshStage::Failed("Permission denied".into())); - assert!(state.logs_open, "失败原因是最该被看见的东西"); - assert!( - state.log.iter().any(|line| line.contains("Permission denied")), - "失败原因必须进日志: {:?}", - state.log - ); - } - - #[test] - fn repeated_stage_reports_do_not_flood_the_log() { - let mut state = SshConnectState::new("root@h".into()); - let base = state.log.len(); - for _ in 0..20 { - state.set_stage(SshStage::Connect); - } - assert_eq!(state.log.len(), base + 1, "同一阶段重复上报只该记一行"); - } - - #[test] - fn buttons_and_logs_toggle_hit_where_they_are_drawn() { - let size = probe_size(); - let lang = UiLanguage::EnUs; - let state = SshConnectState::new("root@h".into()); - let l = layout(&state, &size, VIEW, 1.0, lang); - - let (bx, by, bw, bh) = l.logs_btn; - assert_eq!( - hit_test(&state, &size, VIEW, 1.0, lang, bx + bw * 0.5, by + bh * 0.5), - SshConnectHit::Logs - ); - - let (rect, hit, _) = l.buttons[0]; - assert_eq!(hit, SshConnectHit::Cancel, "连接中只该有取消"); - assert_eq!( - hit_test(&state, &size, VIEW, 1.0, lang, rect.0 + rect.2 * 0.5, rect.1 + rect.3 * 0.5), - SshConnectHit::Cancel - ); - } - - #[test] - fn failed_card_offers_close_instead_of_cancel() { - let size = probe_size(); - let lang = UiLanguage::EnUs; - let mut state = SshConnectState::new("root@h".into()); - state.set_stage(SshStage::Failed("boom".into())); - let l = layout(&state, &size, VIEW, 1.0, lang); - let (rect, hit, primary) = l.buttons[0]; - assert_eq!(hit, SshConnectHit::Close); - assert!(primary, "唯一动作应当是主按钮"); - assert_eq!( - hit_test(&state, &size, VIEW, 1.0, lang, rect.0 + rect.2 * 0.5, rect.1 + rect.3 * 0.5), - SshConnectHit::Close - ); - } - - #[test] - fn the_mask_swallows_clicks_anywhere_in_the_pane() { - // 卡片之外的空白也必须被吞掉,否则按压会漏进终端起拖选。 - assert!(covers(VIEW, 5.0, 5.0)); - assert!(covers(VIEW, 999.0, 799.0)); - assert!(!covers(VIEW, 1001.0, 400.0)); - } - - #[test] - fn opening_the_log_makes_the_card_taller_not_narrower() { - let size = probe_size(); - let lang = UiLanguage::EnUs; - let mut state = SshConnectState::new("root@h".into()); - let before = layout(&state, &size, VIEW, 1.0, lang).card; - state.toggle_logs(); - let after = layout(&state, &size, VIEW, 1.0, lang).card; - assert!(after.3 > before.3, "展开日志应当长高"); - assert_eq!(after.2, before.2, "宽度不该跟着变,否则读起来像在抖"); - assert!(layout(&state, &size, VIEW, 1.0, lang).logs_area.is_some()); - } -} +//! SSH 连接卡片:星云轨道 + 粒子流。 +//! +//! 画在 pane 内的浮层,**不整页接管**——整页会盖掉 ssh 自己的输出,而 +//! host key 警告、banner、`Permission denied` 恰恰是失败时最值钱的信息。 +//! +//! 动画承载信息:已完成段的填充宽度就是进度,粒子只在当前段流动。这是它 +//! 与"转圈"的根本区别——一眼能看出卡在解析、连接、认证还是开 shell。 +//! +//! 有 [`REVEAL_DELAY`] 的延迟门槛:连接比它更快时卡片从不出现。局域网 SSH +//! 常在 200ms 内完成,闪一下的加载页比直接出 prompt 更难受;连接池命中时 +//! [`crate::ssh_session::authenticated_session`] 更是瞬时返回。 + +use std::time::{Duration, Instant}; + +use unicode_width::UnicodeWidthChar; + +use super::color::Rgb; +use super::i18n::UiLanguage; +use super::ui::icons; +use super::ui::tokens::{radius, space, type_scale}; +use super::{NebulaTheme, SizeInfo}; +use crate::renderer::ui::{Gradient, Rgba, UiQuad}; +use crate::renderer::{GlyphCache, Renderer}; +use crate::ssh_session::SshStage; +use nebula_terminal::term::cell::Flags; + +/// 低于这个耗时的连接完全不显示卡片。 +const REVEAL_DELAY: Duration = Duration::from_millis(350); + +/// 粒子绕完当前段一轮的周期。 +const PARTICLE_PERIOD: f32 = 1.100; +/// 主粒子数量。 +const PARTICLE_COUNT: usize = 5; +/// 每颗粒子回溯的拖尾节数。 +const TRAIL_LEN: usize = 5; +/// 拖尾相邻两点的相位差。 +const TRAIL_GAP: f32 = 0.032; +/// 主粒子的相位。**刻意不等距**:等距排列读起来像传送带,不等距才像流体 +/// 的团簇。 +const PARTICLE_PHASE: [f32; PARTICLE_COUNT] = [0.0, 0.17, 0.31, 0.52, 0.63]; + +/// 卡片宽度(逻辑像素)。 +const CARD_W: f32 = 600.0; +/// 轨道节点直径。 +const NODE_D: f32 = 10.0; +/// 按钮高度。 +const BTN_H: f32 = 30.0; +/// 日志展开区显示的行数。 +const LOG_ROWS: usize = 6; + +/// 卡片上可点的部位。绘制与命中共用同一份 [`Layout`],所以图标位置和它的 +/// 命中区不可能漂移——这是 `icons.rs` 那条分层约定的延续。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(crate) enum SshConnectHit { + #[default] + None, + /// 右上角的 Logs 折叠开关。 + Logs, + /// 连接中:放弃这次连接。 + Cancel, + /// 失败后:关掉这个 pane。 + Close, +} + +impl SshConnectHit { + pub(crate) fn is_none(self) -> bool { + matches!(self, SshConnectHit::None) + } +} + +/// 一个 pane 的连接进度。 +#[derive(Debug, Clone)] +pub(crate) struct SshConnectState { + /// 用户看到的目标,原样取自 tab 的启动身份。 + destination: String, + stage: SshStage, + started: Instant, + /// 粒子相位(0..1),按帧 delta 推进——与侧栏 spinner 同款做法,不读挂钟。 + phase: f32, + /// 填充条的当前进度(0..3 的连续值)。向 `stage_index` 插值,避免阶段 + /// 推进时轨道瞬移。 + fill: f32, + /// 失败原因;`Some` 时卡片停止动画并转为可读的错误态。 + failure: Option, + /// 当前悬停的部位。 + hover: SshConnectHit, + /// Logs 区是否展开。 + logs_open: bool, + /// 连接日志。**不伪造 `ssh -v` 的输出**——我们用 russh 自己实现客户端, + /// 每一行都对应一个真实发生过的调用,写成 OpenSSH 的样子只会在排障时 + /// 骗人。 + log: Vec, +} + +impl SshConnectState { + pub(crate) fn new(destination: String) -> Self { + let mut state = Self { + destination: destination.clone(), + stage: SshStage::Resolve, + started: Instant::now(), + phase: 0.0, + fill: 0.0, + failure: None, + hover: SshConnectHit::None, + logs_open: false, + log: Vec::new(), + }; + state.push_log(format!("resolve destination = {destination}")); + state + } + + fn push_log(&mut self, line: String) { + let at = self.started.elapsed().as_secs_f32(); + self.log.push(format!("{at:>6.2}s {line}")); + } + + /// 阶段推进。`Ready` 由调用方处理成"移除状态",不会存进来。 + pub(crate) fn set_stage(&mut self, stage: SshStage) { + // 同一阶段重复上报时不再记一行,否则日志会被刷屏。 + if self.stage != stage { + match &stage { + SshStage::Resolve => {}, + SshStage::Connect => self.push_log("connect tcp handshake + kex".to_owned()), + SshStage::Authenticate => self.push_log("auth authenticating".to_owned()), + SshStage::OpenShell => self.push_log("shell channel + pty + shell".to_owned()), + SshStage::Ready => self.push_log("ready session established".to_owned()), + SshStage::Failed(message) => self.push_log(format!("error {message}")), + } + } + if let SshStage::Failed(message) = &stage { + self.failure = Some(message.clone()); + // 失败的原因是最该被看见的东西,直接把 Logs 展开,不让用户再点 + // 一次才发现真正的线索。 + self.logs_open = true; + } + self.stage = stage; + } + + /// 更新悬停部位;返回是否需要重绘。 + pub(crate) fn set_hover(&mut self, hit: SshConnectHit) -> bool { + let changed = self.hover != hit; + self.hover = hit; + changed + } + + pub(crate) fn toggle_logs(&mut self) { + self.logs_open = !self.logs_open; + } + + pub(crate) fn destination(&self) -> &str { + &self.destination + } + + pub(crate) fn failed(&self) -> bool { + self.failure.is_some() + } + + /// 卡片是否该出现。失败态无视门槛立刻显示——用户已经在等结果了。 + pub(crate) fn visible(&self) -> bool { + self.failed() || self.started.elapsed() >= REVEAL_DELAY + } + + /// 当前阶段在四格轨道上的下标。 + fn stage_index(&self) -> usize { + match self.stage { + SshStage::Resolve => 0, + SshStage::Connect => 1, + SshStage::Authenticate => 2, + SshStage::OpenShell | SshStage::Ready => 3, + // 失败停在它失败的那一格;解析阶段就失败的情况归到第 0 格。 + SshStage::Failed(_) => self.fill.round().clamp(0.0, 3.0) as usize, + } + } + + /// 推进动画。`delta` 来自共享的 motion frame,所以慢帧不会让粒子跳步。 + pub(crate) fn step(&mut self, delta: Duration) { + if self.failed() { + return; + } + let dt = delta.as_secs_f32(); + self.phase = (self.phase + dt / PARTICLE_PERIOD).fract(); + // 填充向目标阶段插值:指数趋近,320ms 量级的观感与原型一致。 + let target = self.stage_index() as f32; + let k = 1.0 - (-dt * 8.0).exp(); + self.fill += (target - self.fill) * k; + } + + fn elapsed_text(&self) -> String { + format!("{:.1}s", self.started.elapsed().as_secs_f32()) + } +} + +/// 卡片的像素布局。所有矩形都在窗口像素坐标系里,与文字共用同一套坐标 —— +/// resize HUD 那次事故(框按窗口居中、文字按终端网格居中)的教训。 +struct Layout { + card: (f32, f32, f32, f32), + /// 主机图标方块。 + icon: (f32, f32, f32, f32), + /// 身份文字的左边缘与两行基线。 + name_x: f32, + name_y: f32, + meta_y: f32, + /// 右上角 Logs 折叠开关。 + logs_btn: (f32, f32, f32, f32), + /// 轨道:左端节点中心、右端节点中心、纵向中心。 + rail_x0: f32, + rail_x1: f32, + rail_cy: f32, + /// 阶段标签基线。 + labels_y: f32, + /// 状态行基线。 + status_y: f32, + /// 失败详情基线(失败态才用)。 + detail_y: f32, + /// 日志区外框(展开时才有)。 + logs_area: Option<(f32, f32, f32, f32)>, + /// 底部按钮:矩形、语义、是否主按钮。绘制与命中同源。 + buttons: Vec<((f32, f32, f32, f32), SshConnectHit, bool)>, + pad: f32, +} + +/// 底部按钮的文案与语义,随状态切换:连接中只能放弃,失败后只剩收尾。 +/// +/// 这里**没有**「重试」——它要在同一个 tab 位上原子地重连,而 `Close` + +/// `NewSsh` 两个事件按序执行会先把最后一个 tab 连窗口一起关掉。宁可少一个 +/// 按钮,也不放一个按下去会出事的。 +fn button_specs( + state: &SshConnectState, + language: UiLanguage, +) -> Vec<(String, SshConnectHit, bool)> { + if state.failed() { + vec![(language.pick("关闭", "Close").to_owned(), SshConnectHit::Close, true)] + } else { + vec![(language.pick("取消", "Cancel").to_owned(), SshConnectHit::Cancel, false)] + } +} + +fn layout( + state: &SshConnectState, + size: &SizeInfo, + view: (f32, f32, f32, f32), + scale: f32, + language: UiLanguage, +) -> Layout { + let s = |v: f32| v * scale; + let pad = s(space::L); + let ch = size.cell_height(); + let cell_w = size.cell_width(); + let card_w = s(CARD_W).min(view.2 - s(space::L) * 2.0); + + // 内容从上往下堆,卡片高度由内容决定(内容自适应,不留空洞)。 + let icon_h = s(40.0); + let mut h = pad; + let icon_top = h; + h += icon_h + s(space::L); + let rail_top = h; + h += s(NODE_D) + s(space::XS) + ch * type_scale::SECTION_CAPTION + s(space::L); + let status_top = h; + h += ch; + + let detail_top = if state.failed() { + let top = h + s(space::XS); + h = top + ch * type_scale::SUPPORTING * 2.0; + top + } else { + 0.0 + }; + + let logs_h = if state.logs_open { + // 高度跟着真实行数走,最多 LOG_ROWS 行。固定高度会在只有三行日志时 + // 留下一大块空框,读起来像"还有内容没加载出来"。 + let rows = state.log.len().clamp(1, LOG_ROWS); + let inner = ch * type_scale::SUPPORTING * rows as f32 + s(space::S) * 2.0; + h += s(space::M) + inner; + Some(inner) + } else { + None + }; + + h += s(space::L); + let btn_top = h; + h += s(BTN_H) + pad; + + let card_x = (view.0 + (view.2 - card_w) * 0.5).round(); + let card_y = (view.1 + (view.3 - h) * 0.5).round(); + + let icon_x = card_x + pad; + let text_x = icon_x + icon_h + s(space::M); + // 两行身份文字在图标高度内垂直居中。 + let name_line = ch * type_scale::BODY; + let meta_line = ch * type_scale::SUPPORTING; + let text_block = name_line + s(2.0) + meta_line; + let text_top = card_y + icon_top + (icon_h - text_block) * 0.5; + + // Logs 按钮贴卡片右内缘,与图标同一条水平中线。 + let logs_label = language.pick("Logs", "Logs"); + let logs_w = text_w(logs_label, cell_w, type_scale::SUPPORTING) + s(space::M) * 2.0 + s(10.0); + let logs_btn = ( + (card_x + card_w - pad - logs_w).round(), + (card_y + icon_top + (icon_h - s(BTN_H)) * 0.5).round(), + logs_w.round(), + s(BTN_H), + ); + + // 底部按钮从右往左排,主按钮在左——与原型一致(提交 / 取消)。 + let mut buttons = Vec::new(); + let mut right = card_x + card_w - pad; + for (label, hit, primary) in button_specs(state, language).into_iter().rev() { + let w = (text_w(&label, cell_w, type_scale::BODY) + s(space::M) * 2.0).max(s(76.0)); + right -= w; + buttons.push(( + ((right).round(), (card_y + btn_top).round(), w.round(), s(BTN_H)), + hit, + primary, + )); + right -= s(space::S); + } + + Layout { + card: (card_x, card_y, card_w, h), + icon: (icon_x, card_y + icon_top, icon_h, icon_h), + name_x: text_x, + name_y: text_top.round(), + meta_y: (text_top + name_line + s(2.0)).round(), + logs_btn, + rail_x0: card_x + pad + s(NODE_D) * 0.5, + rail_x1: card_x + card_w - pad - s(NODE_D) * 0.5, + rail_cy: (card_y + rail_top + s(NODE_D) * 0.5).round(), + labels_y: (card_y + rail_top + s(NODE_D) + s(space::XS)).round(), + status_y: (card_y + status_top).round(), + detail_y: (card_y + detail_top).round(), + logs_area: logs_h.map(|inner| { + let top = card_y + btn_top - s(space::L) - inner; + ((card_x + pad).round(), top.round(), (card_w - pad * 2.0).round(), inner) + }), + buttons, + pad, + } +} + +fn contains(rect: (f32, f32, f32, f32), x: f32, y: f32) -> bool { + x >= rect.0 && x < rect.0 + rect.2 && y >= rect.1 && y < rect.1 + rect.3 +} + +/// 命中测试。卡片是模态浮层:**落在卡片内但不在任何控件上,也要吞掉事件**, +/// 否则点击会漏进下面的终端去拖选区——侧栏拖拽残影那个 bug 就是这么来的。 +pub(super) fn hit_test( + state: &SshConnectState, + size: &SizeInfo, + view: (f32, f32, f32, f32), + scale: f32, + language: UiLanguage, + x: f32, + y: f32, +) -> SshConnectHit { + let l = layout(state, size, view, scale, language); + if contains(l.logs_btn, x, y) { + return SshConnectHit::Logs; + } + for (rect, hit, _) in &l.buttons { + if contains(*rect, x, y) { + return *hit; + } + } + SshConnectHit::None +} + +/// 遮罩覆盖整个 pane,所以连接期间任何落在 pane 内的点击都不该穿透到终端。 +pub(super) fn covers(view: (f32, f32, f32, f32), x: f32, y: f32) -> bool { + contains(view, x, y) +} + +/// 节点中心的 x 坐标。 +fn node_x(l: &Layout, index: usize) -> f32 { + l.rail_x0 + (l.rail_x1 - l.rail_x0) * index as f32 / 3.0 +} + +/// 速度场:两端慢、中段快,与线性混合以保留端点的基础流速。 +/// +/// 拖尾位置也过这条曲线,于是快段自动拉长、慢段自动压缩——流体感就来自 +/// 这里,不是靠额外的装饰。 +fn ease(s: f32) -> f32 { + 0.35 * s + 0.65 * (s * s * (3.0 - 2.0 * s)) +} + +fn lerp_rgb(a: Rgb, b: Rgb, k: f32) -> Rgb { + let k = k.clamp(0.0, 1.0); + let mix = |x: u8, y: u8| (x as f32 + (y as f32 - x as f32) * k).round() as u8; + Rgb::new(mix(a.r, b.r), mix(a.g, b.g), mix(a.b, b.b)) +} + +#[allow(clippy::too_many_arguments)] +pub(super) fn push_quads( + state: &SshConnectState, + theme: &NebulaTheme, + quads: &mut Vec, + size: &SizeInfo, + view: (f32, f32, f32, f32), + scale: f32, + language: UiLanguage, + backdrop: Rgba, +) { + let l = layout(state, size, view, scale, language); + let s = |v: f32| v * scale; + let sk = theme.skin(); + let palette = theme.palette(); + let light = sk.is_light; + // 品牌色对:Nebula 主题自带的星云紫→青。浅色主题下压暗才够对比。 + let brand_l = if light { + lerp_rgb(rgb_of(palette.edge_l), Rgb::new(0, 0, 0), 0.28) + } else { + rgb_of(palette.edge_l) + }; + let brand_r = if light { + lerp_rgb(rgb_of(palette.edge_r), Rgb::new(0, 0, 0), 0.28) + } else { + rgb_of(palette.edge_r) + }; + + // ── 遮罩 ──────────────────────────────────────────────────── + // 连接期间 pane 里没有任何值得看的东西(grid 是空的,只有一个在 blink + // 的光标),却会跟着我们的持续重绘一起闪。用 pane 底色整块盖住:卡片 + // 由此成为真正的第一层,会话就绪后遮罩和卡片一起退场,露出真实终端。 + // ssh 自己的输出不会因此丢失——它们进了 Logs 区。 + quads.push(UiQuad::solid(view.0, view.1, view.2, view.3, 0.0, backdrop).pixel_snapped()); + + let (cx, cy, cw, chh) = l.card; + + // 阴影 + 面板:一个要求用户等待的浮层必须有真实高度。 + let shadow_alpha = if light { 26 } else { 56 }; + quads.push( + UiQuad::shadow( + cx, + cy, + cw, + chh, + s(radius::OVERLAY), + s(20.0), + s(7.0), + Rgba::new(0, 0, 0, shadow_alpha), + ) + .pixel_snapped(), + ); + quads.push(UiQuad::solid(cx, cy, cw, chh, s(radius::OVERLAY), sk.panel).pixel_snapped()); + // hairline 描边:与阴影二选一会显薄,浮层这一层两者都要(阴影给高度, + // 描边给边界),但描边只有 1px 且极淡。 + quads.push(UiQuad::solid(cx, cy, cw, chh, s(radius::OVERLAY), sk.hairline).pixel_snapped()); + quads.push( + UiQuad::solid( + cx + s(1.0), + cy + s(1.0), + cw - s(2.0), + chh - s(2.0), + s(radius::OVERLAY) - s(1.0), + sk.panel, + ) + .pixel_snapped(), + ); + + // 主机图标底:card 底 + hairline,不用彩色块。Termius 那种高饱和彩色 + // 方块排一屏时眼睛不知道看哪,而且颜色不承载任何可操作信息。 + let (ix, iy, iw, ih) = l.icon; + // 卡片底和图标底都带 alpha,直接拿它们当"挖空色"盖不住下面的墨迹—— + // 这正是 icons.rs 里 blend_over 存在的理由。先算出真实的不透明底色。 + let solid_panel = icons::blend_over(backdrop, sk.panel); + let solid_card = icons::blend_over(solid_panel, sk.card); + quads.push(UiQuad::solid(ix, iy, iw, ih, s(radius::CONTROL), solid_card).pixel_snapped()); + // 服务器机架墨迹:两层机箱 + 各一颗指示灯。字体私用区字形在分数 DPI 下 + // 会漂,所以和 icons.rs 一样走矢量,不借字形。 + { + let icx = ix + iw * 0.5; + let icy = iy + ih * 0.5; + let ink = Rgba::opaque(sk.ink_dim); + let bw = s(18.0); + let bh = s(7.5); + let gap = s(3.0); + let stroke = (s(1.4)).max(1.0); + let r = s(2.0); + for row in [-1.0f32, 1.0] { + let by = icy + row * (bh + gap) * 0.5 - bh * 0.5; + let bx = icx - bw * 0.5; + // 无 stroke 图元:ink 板 + 挖回底色的内芯。 + quads.push(UiQuad::solid(bx, by, bw, bh, r, ink).pixel_snapped()); + quads.push( + UiQuad::solid( + bx + stroke, + by + stroke, + bw - stroke * 2.0, + bh - stroke * 2.0, + (r - stroke).max(0.0), + solid_card, + ) + .pixel_snapped(), + ); + // 指示灯:靠左,与机箱同一条中线。 + let d = s(2.4); + quads.push(UiQuad::solid( + bx + stroke + s(2.2), + by + bh * 0.5 - d * 0.5, + d, + d, + d * 0.5, + ink, + )); + } + } + + // ── 星云轨道 ──────────────────────────────────────────────── + let rail_h = s(2.0); + let rail_y = (l.rail_cy - rail_h * 0.5).round(); + // 底轨:未完成部分。 + quads.push( + UiQuad::solid(l.rail_x0, rail_y, l.rail_x1 - l.rail_x0, rail_h, rail_h * 0.5, sk.hairline) + .pixel_snapped(), + ); + + let fill_to = node_x(&l, 0) + (l.rail_x1 - l.rail_x0) * (state.fill / 3.0); + let fill_w = (fill_to - l.rail_x0).max(0.0); + if fill_w > 0.5 { + // 已完成段:品牌紫→青,宽度即进度。这是全屏唯一一处渐变。 + let end = lerp_rgb(brand_l, brand_r, state.fill / 3.0); + if state.failed() { + quads.push( + UiQuad::solid(l.rail_x0, rail_y, fill_w, rail_h, rail_h * 0.5, sk.danger) + .pixel_snapped(), + ); + } else { + // 深色下给填充段极淡外溢;浅色主题 glow 一律关闭(theme.rs 把 + // 浅色 glow alpha 硬编码为 0:低 alpha 径向渐变在浅底上只落在 + // 极少数 8-bit 台阶,量化轮廓会读成模糊灰线)。 + if !light { + // brand-glow: 进度轨道的能量外溢是星云品牌位本身的画面。 + quads.push(UiQuad::glow( + l.rail_x0, + rail_y - s(3.0), + fill_w, + rail_h + s(6.0), + Rgba::opaque(end).with_alpha(0.20), + )); + } + quads.push( + UiQuad::gradient( + l.rail_x0, + rail_y, + fill_w, + rail_h, + rail_h * 0.5, + Rgba::opaque(brand_l), + Rgba::opaque(end), + Gradient::Horizontal, + ) + .pixel_snapped(), + ); + } + } + + // 粒子:只在当前段流动。失败后不画(动画停止)。 + if !state.failed() { + let seg = state.stage_index().min(2); + let from = node_x(&l, seg); + let to = node_x(&l, seg + 1); + let span = l.rail_x1 - l.rail_x0; + for i in 0..PARTICLE_COUNT { + let head = (state.phase + PARTICLE_PHASE[i]).fract(); + // 从尾画到头,头压在最上。 + for k in (0..=TRAIL_LEN).rev() { + let t = head - k as f32 * TRAIL_GAP; + if t < 0.0 || t > 1.0 { + continue; + } + let x = from + (to - from) * ease(t); + let fade = 1.0 - k as f32 / (TRAIL_LEN as f32 + 1.2); + // 进出两端淡入淡出,避免粒子在节点上凭空出现或撞停。 + let edge = (t / 0.14).min((1.0 - t) / 0.14).min(1.0); + let alpha = fade * fade * edge * if light { 0.55 } else { 0.9 }; + if alpha < 0.012 { + continue; + } + // 颜色按整条轨道的位置从品牌紫渐变到品牌青。 + let color = lerp_rgb(brand_l, brand_r, (x - l.rail_x0) / span.max(1.0)); + if !light { + let rad = if k == 0 { s(5.4) } else { s(4.4) * fade + s(1.2) }; + // brand-glow: 拖尾粒子就是发光体,不是在垫层级。 + quads.push(UiQuad::glow( + x - rad, + l.rail_cy - rad, + rad * 2.0, + rad * 2.0, + Rgba::opaque(color).with_alpha(alpha * 0.5), + )); + } + let cr = if k == 0 { s(1.55) } else { s(1.3) * fade + s(0.2) }; + quads.push(UiQuad::solid( + x - cr, + l.rail_cy - cr, + cr * 2.0, + cr * 2.0, + cr, + Rgba::opaque(color).with_alpha(alpha * if k == 0 { 1.0 } else { 0.78 }), + )); + } + } + } + + // 节点:三态。空心(未到)→ accent 实心带光环(正在做)→ 品牌青实心。 + let done_upto = state.fill.floor() as usize; + let active = state.stage_index(); + for i in 0..4 { + let d = s(NODE_D); + let x = node_x(&l, i) - d * 0.5; + let y = l.rail_cy - d * 0.5; + let failed_here = state.failed() && i == active; + if failed_here { + quads.push(UiQuad::solid(x, y, d, d, d * 0.5, sk.danger).pixel_snapped()); + } else if i <= done_upto && i < active { + quads.push(UiQuad::solid(x, y, d, d, d * 0.5, Rgba::opaque(brand_r)).pixel_snapped()); + } else if i == active { + if !light { + // brand-glow: 当前阶段节点的脉冲,和轨道粒子是同一套语言。 + quads.push(UiQuad::glow( + x - s(3.0), + y - s(3.0), + d + s(6.0), + d + s(6.0), + Rgba::opaque(sk.accent).with_alpha(0.45), + )); + } + quads.push(UiQuad::solid(x, y, d, d, d * 0.5, Rgba::opaque(sk.accent)).pixel_snapped()); + } else { + // 空心:外圈 hairline + 内芯挖回面板色。 + quads.push(UiQuad::solid(x, y, d, d, d * 0.5, sk.hairline).pixel_snapped()); + let inset = s(1.5); + quads.push( + UiQuad::solid( + x + inset, + y + inset, + d - inset * 2.0, + d - inset * 2.0, + (d - inset * 2.0) * 0.5, + sk.panel, + ) + .pixel_snapped(), + ); + } + } + + // ── Logs 折叠开关 ─────────────────────────────────────────── + // Tabby 那张连接图唯一真正值钱的东西就是它,所以它必须在失败之前就在 + // 场,而不是失败后才冒出来。 + push_button_frame( + quads, + l.logs_btn, + s(radius::CONTROL), + false, + state.hover == SshConnectHit::Logs, + sk, + ); + { + let (bx, by, bw, bh) = l.logs_btn; + let cxx = bx + bw - s(space::M) - s(3.0); + let cyy = by + bh * 0.5; + let arm = s(3.2); + let stroke = (s(1.3)).max(1.0); + let ink = Rgba::opaque(sk.ink_dim); + // 展开时朝上,收起时朝下——方向指向"点下去会发生什么"。 + let dir = if state.logs_open { -1.0 } else { 1.0 }; + icons::push_segment( + quads, + (cxx - arm, cyy - arm * 0.5 * dir), + (cxx, cyy + arm * 0.5 * dir), + stroke, + ink, + ); + icons::push_segment( + quads, + (cxx, cyy + arm * 0.5 * dir), + (cxx + arm, cyy - arm * 0.5 * dir), + stroke, + ink, + ); + } + + // ── 日志区 ───────────────────────────────────────────────── + if let Some(area) = l.logs_area { + quads.push( + UiQuad::solid(area.0, area.1, area.2, area.3, s(radius::CONTROL), sk.hairline) + .pixel_snapped(), + ); + let inset = s(1.0); + quads.push( + UiQuad::solid( + area.0 + inset, + area.1 + inset, + area.2 - inset * 2.0, + area.3 - inset * 2.0, + s(radius::CONTROL) - inset, + sk.card, + ) + .pixel_snapped(), + ); + } + + // ── 底部按钮 ─────────────────────────────────────────────── + for (rect, hit, primary) in &l.buttons { + push_button_frame(quads, *rect, s(radius::CONTROL), *primary, state.hover == *hit, sk); + } +} + +/// 按钮底:主按钮实心 accent,次按钮 card 底 + hairline 描边。hover 只改 +/// 亮度,不改尺寸——动效纪律里"不加装饰动效"的直接后果。 +fn push_button_frame( + quads: &mut Vec, + rect: (f32, f32, f32, f32), + radius: f32, + primary: bool, + hovered: bool, + sk: crate::display::ui::theme::Skin, +) { + let (x, y, w, h) = rect; + let lift = |c: Rgba| { + if !hovered { + return c; + } + // 深色底往上提亮,浅色底往下压暗,两边都是"更靠近手指"的方向。 + let top = if sk.is_light { Rgba::new(0, 0, 0, 20) } else { Rgba::new(255, 255, 255, 26) }; + icons::blend_over(c, top) + }; + if primary { + quads + .push(UiQuad::solid(x, y, w, h, radius, lift(Rgba::opaque(sk.accent))).pixel_snapped()); + } else { + quads.push(UiQuad::solid(x, y, w, h, radius, sk.hairline).pixel_snapped()); + let inset = (radius * 0.0 + 1.0).max(1.0); + quads.push( + UiQuad::solid( + x + inset, + y + inset, + w - inset * 2.0, + h - inset * 2.0, + (radius - inset).max(0.0), + lift(sk.card), + ) + .pixel_snapped(), + ); + } +} + +pub(super) fn rgb_of(c: Rgba) -> Rgb { + Rgb::new(c.r, c.g, c.b) +} + +/// 文字宽度(像素)。等宽字体下按列宽求和是精确的。 +fn text_w(text: &str, cell_w: f32, scale: f32) -> f32 { + let cols: usize = text.chars().map(|c| c.width().unwrap_or(0)).sum(); + cols as f32 * cell_w * scale +} + +/// 估算宽度与实际渲染宽度之间的安全系数。 +/// +/// UI 文字走 UI 字体,中文字形的实际推进宽度略大于"列数 × cell_w"的估算, +/// 按估算贴边排版会溢出卡片——失败详情第一次就是这么冲出右边界的。 +const TEXT_SAFETY: f32 = 0.94; + +/// 一块宽度能放下多少列。 +pub(super) fn cols_that_fit(width: f32, cell_w: f32, scale: f32) -> usize { + ((width * TEXT_SAFETY / (cell_w * scale)).floor() as usize).max(4) +} + +/// 按列宽截断,超出部分收成省略号。日志行里那条中文系统错误消息不截断就 +/// 会一路画到窗口外面去。 +pub(super) fn truncate_cols(text: &str, max_cols: usize) -> String { + let total: usize = text.chars().map(|c| c.width().unwrap_or(0)).sum(); + if total <= max_cols { + return text.to_owned(); + } + let budget = max_cols.saturating_sub(1); + let mut out = String::new(); + let mut cols = 0usize; + for c in text.chars() { + let w = c.width().unwrap_or(0); + if cols + w > budget { + break; + } + out.push(c); + cols += w; + } + out.push('…'); + out +} + +#[allow(clippy::too_many_arguments)] +pub(super) fn draw_text( + state: &SshConnectState, + theme: &NebulaTheme, + language: UiLanguage, + r: &mut Renderer, + gc: &mut GlyphCache, + size: &SizeInfo, + view: (f32, f32, f32, f32), + scale: f32, +) { + let l = layout(state, size, view, scale, language); + let sk = theme.skin(); + let cell_w = size.cell_width(); + + // 身份:标签用主墨,技术地址降一级——列表和卡片同一套读法。 + let name = short_name(&state.destination); + r.draw_ui_text( + size, + l.name_x, + l.name_y, + type_scale::BODY, + sk.ink_strong, + Flags::empty(), + &name, + gc, + ); + let meta = format!("SSH · {}", state.destination); + r.draw_ui_text( + size, + l.name_x, + l.meta_y, + type_scale::SUPPORTING, + sk.ink_dim, + Flags::empty(), + &meta, + gc, + ); + + // 阶段标签:已完成 ink_dim、当前 ink、未到 ink_faint。 + let labels = stage_labels(language); + let active = state.stage_index(); + for (i, label) in labels.iter().enumerate() { + let ink = if state.failed() && i == active { + rgb_of(sk.danger) + } else if i == active { + sk.ink + } else if i < active { + sk.ink_dim + } else { + sk.ink_faint + }; + let w = text_w(label, cell_w, type_scale::SECTION_CAPTION); + // 首尾贴齐轨道两端,中间的以节点为中心——标签是节点的名字,不是 + // 均分的四列。 + let cx = node_x(&l, i); + let x = match i { + 0 => l.rail_x0 - s_half(scale), + 3 => l.rail_x1 + s_half(scale) - w, + _ => cx - w * 0.5, + }; + r.draw_ui_text( + size, + x.round(), + l.labels_y, + type_scale::SECTION_CAPTION, + ink, + Flags::empty(), + label, + gc, + ); + } + + // 状态行:左边当前动作,右边计时(失败后计时停在失败时刻)。 + let (msg, msg_ink) = match &state.failure { + Some(_) => (failure_headline(language), rgb_of(sk.danger)), + None => (stage_message(&state.stage, language), sk.ink), + }; + r.draw_ui_text( + size, + l.card.0 + l.pad, + l.status_y, + type_scale::BODY, + msg_ink, + Flags::empty(), + &msg, + gc, + ); + if !state.failed() { + let elapsed = state.elapsed_text(); + let w = text_w(&elapsed, cell_w, type_scale::BODY); + r.draw_ui_text( + size, + (l.card.0 + l.card.2 - l.pad - w).round(), + l.status_y, + type_scale::BODY, + sk.ink_faint, + Flags::empty(), + &elapsed, + gc, + ); + } + + // 失败详情:指到位置的原因,最多两行,末行溢出收成省略号。 + if let Some(reason) = &state.failure { + let avail = l.card.2 - l.pad * 2.0; + let per_line = cols_that_fit(avail, cell_w, type_scale::SUPPORTING); + let ch = size.cell_height(); + for (i, line) in wrap(reason, per_line).into_iter().take(2).enumerate() { + r.draw_ui_text( + size, + l.card.0 + l.pad, + l.detail_y + i as f32 * ch * type_scale::SUPPORTING * 1.35, + type_scale::SUPPORTING, + sk.ink_dim, + Flags::empty(), + &truncate_cols(&line, per_line), + gc, + ); + } + } + + // Logs 开关的标签(箭头是矢量,在 push_quads 里)。 + r.draw_ui_text( + size, + (l.logs_btn.0 + space::M * scale).round(), + (l.logs_btn.1 + (l.logs_btn.3 - size.cell_height() * type_scale::SUPPORTING) * 0.5).round(), + type_scale::SUPPORTING, + sk.ink_dim, + Flags::empty(), + "Logs", + gc, + ); + + // 日志:只显示末尾若干行,最新的在最下——和终端一样往下长。每行按日志 + // 区宽度截断,否则那条中文系统错误会一路画到窗口外面。 + if let Some(area) = l.logs_area { + let line_h = size.cell_height() * type_scale::SUPPORTING; + let inner_pad = space::S * scale; + let max_cols = cols_that_fit(area.2 - inner_pad * 2.0, cell_w, type_scale::SUPPORTING); + let start = state.log.len().saturating_sub(LOG_ROWS); + for (i, line) in state.log[start..].iter().enumerate() { + let y = area.1 + inner_pad + i as f32 * line_h; + if y + line_h > area.1 + area.3 { + break; + } + let ink = if line.contains("error") { rgb_of(sk.danger) } else { sk.ink_dim }; + r.draw_ui_text( + size, + (area.0 + inner_pad).round(), + y.round(), + type_scale::SUPPORTING, + ink, + Flags::empty(), + &truncate_cols(line, max_cols), + gc, + ); + } + } + + // 底部按钮文字:居中。主按钮的文字色按 accent 亮度选黑或白,不能写死。 + for (rect, hit, primary) in &l.buttons { + let label = button_specs(state, language) + .into_iter() + .find(|(_, h, _)| h == hit) + .map(|(label, _, _)| label) + .unwrap_or_default(); + let w = text_w(&label, cell_w, type_scale::BODY); + let ink = if *primary { on_accent(sk.accent) } else { sk.ink }; + r.draw_ui_text( + size, + (rect.0 + (rect.2 - w) * 0.5).round(), + (rect.1 + (rect.3 - size.cell_height() * type_scale::BODY) * 0.5).round(), + type_scale::BODY, + ink, + Flags::empty(), + &label, + gc, + ); + } +} + +/// accent 上的文字色。accent 在浅色主题里是深靛蓝、在深色主题里是亮蓝, +/// 写死黑或白都会在另一边糊掉。 +fn on_accent(accent: Rgb) -> Rgb { + let lum = 0.299 * accent.r as f32 + 0.587 * accent.g as f32 + 0.114 * accent.b as f32; + if lum > 150.0 { Rgb::new(12, 14, 18) } else { Rgb::new(255, 255, 255) } +} + +fn s_half(scale: f32) -> f32 { + NODE_D * 0.5 * scale +} + +/// 列表和卡片共用的短名:有别名就用别名,否则去掉 user@ 只留主机。 +fn short_name(destination: &str) -> String { + let host = destination.rsplit('@').next().unwrap_or(destination); + host.to_owned() +} + +fn stage_labels(language: UiLanguage) -> [String; 4] { + [ + language.pick("解析", "Resolve").to_owned(), + language.pick("连接", "Connect").to_owned(), + language.pick("认证", "Auth").to_owned(), + language.pick("会话", "Shell").to_owned(), + ] +} + +fn stage_message(stage: &SshStage, language: UiLanguage) -> String { + match stage { + SshStage::Resolve => language.pick("正在解析主机…", "Resolving host…"), + SshStage::Connect => language.pick("正在建立连接…", "Connecting…"), + SshStage::Authenticate => language.pick("正在认证…", "Authenticating…"), + SshStage::OpenShell => language.pick("正在打开会话…", "Opening shell…"), + SshStage::Ready => language.pick("已连接", "Connected"), + SshStage::Failed(_) => language.pick("连接失败", "Connection failed"), + } + .to_owned() +} + +fn failure_headline(language: UiLanguage) -> String { + language.pick("连接失败", "Connection failed").to_owned() +} + +/// 按列宽换行。中日韩宽字符按 2 列计,所以不能按 char 数切。 +fn wrap(text: &str, per_line: usize) -> Vec { + let mut lines = Vec::new(); + let mut line = String::new(); + let mut cols = 0usize; + for c in text.chars() { + if c == '\n' { + lines.push(std::mem::take(&mut line)); + cols = 0; + continue; + } + let w = c.width().unwrap_or(0); + if cols + w > per_line && !line.is_empty() { + lines.push(std::mem::take(&mut line)); + cols = 0; + } + line.push(c); + cols += w; + } + if !line.is_empty() { + lines.push(line); + } + lines +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn card_stays_hidden_until_the_reveal_delay() { + let state = SshConnectState::new("root@example.com".into()); + assert!(!state.visible(), "快连接不该闪一下加载页"); + } + + #[test] + fn failure_shows_immediately_regardless_of_delay() { + let mut state = SshConnectState::new("root@example.com".into()); + state.set_stage(SshStage::Failed("Permission denied".into())); + assert!(state.visible()); + assert!(state.failed()); + } + + #[test] + fn stage_index_maps_every_stage_into_the_four_slot_rail() { + let mut state = SshConnectState::new("h".into()); + for (stage, want) in [ + (SshStage::Resolve, 0), + (SshStage::Connect, 1), + (SshStage::Authenticate, 2), + (SshStage::OpenShell, 3), + (SshStage::Ready, 3), + ] { + state.set_stage(stage); + assert_eq!(state.stage_index(), want); + } + } + + #[test] + fn fill_interpolates_toward_the_stage_without_overshooting() { + let mut state = SshConnectState::new("h".into()); + state.set_stage(SshStage::Authenticate); + for _ in 0..240 { + state.step(Duration::from_millis(16)); + } + assert!((state.fill - 2.0).abs() < 0.05, "fill={}", state.fill); + assert!(state.fill <= 2.0 + f32::EPSILON, "插值不该越过目标"); + } + + #[test] + fn particle_phase_wraps_and_never_leaves_unit_range() { + let mut state = SshConnectState::new("h".into()); + for _ in 0..500 { + state.step(Duration::from_millis(33)); + assert!((0.0..1.0).contains(&state.phase), "phase={}", state.phase); + } + } + + #[test] + fn failed_state_freezes_the_animation() { + let mut state = SshConnectState::new("h".into()); + state.step(Duration::from_millis(16)); + let phase = state.phase; + state.set_stage(SshStage::Failed("boom".into())); + state.step(Duration::from_millis(100)); + assert_eq!(state.phase, phase, "失败后不该继续跑粒子"); + } + + #[test] + fn speed_field_is_monotonic_and_pins_both_ends() { + assert!((ease(0.0)).abs() < 1e-6); + assert!((ease(1.0) - 1.0).abs() < 1e-6); + let mut prev = -1.0; + for i in 0..=100 { + let v = ease(i as f32 / 100.0); + assert!(v > prev, "速度场必须单调递增"); + prev = v; + } + // 中段比端点快:这正是拖尾被拉长的原因。 + let mid = ease(0.55) - ease(0.45); + let head = ease(0.10) - ease(0.0); + assert!(mid > head, "中段速度应高于起步段"); + } + + #[test] + fn wrap_counts_wide_characters_as_two_columns() { + let lines = wrap("认证失败拒绝", 4); + assert_eq!(lines, vec!["认证".to_owned(), "失败".to_owned(), "拒绝".to_owned()]); + } + + #[test] + fn short_name_drops_the_user_prefix() { + assert_eq!(short_name("root@192.168.200.150"), "192.168.200.150"); + assert_eq!(short_name("web-01"), "web-01"); + } + + fn probe_size() -> SizeInfo { + SizeInfo::new_fully_asymmetric(1000.0, 1000.0, 10.0, 20.0, 0.0, 0.0, 64.0, 16.0) + } + + const VIEW: (f32, f32, f32, f32) = (0.0, 0.0, 1000.0, 800.0); + + #[test] + fn failure_opens_the_log_so_the_reason_is_not_one_click_away() { + let mut state = SshConnectState::new("root@h".into()); + assert!(!state.logs_open); + state.set_stage(SshStage::Failed("Permission denied".into())); + assert!(state.logs_open, "失败原因是最该被看见的东西"); + assert!( + state.log.iter().any(|line| line.contains("Permission denied")), + "失败原因必须进日志: {:?}", + state.log + ); + } + + #[test] + fn repeated_stage_reports_do_not_flood_the_log() { + let mut state = SshConnectState::new("root@h".into()); + let base = state.log.len(); + for _ in 0..20 { + state.set_stage(SshStage::Connect); + } + assert_eq!(state.log.len(), base + 1, "同一阶段重复上报只该记一行"); + } + + #[test] + fn buttons_and_logs_toggle_hit_where_they_are_drawn() { + let size = probe_size(); + let lang = UiLanguage::EnUs; + let state = SshConnectState::new("root@h".into()); + let l = layout(&state, &size, VIEW, 1.0, lang); + + let (bx, by, bw, bh) = l.logs_btn; + assert_eq!( + hit_test(&state, &size, VIEW, 1.0, lang, bx + bw * 0.5, by + bh * 0.5), + SshConnectHit::Logs + ); + + let (rect, hit, _) = l.buttons[0]; + assert_eq!(hit, SshConnectHit::Cancel, "连接中只该有取消"); + assert_eq!( + hit_test(&state, &size, VIEW, 1.0, lang, rect.0 + rect.2 * 0.5, rect.1 + rect.3 * 0.5), + SshConnectHit::Cancel + ); + } + + #[test] + fn failed_card_offers_close_instead_of_cancel() { + let size = probe_size(); + let lang = UiLanguage::EnUs; + let mut state = SshConnectState::new("root@h".into()); + state.set_stage(SshStage::Failed("boom".into())); + let l = layout(&state, &size, VIEW, 1.0, lang); + let (rect, hit, primary) = l.buttons[0]; + assert_eq!(hit, SshConnectHit::Close); + assert!(primary, "唯一动作应当是主按钮"); + assert_eq!( + hit_test(&state, &size, VIEW, 1.0, lang, rect.0 + rect.2 * 0.5, rect.1 + rect.3 * 0.5), + SshConnectHit::Close + ); + } + + #[test] + fn the_mask_swallows_clicks_anywhere_in_the_pane() { + // 卡片之外的空白也必须被吞掉,否则按压会漏进终端起拖选。 + assert!(covers(VIEW, 5.0, 5.0)); + assert!(covers(VIEW, 999.0, 799.0)); + assert!(!covers(VIEW, 1001.0, 400.0)); + } + + #[test] + fn opening_the_log_makes_the_card_taller_not_narrower() { + let size = probe_size(); + let lang = UiLanguage::EnUs; + let mut state = SshConnectState::new("root@h".into()); + let before = layout(&state, &size, VIEW, 1.0, lang).card; + state.toggle_logs(); + let after = layout(&state, &size, VIEW, 1.0, lang).card; + assert!(after.3 > before.3, "展开日志应当长高"); + assert_eq!(after.2, before.2, "宽度不该跟着变,否则读起来像在抖"); + assert!(layout(&state, &size, VIEW, 1.0, lang).logs_area.is_some()); + } +} diff --git a/nebula_app/src/display/ssh_editor_input.rs b/nebula_app/src/display/ssh_editor_input.rs index f53115e..a8484f4 100644 --- a/nebula_app/src/display/ssh_editor_input.rs +++ b/nebula_app/src/display/ssh_editor_input.rs @@ -35,10 +35,17 @@ impl Display { destination: String::new(), port: String::new(), label: String::new(), + icon: String::new(), + icon_picker: false, + icon_filter: String::new(), + icon_filter_cursor: Default::default(), + icon_scroll: 0, password: String::new(), save_password: true, show_password: false, - auth: crate::ssh_profiles::SshAuthMode::Auto, + // 默认密码:第一次添加主机的人手里通常只有一串密码;「自动」要 + // 先有配好的私钥才走得通,拿它当默认等于让新手先撞一次失败。 + auth: crate::ssh_profiles::SshAuthMode::Password, private_keys: Vec::new(), field: SshEditorField::Destination, focus: crate::ux::FocusIndex::default(), @@ -73,6 +80,11 @@ impl Display { destination: address, port, label: profile.label.clone().unwrap_or_default(), + icon: profile.icon.clone().unwrap_or_default(), + icon_picker: false, + icon_filter: String::new(), + icon_filter_cursor: Default::default(), + icon_scroll: 0, // Never pull a stored secret back into a text field. Leaving this // blank preserves the existing credential when the address stays // unchanged; typing a new value explicitly replaces it. @@ -116,12 +128,136 @@ impl Display { } } + /// 图标选择器是否正开着。开着的时候它**独占键盘**:打字进搜索框、Esc + /// 只关它、回车挑第一项——一个浮层开着的时候,键盘属于最上面那一层。 + pub fn ssh_editor_icon_picker_open(&self) -> bool { + self.nebula_ssh_editor.as_ref().is_some_and(|editor| editor.icon_picker) + } + + pub fn ssh_editor_close_icon_picker(&mut self) { + if let Some(editor) = self.nebula_ssh_editor.as_mut() { + editor.icon_picker = false; + editor.icon_filter.clear(); + editor.icon_filter_cursor = Default::default(); + editor.icon_scroll = 0; + self.pending_update.dirty = true; + self.window.request_redraw(); + } + } + + /// 搜索词改一个字,列表就得回到顶:筛完之后的第 5 行和筛之前的第 5 行 + /// 毫无关系,停在原处等于随机翻了一页。 + /// + /// 编辑走光标模型:插入落在**光标处**、替换选区,Backspace 先删选区。 + /// 它是一个正经输入框,不是只能尾部追加的过滤串。 + pub fn ssh_editor_icon_filter_edit(&mut self, insert: Option<&str>) { + if let Some(editor) = self.nebula_ssh_editor.as_mut() { + match insert { + Some(text) => editor.icon_filter_cursor.insert(&mut editor.icon_filter, text), + None => editor.icon_filter_cursor.backspace(&mut editor.icon_filter), + } + editor.icon_scroll = 0; + self.pending_update.dirty = true; + self.window.request_redraw(); + } + } + + pub fn ssh_editor_icon_filter_delete_forward(&mut self) { + if let Some(editor) = self.nebula_ssh_editor.as_mut() { + editor.icon_filter_cursor.delete_forward(&mut editor.icon_filter); + editor.icon_scroll = 0; + self.pending_update.dirty = true; + self.window.request_redraw(); + } + } + + /// 搜索框的光标移动 / Home / End / 全选 / 取选中——全部转发组件层。 + /// 它和表单字段的差别只有"值存在哪",行为必须是同一套。 + pub fn ssh_editor_icon_filter_move(&mut self, forward: bool, extend: bool) { + if let Some(editor) = self.nebula_ssh_editor.as_mut() { + let text = editor.icon_filter.clone(); + editor.icon_filter_cursor.step(&text, forward, extend); + self.pending_update.dirty = true; + self.window.request_redraw(); + } + } + + pub fn ssh_editor_icon_filter_jump(&mut self, to_end: bool, extend: bool) { + if let Some(editor) = self.nebula_ssh_editor.as_mut() { + let text = editor.icon_filter.clone(); + editor.icon_filter_cursor.jump(&text, to_end, extend); + self.pending_update.dirty = true; + self.window.request_redraw(); + } + } + + pub fn ssh_editor_icon_filter_select_all(&mut self) { + if let Some(editor) = self.nebula_ssh_editor.as_mut() { + let text = editor.icon_filter.clone(); + editor.icon_filter_cursor.select_all(&text); + self.pending_update.dirty = true; + self.window.request_redraw(); + } + } + + pub fn ssh_editor_icon_filter_selected_text(&self) -> Option { + let editor = self.nebula_ssh_editor.as_ref()?; + editor.icon_filter_cursor.selected_text(&editor.icon_filter) + } + + /// 回车挑当前列表的第一项。搜到只剩一个的时候,让人还得再去点一下鼠标 + /// 是没道理的。 + pub fn ssh_editor_icon_pick_first(&mut self) { + let first = self + .nebula_ssh_editor_rects + .as_ref() + .and_then(|rects| rects.icon_rows.first().map(|(pick, _)| *pick)); + if let (Some(pick), Some(editor)) = (first, self.nebula_ssh_editor.as_mut()) { + editor.icon = match pick { + Some(index) => super::ui::os_icons::CATALOG[index].id.to_owned(), + None => String::new(), + }; + } + self.ssh_editor_close_icon_picker(); + self.pending_update.dirty = true; + self.window.request_redraw(); + } + + pub fn ssh_editor_icon_scroll(&mut self, lines: i32) { + let max = self.nebula_ssh_editor_rects.as_ref().map_or(0, |rects| rects.icon_max_scroll); + if let Some(editor) = self.nebula_ssh_editor.as_mut() { + let next = (editor.icon_scroll as i32 + lines).clamp(0, max as i32) as usize; + if next != editor.icon_scroll { + editor.icon_scroll = next; + self.pending_update.dirty = true; + self.window.request_redraw(); + } + } + } + pub fn ssh_editor_hit(&self, x: f32, y: f32) -> SshEditorHit { let Some(rects) = self.nebula_ssh_editor_rects.as_ref() else { return SshEditorHit::None; }; let hit = |r: (f32, f32, f32, f32)| x >= r.0 && x < r.0 + r.2 && y >= r.1 && y < r.1 + r.3; - if hit(rects.close) { + // 弹层最先判:它画在最上面,命中顺序就得跟绘制顺序反过来,否则被它 + // 盖住的字段会"隔着浮层"被点到。落在浮层里但不在任何一行上的点由 + // `IconPopupChrome` 吞掉——盖住的地方不再属于下面那一层。 + if hit(rects.icon_popup) { + if hit(rects.icon_search) { + return SshEditorHit::IconSearch; + } + return rects + .icon_rows + .iter() + .find(|(_, rect)| hit(*rect)) + .map_or(SshEditorHit::IconPopupChrome, |(pick, _)| { + SshEditorHit::IconOption(*pick) + }); + } + if hit(rects.avatar) { + SshEditorHit::Avatar + } else if hit(rects.close) { SshEditorHit::Close } else if hit(rects.destination) { SshEditorHit::Destination @@ -196,7 +332,7 @@ impl Display { let Some(rects) = self.nebula_ssh_editor_rects.as_ref() else { return }; let Some((field, rect)) = rects.field_of(hit) else { return }; let origin = rects.metrics.origin(field); - let cell_w = rects.metrics.cell_w; + let cell_w = rects.metrics.cell_w_of(field); let slot = match field { SshEditorField::Destination => 0, SshEditorField::Port => 1, @@ -210,21 +346,33 @@ impl Display { let text = value.clone(); cursor.place(&text, index); let _ = rect; - self.nebula_ssh_editor_drag = Some(field); + self.nebula_ssh_editor_drag = Some(ssh_ui::SshEditorDrag::Field(field)); } /// 拖动中:把选区从按下的锚点拉到当前位置。返回是否真的在拖,让调用方 /// 决定要不要重绘。 pub fn ssh_editor_drag_to(&mut self, x: f32) -> bool { - let Some(field) = self.nebula_ssh_editor_drag else { return false }; + let Some(drag) = self.nebula_ssh_editor_drag else { return false }; let Some(rects) = self.nebula_ssh_editor_rects.as_ref() else { return false }; - let origin = rects.metrics.origin(field); - let cell_w = rects.metrics.cell_w; - let Some(editor) = self.nebula_ssh_editor.as_mut() else { return false }; - let index = editor.index_at(field, x - origin, cell_w); - let (value, cursor) = editor.field_mut(field); - let text = value.clone(); - cursor.extend_to(&text, index); + match drag { + ssh_ui::SshEditorDrag::Field(field) => { + let origin = rects.metrics.origin(field); + let cell_w = rects.metrics.cell_w_of(field); + let Some(editor) = self.nebula_ssh_editor.as_mut() else { return false }; + let index = editor.index_at(field, x - origin, cell_w); + let (value, cursor) = editor.field_mut(field); + let text = value.clone(); + cursor.extend_to(&text, index); + }, + ssh_ui::SshEditorDrag::IconSearch => { + let (text_x, cell_w) = (rects.icon_search_text_x, rects.icon_search_cell_w); + let Some(editor) = self.nebula_ssh_editor.as_mut() else { return false }; + let index = + super::ui::text_field::index_at(&editor.icon_filter, x - text_x, cell_w); + let text = editor.icon_filter.clone(); + editor.icon_filter_cursor.extend_to(&text, index); + }, + } true } @@ -248,10 +396,8 @@ impl Display { } // 位数上限在插入**前**算:先插再 `truncate` 会砍掉尾部,而 // 用户刚打的那个字符正在光标处——被砍的看起来是别人的字符。 - let selected = editor - .port_cursor - .range(&editor.port) - .map_or(0, |(start, end)| end - start); + let selected = + editor.port_cursor.range(&editor.port).map_or(0, |(start, end)| end - start); let room = 5usize.saturating_sub(editor.port.chars().count() - selected); if room == 0 { return; @@ -438,7 +584,25 @@ impl Display { } pub fn ssh_editor_click(&mut self, x: f32, y: f32) -> bool { - match self.ssh_editor_hit(x, y) { + let hit = self.ssh_editor_hit(x, y); + // 列表开着的时候,点在它以外的任何地方都只做一件事:关掉它。这一下 + // **不**穿透到底下的控件——用户的意图是"收起这个列表",顺手把光标 + // 落进某个输入框、甚至切了认证方式,都是他没要的第二个动作。 + if self.nebula_ssh_editor.as_ref().is_some_and(|editor| editor.icon_picker) + && !matches!( + hit, + SshEditorHit::IconOption(_) + | SshEditorHit::IconSearch + | SshEditorHit::IconPopupChrome + | SshEditorHit::Avatar + ) + { + self.ssh_editor_close_icon_picker(); + self.pending_update.dirty = true; + self.window.request_redraw(); + return true; + } + match hit { // 点哪个框就聚焦哪个框,并把光标放到点中的字符缝隙上,同时开一段 // 拖选。焦点序号必须和 `ssh_editor_next_field` 的顺序一致,否则点 // 完再按 Tab 会跳到毫不相干的地方。 @@ -451,6 +615,48 @@ impl Display { editor.show_password = !editor.show_password; } }, + SshEditorHit::Avatar => { + if let Some(editor) = self.nebula_ssh_editor.as_mut() { + editor.icon_picker = !editor.icon_picker; + // 每次打开都从干净的状态开始:上次搜过的词留在框里,下次 + // 打开只剩一两项可选,人会以为图标丢了。 + editor.icon_filter.clear(); + editor.icon_filter_cursor = Default::default(); + editor.icon_scroll = 0; + if editor.icon_picker { + // 弹层一开搜索框即聚焦:光标此刻必须是亮的。 + super::ui::caret::note_activity(); + } + } + }, + SshEditorHit::IconOption(pick) => { + if let Some(editor) = self.nebula_ssh_editor.as_mut() { + editor.icon = match pick { + Some(index) => super::ui::os_icons::CATALOG[index].id.to_owned(), + // 「自动识别」存空串:配置里只写用户明确挑过的形状。 + None => String::new(), + }; + // 图标不参与连接,所以**不**清测试结果——刚测通的那条绿字 + // 依然对这份草稿有效,换个形状不该把它抹掉。 + editor.icon_picker = false; + editor.icon_filter.clear(); + editor.icon_scroll = 0; + } + }, + // 浮层自己的空白:吞掉,什么都不做。 + SshEditorHit::IconPopupChrome => {}, + // 搜索框:点击定位光标并开一段拖选——正经输入框的第一课。 + SshEditorHit::IconSearch => { + let Some(rects) = self.nebula_ssh_editor_rects.as_ref() else { return true }; + let (text_x, cell_w) = (rects.icon_search_text_x, rects.icon_search_cell_w); + if let Some(editor) = self.nebula_ssh_editor.as_mut() { + let index = + super::ui::text_field::index_at(&editor.icon_filter, x - text_x, cell_w); + let text = editor.icon_filter.clone(); + editor.icon_filter_cursor.place(&text, index); + self.nebula_ssh_editor_drag = Some(ssh_ui::SshEditorDrag::IconSearch); + } + }, SshEditorHit::Auth(mode) => { if let Some(editor) = self.nebula_ssh_editor.as_mut() { editor.auth = mode; @@ -584,13 +790,25 @@ impl Display { profiles.rename(original, &destination); } } + // 标签留空就发一个自增的默认名(「主机 6」)而不是存 None。 + // + // 侧栏的主机行是双行的:第一行名字、第二行地址。没有名字那一行就得 + // 拿地址顶上,于是同一行里出现两遍同样的字符串——比留空更糟。给个 + // 编号至少让它可读、可改、可搜。 + // + // 编号取现有默认标签的最大值 +1(见 `next_default_label`),所以删掉 + // 中间几台再新建也不会撞名。 + let label = match editor.label.trim() { + "" => profiles.next_default_label(self.ui_language().pick("主机", "Host")), + named => named.to_owned(), + }; profiles.upsert(crate::ssh_profiles::SshProfileAuth { destination: destination.clone(), auth: editor.auth, private_keys: editor.private_keys.clone(), - // 空标签存成 None 而不是空串:两者语义相同,但 None 会被跳过序列化, - // 于是没起名字的主机在配置文件里干干净净。 - label: Some(editor.label.trim().to_owned()).filter(|label| !label.is_empty()), + label: Some(label), + // 空串 = 自动识别,不落盘;配置里只存用户明确选过的形状。 + icon: (!editor.icon.is_empty()).then(|| editor.icon.clone()), }); if let Err(err) = profiles.save(&profile_path) { editor.error = Some(format!("保存 SSH Profile 失败: {err}")); @@ -602,6 +820,7 @@ impl Display { // 只在存盘成功后才刷新缓存:写失败时侧栏应当继续显示旧名字,而不是 // 显示一个磁盘上并不存在的标签。 self.nebula_ssh_labels = profiles.labels(); + self.nebula_ssh_icons = profiles.icons(); if ssh_ui::auth_sections(editor.auth).0 && editor.save_password && !editor.password.is_empty() diff --git a/nebula_app/src/display/ssh_editor_render.rs b/nebula_app/src/display/ssh_editor_render.rs index 81c81d5..80bf254 100644 --- a/nebula_app/src/display/ssh_editor_render.rs +++ b/nebula_app/src/display/ssh_editor_render.rs @@ -2,9 +2,9 @@ use unicode_width::UnicodeWidthChar; use super::ssh_connect::{cols_that_fit, rgb_of, truncate_cols}; use super::ssh_ui::{SshTestState, auth_sections}; -use super::*; use super::ui::theme; use super::ui::tokens::{control, radius, space, type_scale}; +use super::*; use crate::ssh_profiles::SshAuthMode; type Rect = (f32, f32, f32, f32); @@ -25,6 +25,26 @@ const KEY_ROW_H: f32 = 30.0; /// 私钥最多显示几行,超出保留尾部(最近添加的)。 const KEY_ROWS_MAX: usize = 4; +/// 测试失败的原因最多铺几行。四行装得下绝大多数错误链;再长的尾巴收进 +/// 悬浮层——但前四行必须直接可见,排障的人不该先去发现"这行字能悬浮"。 +const STATUS_ROWS_MAX: usize = 4; + +/// 身份条:头像方块的边长,以及右边名字输入框的高(设计单位,照原型)。 +const AVATAR_H: f32 = 46.0; +const IDENT_NAME_H: f32 = 30.0; +/// 名字用比正文大一档的字(原型 15px / 基准 12.5px)。这一行是整张表单里 +/// 唯一一处"标题级"的输入——它答的是"这台机器叫什么",其余都是参数。 +const IDENT_NAME_SCALE: f32 = 1.2; + +/// 图标选择器弹出层的宽度与行高(设计单位)。 +const ICON_POPUP_W: f32 = 232.0; +const ICON_ROW_H: f32 = 28.0; +/// 列表最多同时显示几个**可点的行**,再多就滚动。 +/// +/// 二十二个形状一次摊开有 600 多像素,会盖住半张表单——而这是这张表单里 +/// 最不需要改的字段。给它一屏七行的窗口,其余交给滚轮和搜索。 +const ICON_ROWS_MAX: usize = 7; + /// 这套尺寸是按 12.5px 等宽字排的,那个字号下一格的推进宽度就是这个数。 const DESIGN_CELL_W: f32 = 6.9; @@ -52,6 +72,9 @@ fn ui_scale(cell_w_logical: f32) -> f32 { #[derive(Debug, Clone, Default)] struct EditorLayout { height: f32, + /// 身份条:头像 + 名字 + 地址副行,以及它下面那条分隔线。 + ident_y: f32, + ident_rule_y: f32, /// 分组卡片外框 (y, h):连接、认证。 conn_group: (f32, f32), auth_group: (f32, f32), @@ -62,7 +85,6 @@ struct EditorLayout { dest_y: f32, helper_y: f32, port_y: f32, - label_y: f32, /// 「认证」组:方式分段器,以及随方式切换的内容。 auth_y: f32, note_y: f32, @@ -76,13 +98,14 @@ struct EditorLayout { footer_h: f32, } -/// 推导整张卡片的纵向布局。`cell_h` 是逻辑像素的 UI 行高。 +/// 推导整张卡片的纵向布局。`cell_h` 是逻辑像素的 UI 行高;`status_lines` +/// 是测试状态条要占的行数(0 = 没有状态条,失败原因折行后可能多行)。 fn editor_layout( show_password: bool, show_keys: bool, note_lines: usize, key_rows: usize, - has_teststate: bool, + status_lines: usize, cell_h: f32, ) -> EditorLayout { let caption_h = cell_h * type_scale::SECTION_CAPTION; @@ -93,6 +116,16 @@ fn editor_layout( let mut l = EditorLayout::default(); let mut y = HEAD_H + space::S; + // ── 身份条 ──────────────────────────────────────────────── + // 图标 + 名字提到最顶上,合成一张「这台机器是谁」的卡片。它俩是同一件 + // 事的两半——一个给眼睛认,一个给嘴巴念——分开塞进下面的字段表里,就都 + // 退化成了可填可不填的杂项。它下面的「连接 / 认证」则纯粹是**怎么连上 + // 去**,与身份无关,所以中间用一条分隔线断开。 + l.ident_y = y; + y += AVATAR_H; + l.ident_rule_y = y + space::S; + y = l.ident_rule_y + space::S; + // ── 连接组 ──────────────────────────────────────────────── l.conn_group.0 = y; let mut gy = y + space::S; @@ -103,8 +136,6 @@ fn editor_layout( l.helper_y = gy; gy += support_h + FIELD_GAP; l.port_y = gy; - gy += CTL_H + FIELD_GAP; - l.label_y = gy; gy += CTL_H + space::S; l.conn_group.1 = gy - y; y = gy + space::S; @@ -143,9 +174,9 @@ fn editor_layout( y = gy + space::S; // ── 底部 ───────────────────────────────────────────────── - if has_teststate { + if status_lines > 0 { l.teststate_y = Some(y); - y += space::XS * 2.0 + support_h; + y += space::XS * 2.0 + support_h * status_lines as f32; } l.footer_y = y; l.footer_h = space::S * 2.0 + CTL_H; @@ -164,31 +195,6 @@ fn spinner_phase() -> f32 { .map_or(0.0, |since| (since.as_millis() % PERIOD_MS) as f32 / PERIOD_MS as f32) } -/// 转圈的忙碌指示器。 -/// -/// 原型那个 `border-top-color` 圆环靠旋转得到,而 quad 是轴对齐的、转不了。 -/// 所以把环离散成一圈点,让亮的那一段随相位绕行——读起来仍是"一段亮弧在 -/// 转",尾迹衰减还比纯色圆环更能表达方向。 -fn push_spinner(quads: &mut Vec, cx: f32, cy: f32, radius: f32, phase: f32, accent: Rgba) { - const DOTS: usize = 8; - let dot = (radius * 0.44).max(1.0); - for index in 0..DOTS { - let at = index as f32 / DOTS as f32; - let angle = std::f32::consts::TAU * at; - // 离头部越远越淡。平方衰减让头部足够突出,尾巴不至于糊成一个静止的环。 - let behind = (at - phase).rem_euclid(1.0); - let strength = (1.0 - behind).powi(2); - quads.push(UiQuad::solid( - cx + radius * angle.cos() - dot * 0.5, - cy + radius * angle.sin() - dot * 0.5, - dot, - dot, - dot * 0.5, - Rgba::new(accent.r, accent.g, accent.b, (40.0 + strength * 200.0) as u8), - )); - } -} - impl Display { pub(super) fn draw_ssh_editor_modal(&mut self) { let progress = self.nebula_ui_anims.ssh_editor.value().clamp(0.0, 1.0); @@ -240,21 +246,43 @@ impl Display { // 组内字段的可用宽度:卡片内边距 → 分组卡片内边距 → label 左列。 let field_w = content_w - s(space::S) * 2.0; let ctl_w = field_w - s(LABEL_W) - s(space::S); - let note_cols = ((ctl_w + s(LABEL_W) + s(space::S)) - / (cell_w * type_scale::SUPPORTING)) + let note_cols = ((ctl_w + s(LABEL_W) + s(space::S)) / (cell_w * type_scale::SUPPORTING)) .floor() .max(8.0) as usize; let note_lines = wrap_status_tooltip(note_text, note_cols).len().max(1); // 私钥最多展示四行,更多条目保留尾部(最近添加项)。 let key_rows = if show_keys { editor.private_keys.len().clamp(1, KEY_ROWS_MAX) } else { 0 }; - let has_teststate = editor.test != SshTestState::Idle; + // 失败原因在这里先折好行:布局要按行数给状态条留高,绘制按行铺开, + // 两边必须拿同一份折行结果,各折各的迟早对不上。 + let status_cols = (((content_w - s(space::S)) / (cell_w * type_scale::SUPPORTING)).floor()) + .max(12.0) as usize; + let (status_wrapped, status_truncated) = match &editor.test { + SshTestState::Failed { summary } => { + let mut lines = wrap_status_tooltip(summary, status_cols); + let truncated = lines.len() > STATUS_ROWS_MAX; + if truncated { + lines.truncate(STATUS_ROWS_MAX); + if let Some(last) = lines.last_mut() { + // 末行钉上"未完"记号;超宽由 truncate 兜住。 + *last = truncate_tab_label(&format!("{last} …"), status_cols); + } + } + (lines, truncated) + }, + _ => (Vec::new(), false), + }; + let status_lines = match &editor.test { + SshTestState::Idle => 0, + SshTestState::Failed { .. } => status_wrapped.len().max(1), + _ => 1, + }; let v = editor_layout( show_password, show_keys, note_lines, key_rows, - has_teststate, + status_lines, // layout 全程用设计单位,所以行高也要换算过去。 cell_h / scale / ui, ); @@ -271,19 +299,33 @@ impl Display { let conn_group = (group_x, by + s(v.conn_group.0), content_w, s(v.conn_group.1)); let auth_group = (group_x, by + s(v.auth_group.0), content_w, s(v.auth_group.1)); - let close = - (bx + box_w - s(space::S) - s(CTL_H), by + (s(HEAD_H) - s(CTL_H)) * 0.5, s(CTL_H), s(CTL_H)); + let close = ( + bx + box_w - s(space::S) - s(CTL_H), + by + (s(HEAD_H) - s(CTL_H)) * 0.5, + s(CTL_H), + s(CTL_H), + ); let destination = (ctl_x, by + s(v.dest_y), ctl_w, field_h); let port = (ctl_x, by + s(v.port_y), s(PORT_W), field_h); - let host_label = (ctl_x, by + s(v.label_y), ctl_w, field_h); + // 身份条:[头像][名字 / 地址]。两者垂直居中对齐,头像跨着名字和它 + // 下面那行地址——地址是名字的注脚,不是另一个条目。 + let ident_x = bx + s(space::M); + let avatar = (ident_x, by + s(v.ident_y), s(AVATAR_H), s(AVATAR_H)); + let ident_text_x = avatar.0 + avatar.2 + s(space::S); + let ident_text_w = bx + box_w - s(space::M) - ident_text_x; + let host_label = (ident_text_x, avatar.1, ident_text_w, s(IDENT_NAME_H)); + let ident_sub_y = host_label.1 + host_label.3 + s(2.0); let auth_track = (ctl_x, by + s(v.auth_y), ctl_w, field_h); let auth_pad = s(2.0); let auth_w = (auth_track.2 - auth_pad * 2.0) / 4.0; + // 顺序按「人挑哪个」排,不按枚举定义排(2026-08-01 用户裁定):密码 + // 和密钥是明确的意图,放前面;自动是"都试试"的兜底,交互式最少用。 + // 默认值仍是 Auto——顺序只关排版,不改语义。 let auth_modes = [ - SshAuthMode::Auto, SshAuthMode::Password, SshAuthMode::PublicKey, + SshAuthMode::Auto, SshAuthMode::KeyboardInteractive, ]; let auth = std::array::from_fn(|index| { @@ -298,21 +340,25 @@ impl Display { ) }); let zero = (0.0, 0.0, 0.0, 0.0); - let password = if show_password { (ctl_x, by + s(v.password_y), ctl_w, field_h) } else { zero }; + let password = + if show_password { (ctl_x, by + s(v.password_y), ctl_w, field_h) } else { zero }; let password_toggle = if show_password { (password.0 + password.2 - s(30.0), password.1 + s(2.0), s(28.0), password.3 - s(4.0)) } else { zero }; - let save_label = language - .pick("保存到 Windows 凭据管理器", "Save in Windows Credential Manager"); + let save_label = + language.pick("保存到 Windows 凭据管理器", "Save in Windows Credential Manager"); let save_toggle = if show_password { (ctl_x, by + s(v.save_y), (s(24.0) + text_width(save_label)).min(ctl_w), s(26.0)) } else { zero }; - let save_checkbox = - if show_password { (save_toggle.0, save_toggle.1 + s(5.0), s(16.0), s(16.0)) } else { zero }; + let save_checkbox = if show_password { + (save_toggle.0, save_toggle.1 + s(5.0), s(16.0), s(16.0)) + } else { + zero + }; // 私钥行占满 label 右侧的整条控件列。 let key_rows_y = by + s(v.keys_y); @@ -359,12 +405,75 @@ impl Display { let test_w = s(96.0).max(text_width(test_action) + s(space::L)); let test = (bx + s(space::M), footer_y, test_w, s(CTL_H)); // 测试状态自己占一条,不再挤在按钮之间——那里放不下真实的错误原因。 + // 失败时有几行折行就有几行高,命中区(悬浮)也跟着长。 let teststate = v.teststate_y.map(|y| { - (bx + s(space::M), by + s(y) + s(space::XS), content_w, cell_h * type_scale::SUPPORTING) + ( + bx + s(space::M), + by + s(y) + s(space::XS), + content_w, + cell_h * type_scale::SUPPORTING * status_lines.max(1) as f32, + ) }); let test_status = teststate.unwrap_or(zero); let footer_top = by + s(v.footer_y); + // ── 图标选择器 ──────────────────────────────────────────── + // 默认收起。二十二个形状常驻摊开,会把"你正在编一台机器"变成"你正 + // 在挑一个贴纸"——图标是这张表单里最不需要改的字段,它本来就有值。 + let zh = language == super::UiLanguage::ZhCn; + let picker_rows = if editor.icon_picker { + super::ui::os_icons::picker_rows(&editor.icon_filter, zh) + } else { + Vec::new() + }; + let icon_group_h = s(20.0); + let icon_row_h = s(ICON_ROW_H); + let list_budget = s(ICON_ROWS_MAX as f32 * ICON_ROW_H); + let (icon_max_scroll, visible_icon_rows) = icon_list_window( + &picker_rows, + editor.icon_scroll, + list_budget, + icon_group_h, + icon_row_h, + ); + let mut list_h = visible_icon_rows.iter().map(|(_, _, h)| h).sum::(); + // 一条也不剩时列表不能是零高——那样浮层会塌成一条缝,看起来像坏了。 + // 留一行的位置写「没有匹配的图标」。 + let icon_empty = editor.icon_picker && picker_rows.is_empty(); + if icon_empty { + list_h = icon_row_h; + } + let popup_pad = s(space::XXS); + let search_h = s(26.0); + let (icon_popup, icon_search, icon_rows) = if editor.icon_picker { + let w = s(ICON_POPUP_W).min(box_w - s(space::M) * 2.0); + let h = popup_pad * 2.0 + search_h + s(space::XXS) + list_h; + let x = avatar.0.min(bx + box_w - s(space::M) - w); + // 下面放不下就翻到头像**上方**:浮层宁可换边也不该滑出卡片, + // 半截露在终端上的列表既点不全也读不出属于谁。 + let below = avatar.1 + avatar.3 + s(space::XXS); + let y = if below + h > stage.1 + stage.3 - s(space::S) { + (avatar.1 - s(space::XXS) - h).max(stage.1 + s(space::S)) + } else { + below + }; + let popup = (x, y, w, h); + let search = (x + popup_pad, y + popup_pad, w - popup_pad * 2.0, search_h); + let list_top = search.1 + search.3 + s(space::XXS); + let rows = visible_icon_rows + .iter() + .filter_map(|(row, offset, h)| match row { + super::ui::os_icons::PickerRow::Option(pick) => { + Some((*pick, (search.0, list_top + offset, search.2, *h))) + }, + super::ui::os_icons::PickerRow::Group(_) => None, + }) + .collect::>(); + (popup, search, rows) + } else { + (zero, zero, Vec::new()) + }; + // 文字起点:绘制和命中共用这一份。端口居中,其余靠左;端口空着时起点 // 落在框正中,于是光标停在中央而不是贴着左边等着。 let pad = s(space::XS); @@ -375,6 +484,11 @@ impl Display { label_x: host_label.0 + pad, password_x: password.0 + pad, cell_w, + // 名字按 1.2× 真栅格化,绘制的步进是**未取整** UI advance 的 + // 1.2 倍;cell_w 是 floor 过的,拿它乘列宽每个字差零点几像素, + // 点到第十个字光标就漂进邻格了。 + label_cell_w: self.glyph_cache.ui_font_metrics().average_advance as f32 + * IDENT_NAME_SCALE, }; self.nebula_ssh_editor_rects = Some(SshEditorRects { @@ -396,31 +510,39 @@ impl Display { test_status, primary, cancel, + avatar, + icon_popup, + icon_search, + icon_search_text_x: icon_search.0 + pad, + icon_search_cell_w: cell_w * type_scale::SUPPORTING, + icon_rows: icon_rows.clone(), + icon_max_scroll, metrics, }); - let status_tooltip = if self.nebula_ssh_editor_hover == SshEditorHit::TestStatus { - match &editor.test { - SshTestState::Failed { summary } => { - let max_cols = (((field_w - s(24.0)) / cell_w).floor() as usize).max(12); - let lines = wrap_status_tooltip(summary, max_cols); - let widest = lines - .iter() - .map(|line| line.chars().map(|c| c.width().unwrap_or(0)).sum::()) - .max() - .unwrap_or(0); - let line_h = cell_h + s(3.0); - let width = (widest as f32 * cell_w + s(24.0)).clamp(s(180.0), field_w); - let height = lines.len() as f32 * line_h + s(16.0); - let x = bx + box_w - s(space::M) - width; - let y = (footer_top - height - s(8.0)).max(by + s(10.0)); - Some(((x, y, width, height), lines, line_h)) - }, - _ => None, - } - } else { - None - }; + let status_tooltip = + if status_truncated && self.nebula_ssh_editor_hover == SshEditorHit::TestStatus { + match &editor.test { + SshTestState::Failed { summary } => { + let max_cols = (((field_w - s(24.0)) / cell_w).floor() as usize).max(12); + let lines = wrap_status_tooltip(summary, max_cols); + let widest = lines + .iter() + .map(|line| line.chars().map(|c| c.width().unwrap_or(0)).sum::()) + .max() + .unwrap_or(0); + let line_h = cell_h + s(3.0); + let width = (widest as f32 * cell_w + s(24.0)).clamp(s(180.0), field_w); + let height = lines.len() as f32 * line_h + s(16.0); + let x = bx + box_w - s(space::M) - width; + let y = (footer_top - height - s(8.0)).max(by + s(10.0)); + Some(((x, y, width, height), lines, line_h)) + }, + _ => None, + } + } else { + None + }; // SSH 主机编辑器是 Modal:遮罩、外阴影、同心描边、圆角全部走共享 // 配方。此前这里的遮罩是写死的 `Rgba::new(0, 0, 0, 170)`——不读 @@ -491,15 +613,96 @@ impl Display { &skin, scale, ); - input_quads( - &mut quads, - host_label, - editor.field == SshEditorField::Label, - self.nebula_ssh_editor_hover == SshEditorHit::Label, - accent, - &skin, - scale, - ); + // 身份条的名字框:默认**没有框**,只有字。它是这张卡片的标题,一上 + // 来就画一圈边会让整块顶部变成"又一个表单行";悬浮时给一条发丝提示 + // 可点,真编辑时才落下输入框的底和强调边。 + { + let focused = editor.field == SshEditorField::Label; + if focused { + super::ui::surface::push_stroke( + &mut quads, + host_label, + s(radius::CONTROL), + scale, + Rgba::new(accent.r, accent.g, accent.b, if skin.is_light { 118 } else { 136 }), + ); + quads.push(UiQuad::solid( + host_label.0, + host_label.1, + host_label.2, + host_label.3, + s(radius::CONTROL), + skin.input, + )); + } else if self.nebula_ssh_editor_hover == SshEditorHit::Label { + super::ui::surface::push_stroke( + &mut quads, + host_label, + s(radius::CONTROL), + scale, + skin.hairline, + ); + // 描边是**实心矩形**,得有东西盖住中心才成环。这里要的是 + // 透明底,所以拿它和面板色合成出的那个不透明色去盖。 + quads.push(UiQuad::solid( + host_label.0, + host_label.1, + host_label.2, + host_label.3, + s(radius::CONTROL), + Rgba::new(skin.panel.r, skin.panel.g, skin.panel.b, 255), + )); + } + } + // 头像沿用输入框的皮:它和右边的名字是同一条身份里的两半,长得像 + // 输入框才读得出"这也是你能改的"。开着列表时按聚焦态描边——弹层是 + // 从它身上长出来的,得看得出源头。 + // + // 圆角走**浮层**那一档而不是控件档:46px 的方块用 6px 圆角会显得 + // 生硬,原型这里也是 r-overlay。 + { + let open = editor.icon_picker; + let hovered = self.nebula_ssh_editor_hover == SshEditorHit::Avatar; + super::ui::surface::push_stroke( + &mut quads, + avatar, + s(radius::OVERLAY), + scale, + if open || hovered { + Rgba::new(accent.r, accent.g, accent.b, if skin.is_light { 118 } else { 136 }) + } else { + skin.hairline + }, + ); + quads.push(UiQuad::solid( + avatar.0, + avatar.1, + avatar.2, + avatar.3, + s(radius::OVERLAY), + skin.input, + )); + if hovered && !open { + quads.push(UiQuad::solid( + avatar.0, + avatar.1, + avatar.2, + avatar.3, + s(radius::OVERLAY), + skin.hover, + )); + } + } + // 身份条与下面那两组之间的分隔线:上面答的是"这是谁",下面答的是 + // "怎么连上去",两件事。 + quads.push(UiQuad::solid( + bx + s(space::M), + by + s(v.ident_rule_y), + content_w, + s(control::HAIRLINE), + 0.0, + hairline, + )); if show_password { input_quads( &mut quads, @@ -648,7 +851,19 @@ impl Display { if let Some(color) = status_dot { quads.push(UiQuad::solid(bar.0, cy - s(3.0), s(6.0), s(6.0), s(3.0), color)); } else if matches!(editor.test, SshTestState::Running { .. }) { - push_spinner(&mut quads, bar.0 + s(5.5), cy, s(5.5), spinner_phase(), accent); + // 和侧栏标签行同一个组件(`ui::icons::push_spinner`):这里原来 + // 有一份 8 点的私货,点少到能数出珠子。轨道用 hairline、亮弧用 + // accent——"正在连接"是这张表单里唯一在动的东西,值得用品牌色。 + super::ui::icons::push_spinner( + &mut quads, + bar.0 + s(5.5), + cy, + s(5.5), + spinner_phase(), + skin.hairline, + accent, + Rgba::new(skin.panel.r, skin.panel.g, skin.panel.b, 255), + ); } } @@ -667,8 +882,9 @@ impl Display { SshEditorField::Label => host_label, SshEditorField::Password => password, }; - // 光标、选区、命中三者共用 metrics 的起点与组件层的列换算,所以 - // 点在哪一格,光标就落在哪一格。 + // 光标、选区、命中三者共用 metrics 的起点与**同一份列宽**,所以 + // 点在哪一格,光标就落在哪一格——名字那一格比别处宽一档,这里跟 + // 着字段取,不能图省事全用 `cell_w`。 super::ui::text_field::push_cursor( &mut quads, caret_rect.1, @@ -676,28 +892,14 @@ impl Display { metrics.origin(caret_field), &editor.display_text(caret_field), editor.field_view(caret_field).1, - cell_w, + metrics.cell_w_of(caret_field), scale, &skin, ); } - if let Some((tooltip, ..)) = &status_tooltip { - super::ui::surface::push_stroke( - &mut quads, - *tooltip, - s(radius::CONTROL), - scale, - skin.hairline, - ); - quads.push(UiQuad::solid( - tooltip.0, - tooltip.1, - tooltip.2, - tooltip.3, - s(radius::CONTROL), - skin.panel, - )); - } + // tooltip 与图标选择器弹层不在这一批里:主批 quad 整体沉在表单 + // 文字之下,浮层的底混在这里就压不住字,表单文字会从弹层里透出 + // 来。它们在表单文字画完后作为 overlay 批另行提交(见下)。 self.renderer.draw_ui(&size, &quads); let glyph_cache = &mut self.glyph_cache; @@ -737,9 +939,16 @@ impl Display { ); // ── 分组标题 ─────────────────────────────────────────────── + // + // 两个都换成 codicon 的**空心**字形(原来是 Font Awesome 的实心机架 + // 和实心钥匙)。实心图标在这个尺寸上是一块墨,形状全靠外轮廓;空心 + // 的把内部结构留出来,读得出"这是机架的那几层""这是钥匙的那个齿", + // 而且墨量小得多——分组标题本来就该轻,一块实心墨会把它顶到比标题 + // 文字还重。整个 chrome 的图标语言也统一在 codicon 这一套上(齿轮、 + // 关闭、图钉早就是它)。 for (head_y, icon, text) in [ - (v.conn_head_y, "\u{f233}", language.pick("连接", "Connection")), - (v.auth_head_y, "\u{f084}", language.pick("认证", "Authentication")), + (v.conn_head_y, "\u{eb50}", language.pick("连接", "Connection")), + (v.auth_head_y, "\u{eb11}", language.pick("认证", "Authentication")), ] { // 图标和标题同色同层:它是标题的一部分,抢眼了反而把"这一组叫 // 什么"推到第二位。 @@ -811,24 +1020,16 @@ impl Display { glyph_cache, ); - for (row_y, text, rect, value, placeholder, field) in [ - ( - v.port_y, - language.pick("端口", "Port"), - port, - &editor.port, - "22", - SshEditorField::Port, - ), - ( - v.label_y, - language.pick("标签", "Label"), - host_label, - &editor.label, - language.pick("可选,列表里显示这个名字", "Optional, shown in the list"), - SshEditorField::Label, - ), - ] { + // 端口只剩它自己一行了(名字已经提到身份条),但循环留着:把"画 + // label + 画内容/占位"这套规则写两遍,正是两个框慢慢长歪的开头。 + for (row_y, text, rect, value, placeholder, field) in [( + v.port_y, + language.pick("端口", "Port"), + port, + &editor.port, + "22", + SshEditorField::Port, + )] { self.renderer.draw_ui_text( &size, field_x, @@ -884,9 +1085,9 @@ impl Display { glyph_cache, ); let auth_labels = if language == super::UiLanguage::ZhCn { - ["自动", "密码", "密钥", "交互式"] + ["密码", "密钥", "自动", "交互式"] } else { - ["Auto", "Password", "Key", "Interactive"] + ["Password", "Key", "Auto", "Interactive"] }; for ((mode, rect), label) in auth.iter().zip(auth_labels) { self.renderer.draw_chrome_text( @@ -1036,48 +1237,163 @@ impl Display { ); } - let (status, status_ink, has_dot) = match &editor.test { - SshTestState::Idle => (None, skin.ink_faint, false), + let (status_rows, status_ink, has_dot) = match &editor.test { + SshTestState::Idle => (Vec::new(), skin.ink_faint, false), SshTestState::Running { .. } => ( - Some(language.pick("正在连接…", "Connecting...").to_owned()), + vec![language.pick("正在连接…", "Connecting...").to_owned()], skin.ink_faint, // 转圈指示器和成功/失败的圆点占同一格,文字的左缩进因此一致 // ——不然状态在三态之间切换时,文字会横着跳一下。 true, ), SshTestState::Ok { elapsed_ms } => ( - Some(format!( - "{} · {elapsed_ms}ms", - language.pick("连接成功", "Connected") - )), + vec![format!("{} · {elapsed_ms}ms", language.pick("连接成功", "Connected"))], if skin.is_light { Rgb::new(26, 127, 55) } else { Rgb::new(63, 185, 80) }, true, ), - SshTestState::Failed { summary } => ( - Some(summary.replace(['\r', '\n'], " ")), + // 失败原因逐行铺开。此前这里把换行压成空格、截成一行,完整原因 + // 只活在悬浮层里——排障的人第一眼就该读到全文,而不是先发现 + // "这行字原来能悬浮"。 + SshTestState::Failed { .. } => ( + status_wrapped, if skin.is_light { Rgb::new(207, 34, 46) } else { Rgb::new(248, 81, 73) }, true, ), }; - if let (Some(status), Some(bar)) = (status, teststate) { - // 状态条独占一行,失败原因终于有地方完整显示,不再被挤在两个 - // 按钮之间截断成一句读不懂的半句话。 + if let Some(bar) = teststate { + // 状态条有几行,失败原因就铺几行;圆点缩进对齐每一行的左沿。 let dot_w = if has_dot { s(space::S) } else { 0.0 }; let max_cols = (((bar.2 - dot_w) / cell_w).floor() as isize).max(0); if max_cols > 0 { - let shown = truncate_tab_label(&status, max_cols as usize); - self.renderer.draw_ui_text( - &size, - bar.0 + dot_w, - bar.1, - support, - status_ink, - Flags::empty(), - &shown, - glyph_cache, - ); + for (row, text) in status_rows.iter().enumerate() { + let shown = truncate_tab_label(text, max_cols as usize); + self.renderer.draw_ui_text( + &size, + bar.0 + dot_w, + bar.1 + row as f32 * cell_h * support, + support, + status_ink, + Flags::empty(), + &shown, + glyph_cache, + ); + } } } + + // ── 身份条 ─────────────────────────────────────────────── + // 名字:整张表单里唯一一处标题级的输入。空着时给一句招呼而不是 + // 「可选」——这一行的意思是"给它起个名字",不是"这里还有个字段"。 + let name_empty = editor.label.is_empty(); + // 真栅格化(draw_ui_text 按 1.2× 字号重新出字形),不做位图拉伸—— + // `draw_chrome_text_scaled` 是把终端字号的图集位图硬放大,名字作为 + // 整张卡最大的一行字,糊得最扎眼。原型这行就是真 15px。 + self.renderer.draw_ui_text( + &size, + metrics.label_x, + host_label.1 + (host_label.3 - cell_h * IDENT_NAME_SCALE) * 0.5, + IDENT_NAME_SCALE, + if name_empty { skin.ink_faint } else { skin.ink }, + Flags::empty(), + if name_empty { + language.pick("给这台机器起个名字", "Name this machine") + } else { + editor.label.as_str() + }, + glyph_cache, + ); + // 地址副行:名字的注脚,跟着上面的地址框实时变。它是等宽的——地址 + // 是机器读的东西,用等宽排能一眼看出点分十进制的对齐。 + let ident_sub = if editor.destination.is_empty() { + language.pick("未填地址", "No address yet").to_owned() + } else { + ssh_ui::join_destination_port(&editor.destination, &editor.port) + }; + let sub_cols = ((ident_text_w - s(space::XS)) / (cell_w * support)).floor().max(4.0); + self.renderer.draw_ui_text( + &size, + metrics.label_x, + ident_sub_y, + support, + skin.ink_faint, + Flags::empty(), + &truncate_tab_label(&ident_sub, sub_cols as usize), + glyph_cache, + ); + + // ── 浮层批:tooltip 与图标选择器弹层 ───────────────────── + // quad 与文字分两阶段提交,主批 quad 整体沉在表单文字之下。浮层 + // 的底必须反过来压住表单文字,所以在文字画完后另起一批提交;浮 + // 层自己的文字再叠在这批之上。 + let mut overlay_quads: Vec = Vec::new(); + if let Some((tooltip, ..)) = &status_tooltip { + super::ui::surface::push_stroke( + &mut overlay_quads, + *tooltip, + s(radius::CONTROL), + scale, + skin.hairline, + ); + overlay_quads.push(UiQuad::solid( + tooltip.0, + tooltip.1, + tooltip.2, + tooltip.3, + s(radius::CONTROL), + skin.panel, + )); + } + if editor.icon_picker { + // 和模态卡同一个 `panel` 底,靠 hairline + 外阴影分层——原型如此。 + // 换个更亮的底色也能分开,但那样界面里就多出一档只在这里出现的 + // 颜色;层级本来就该由阴影表达。 + super::ui::surface::push_surface_in( + &mut overlay_quads, + icon_popup, + stage, + stage_radius, + scale, + &skin, + super::ui::surface::Elevation::Menu, + 1.0, + ); + // 搜索框:正经输入框——弹层开着它就是聚焦的(键盘归它),按 + // 聚焦态描边;光标与选区走组件层,和表单字段同一套节律。 + input_quads(&mut overlay_quads, icon_search, true, false, accent, &skin, scale); + super::ui::text_field::push_cursor( + &mut overlay_quads, + icon_search.1, + icon_search.3, + icon_search.0 + s(space::XS), + &editor.icon_filter, + &editor.icon_filter_cursor, + cell_w * support, + scale, + &skin, + ); + for (pick, rect) in &icon_rows { + let picked = match pick { + Some(index) => super::ui::os_icons::CATALOG[*index].id == editor.icon, + // 「自动识别」= 没存过 id。空串和显式的 "auto" 都算它, + // 这样手改过配置文件的人也能看到自己那一项是选中的。 + None => editor.icon.is_empty() || editor.icon == super::ui::os_icons::AUTO_ID, + }; + let hovered = self.nebula_ssh_editor_hover == SshEditorHit::IconOption(*pick); + if picked || hovered { + overlay_quads.push(UiQuad::solid( + rect.0, + rect.1, + rect.2, + rect.3, + s(radius::CHIP), + if picked { skin.accent_soft } else { skin.hover }, + )); + } + } + } + if !overlay_quads.is_empty() { + self.renderer.draw_ui(&size, &overlay_quads); + } if let Some((tooltip, lines, line_h)) = status_tooltip { for (line, text) in lines.iter().enumerate() { self.renderer.draw_chrome_text( @@ -1091,6 +1407,153 @@ impl Display { } } + // ── 头像里的形状 ───────────────────────────────────────── + // 「自动识别」在真连上之前先用通用终端的形状顶着:头像不能是空的, + // 一个空框会让人以为图标丢了。 + let picked_icon = super::ui::os_icons::resolve(Some(editor.icon.as_str())); + let avatar_px = avatar.3 * 0.46; + let avatar_mult = super::ui::os_icons::scale_for(picked_icon, cell_w, avatar_px); + // 同名字:真栅格化。图标要放大到两倍上下,拉伸版在 46px 方块里 + // 糊成一团墨渍(原型 `.nf` 是真 24px)。 + self.renderer.draw_ui_text( + &size, + avatar.0 + (avatar.2 - avatar_px) * 0.5, + avatar.1 + (avatar.3 - cell_h * avatar_mult) * 0.5, + avatar_mult, + if editor.icon_picker { skin.ink } else { skin.ink_dim }, + Flags::empty(), + picked_icon.glyph.encode_utf8(&mut [0u8; 4]), + glyph_cache, + ); + + // ── 选择器弹层的文字 ───────────────────────────────────── + if editor.icon_picker { + let filter_shown = !editor.icon_filter.is_empty(); + self.renderer.draw_ui_text( + &size, + icon_search.0 + s(space::XS), + icon_search.1 + (icon_search.3 - cell_h * support) * 0.5, + support, + if filter_shown { skin.ink } else { skin.ink_faint }, + Flags::empty(), + if filter_shown { + editor.icon_filter.as_str() + } else { + language.pick("搜索图标…(直接打字)", "Search icons… (just type)") + }, + glyph_cache, + ); + let caption = type_scale::SECTION_CAPTION; + let list_top = icon_search.1 + icon_search.3 + s(space::XXS); + for (row, offset, h) in &visible_icon_rows { + let y = list_top + offset; + match row { + super::ui::os_icons::PickerRow::Group(title) => { + self.renderer.draw_ui_text_tracked( + &size, + icon_search.0 + s(space::XS), + y + (h - cell_h * caption) * 0.5, + caption, + s(0.65), + skin.ink_faint, + Flags::empty(), + title, + glyph_cache, + ); + }, + super::ui::os_icons::PickerRow::Option(pick) => { + let picked = match pick { + Some(index) => super::ui::os_icons::CATALOG[*index].id == editor.icon, + None => { + editor.icon.is_empty() + || editor.icon == super::ui::os_icons::AUTO_ID + }, + }; + let ink = if picked { skin.ink_strong } else { skin.ink_dim }; + // 图标列和名字列的节奏跟侧栏主机行一致:图标有自己的 + // 槽,槽后固定气口再排字。两处对不齐的话,"我在列表里 + // 挑的形状"和"侧栏里长出来的形状"就读不成同一个东西。 + let slot = h * 0.62; + let glyph_x = icon_search.0 + s(space::XS); + let text_x = glyph_x + slot + s(space::XS); + match pick { + Some(index) => { + let icon = &super::ui::os_icons::CATALOG[*index]; + let mult = + super::ui::os_icons::scale_for(icon, cell_w, slot * 0.86); + // 列表里的图标同样走真栅格化,跟头像出自 + // 同一张脸——挑的时候看的和挑完看到的必须 + // 是同一个清晰度。 + self.renderer.draw_ui_text( + &size, + glyph_x + slot * 0.07, + y + (h - cell_h * mult) * 0.5, + mult, + ink, + Flags::empty(), + icon.glyph.encode_utf8(&mut [0u8; 4]), + glyph_cache, + ); + self.renderer.draw_ui_text( + &size, + text_x, + y + (h - cell_h * support) * 0.5, + support, + ink, + Flags::empty(), + if zh { icon.zh } else { icon.en }, + glyph_cache, + ); + // 码位贴右缘。这是给"照着配置文件找过来"的 + // 人留的锚点,所以用最淡的一档——它是注解, + // 不参与挑选。 + let cp = format!("U+{:X}", icon.glyph as u32); + let cp_w = cp.chars().count() as f32 + * self.renderer.ui_text_advance(glyph_cache, caption); + self.renderer.draw_ui_text( + &size, + icon_search.0 + icon_search.2 - s(space::XS) - cp_w, + y + (h - cell_h * caption) * 0.5, + caption, + skin.ink_faint, + Flags::empty(), + &cp, + glyph_cache, + ); + }, + None => { + self.renderer.draw_ui_text( + &size, + text_x, + y + (h - cell_h * support) * 0.5, + support, + ink, + Flags::empty(), + language.pick( + "自动识别(连上后按系统认)", + "Automatic (detect on first connect)", + ), + glyph_cache, + ); + }, + } + }, + } + } + if icon_empty { + self.renderer.draw_ui_text( + &size, + icon_search.0 + s(space::XS), + list_top + (icon_row_h - cell_h * support) * 0.5, + support, + skin.ink_faint, + Flags::empty(), + language.pick("没有匹配的图标", "No matching icon"), + glyph_cache, + ); + } + } + if self.nebula_ui_anims.ssh_editor.animating_to(if self.nebula_ssh_editor_open { 1.0 } else { @@ -1132,9 +1595,77 @@ fn wrap_status_tooltip(value: &str, budget: usize) -> Vec { #[cfg(test)] mod tests { - use super::{CTL_H, DESIGN_CELL_W, editor_layout, ui_scale, wrap_status_tooltip}; + use super::{ + AVATAR_H, CTL_H, DESIGN_CELL_W, editor_layout, icon_list_window, type_scale, ui_scale, + wrap_status_tooltip, + }; + use crate::display::ui::os_icons::{PickerRow, picker_rows}; use unicode_width::UnicodeWidthChar; + /// 失败原因折成几行,状态条就长几行高——错误全文必须直接可见,压成 + /// 单行等于把原因藏回悬浮层。 + #[test] + fn the_test_status_bar_grows_with_the_wrapped_error() { + let single = editor_layout(true, false, 1, 0, 1, 15.0); + let triple = editor_layout(true, false, 1, 0, 3, 15.0); + let support_h = 15.0 * type_scale::SUPPORTING; + assert!( + (triple.height - single.height - support_h * 2.0).abs() < 0.01, + "三行状态该比一行高出恰好两行,实际差 {}", + triple.height - single.height + ); + // 状态条从同一处起笔:长的是它自己,顺带把页脚推下去。 + assert_eq!(single.teststate_y, triple.teststate_y); + assert!(triple.footer_y > single.footer_y); + // 没有状态时整条不存在,不占一行空白。 + assert!(editor_layout(true, false, 1, 0, 0, 15.0).teststate_y.is_none()); + } + + /// 列表的窗口按**高度**开,不按行数——标题矮一档,按行数除会算错。 + #[test] + fn the_icon_list_window_never_overruns_its_height_budget() { + let rows = picker_rows("", true); + let (_, visible) = icon_list_window(&rows, 0, 196.0, 20.0, 28.0); + let used: f32 = visible.iter().map(|(_, _, h)| h).sum(); + assert!(used <= 196.0, "窗口用了 {used}px,预算只有 196px"); + assert!(visible.len() >= 7, "196px 至少能放下七行,实际只有 {}", visible.len()); + // 偏移必须首尾相接:中间空一档会被读成"这里有一行没画出来"。 + let mut expected = 0.0; + for (_, offset, h) in &visible { + assert!((offset - expected).abs() < 0.01); + expected += h; + } + } + + /// 滚到底时最后一行正好贴着下沿。按 `len - 可见行数` 算会多滚出一段空 + /// 白,看起来像下面还有东西却怎么也滚不出来。 + #[test] + fn scrolling_to_the_bottom_lands_flush_on_the_last_row() { + let rows = picker_rows("", true); + let (max_scroll, _) = icon_list_window(&rows, 0, 196.0, 20.0, 28.0); + assert!(max_scroll > 0, "二十多个图标塞不进 196px,理应可滚"); + + let (_, bottom) = icon_list_window(&rows, max_scroll, 196.0, 20.0, 28.0); + assert_eq!( + bottom.last().map(|(row, ..)| *row), + rows.last().copied(), + "滚到底该看见最后一行" + ); + // 再往下滚也是同一屏——夹取在窗口里做掉,输入侧不必自己算上限。 + let (_, past_end) = icon_list_window(&rows, max_scroll + 5, 196.0, 20.0, 28.0); + assert_eq!(past_end.len(), bottom.len()); + } + + /// 全放得下时不该报出可滚——一个滚不动的滚动条比没有更让人困惑。 + #[test] + fn a_short_list_reports_no_scroll() { + let rows = picker_rows("ubuntu", true); + let (max_scroll, visible) = icon_list_window(&rows, 0, 196.0, 20.0, 28.0); + assert_eq!(max_scroll, 0); + assert_eq!(visible.len(), rows.len()); + assert!(matches!(visible[0].0, PickerRow::Group(_))); + } + #[test] fn panel_grows_with_the_font_so_padding_survives() { // 字号正好是设计稿的基准:一比一,不放大。 @@ -1154,18 +1685,24 @@ mod tests { let source = "认证失败:私钥路径 C:/用户/密钥/id_ed25519 不可用"; let lines = wrap_status_tooltip(source, 12); assert_eq!(lines.concat(), source); - assert!(lines.iter().all(|line| { - line.chars().map(|ch| ch.width().unwrap_or(0)).sum::() <= 12 - })); + assert!( + lines.iter().all(|line| { + line.chars().map(|ch| ch.width().unwrap_or(0)).sum::() <= 12 + }) + ); } #[test] fn editor_layout_stacks_fields_without_overlap() { - let l = editor_layout(true, true, 1, 2, true, 15.0); + let l = editor_layout(true, true, 1, 2, 1, 15.0); + // 身份条在最上面,它下面那条分隔线把"这是谁"和"怎么连"隔开,连接 + // 组从线以下开始。 + assert!(l.ident_y + AVATAR_H <= l.ident_rule_y); + assert!(l.ident_rule_y < l.conn_group.0); // 连接组的三行按视觉顺序推进,helper 夹在地址和端口之间。 assert!(l.dest_y + CTL_H <= l.helper_y); assert!(l.helper_y < l.port_y); - assert!(l.port_y + CTL_H <= l.label_y); + assert!(l.port_y + CTL_H <= l.conn_group.0 + l.conn_group.1); // 两个分组不重叠,且认证组在连接组下面。 assert!(l.conn_group.0 + l.conn_group.1 <= l.auth_group.0); // 认证组内部的字段都落在组框里。 @@ -1181,25 +1718,72 @@ mod tests { #[test] fn editor_layout_shrinks_when_the_auth_mode_needs_no_fields() { - let full = editor_layout(true, true, 1, 4, false, 15.0); + let full = editor_layout(true, true, 1, 4, 0, 15.0); // keyboard-interactive:没有密码框也没有私钥列表,只剩一句说明。 - let bare = editor_layout(false, false, 1, 0, false, 15.0); + let bare = editor_layout(false, false, 1, 0, 0, 15.0); assert!(bare.height < full.height); // 说明文案折成两行时,认证组要跟着长高——否则文字会顶穿组框下缘。 - let two_lines = editor_layout(false, false, 2, 0, false, 15.0); + let two_lines = editor_layout(false, false, 2, 0, 0, 15.0); assert!(two_lines.auth_group.1 > bare.auth_group.1); - assert!(two_lines.note_y + 15.0 * 2.0 * 0.8 <= two_lines.auth_group.0 + two_lines.auth_group.1); + assert!( + two_lines.note_y + 15.0 * 2.0 * 0.8 <= two_lines.auth_group.0 + two_lines.auth_group.1 + ); } #[test] fn editor_layout_grows_with_the_private_key_list() { - let one = editor_layout(false, true, 1, 1, false, 15.0); - let four = editor_layout(false, true, 1, 4, false, 15.0); + let one = editor_layout(false, true, 1, 1, 0, 15.0); + let four = editor_layout(false, true, 1, 4, 0, 15.0); assert!(four.height > one.height); assert!(four.add_key_y > one.add_key_y); } } +/// 选择器列表的可视窗口:`(最大滚动量, [(行, 行内偏移, 行高)])`。 +/// +/// 分组标题比选项矮一档,所以窗口只能按**高度**算,不能按行数除。 +/// +/// 最大滚动量从**末尾**倒推:滚到底时最后一行正好贴着列表下沿。按 +/// `len - 可见行数` 算是不对的——那是拿"从顶上看能显示几行"去推"从底下 +/// 数该留几行",两头行高不一样时会多滚出一段空白,看起来像列表下面还有 +/// 东西却怎么也滚不出来。 +fn icon_list_window( + rows: &[super::ui::os_icons::PickerRow], + scroll: usize, + budget: f32, + group_h: f32, + option_h: f32, +) -> (usize, Vec<(super::ui::os_icons::PickerRow, f32, f32)>) { + use super::ui::os_icons::PickerRow; + let height = |row: &PickerRow| match row { + // 分组标题比选项矮:它是路牌不是选项,占同样的高度会让人去点它。 + PickerRow::Group(_) => group_h, + PickerRow::Option(_) => option_h, + }; + + let mut max_scroll = rows.len(); + let mut tail = 0.0; + for (index, row) in rows.iter().enumerate().rev() { + tail += height(row); + if tail > budget { + break; + } + max_scroll = index; + } + + let mut visible = Vec::new(); + let mut used = 0.0; + for row in rows.iter().skip(scroll.min(max_scroll)) { + let h = height(row); + if used + h > budget { + break; + } + visible.push((*row, used, h)); + used += h; + } + (max_scroll, visible) +} + fn input_quads( quads: &mut Vec, rect: Rect, diff --git a/nebula_app/src/display/ssh_ui.rs b/nebula_app/src/display/ssh_ui.rs index cb9f78a..057792b 100644 --- a/nebula_app/src/display/ssh_ui.rs +++ b/nebula_app/src/display/ssh_ui.rs @@ -129,6 +129,16 @@ pub enum SshEditorField { pub enum SshEditorHit { None, Close, + /// 身份条头像:点击开合图标选择列表。 + Avatar, + /// 弹出列表里的一项:`None` = 「自动识别」,`Some(i)` = `CATALOG[i]`。 + IconOption(Option), + /// 弹层顶部的搜索框。它是一个正经输入框:点击定位光标、拖选、复制 + /// 粘贴,一样不缺。 + IconSearch, + /// 弹出列表里**不可点**的地方(标题、内边距)。它存在只为了 + /// 吞掉点击:浮层盖住的位置不该穿透到下面的字段上。 + IconPopupChrome, Destination, Port, Label, @@ -145,6 +155,14 @@ pub enum SshEditorHit { Cancel, } +/// 一段进行中的拖选属于谁——表单字段,或弹层里的搜索框。两者的选区模型 +/// 是同一个组件(`TextCursor`),只是值存的地方不同。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SshEditorDrag { + Field(SshEditorField), + IconSearch, +} + /// 页脚「测试连接」的四态状态小字(稿一)。结果只对发起时的草稿有效—— /// 任何字段一改就回 [`Idle`],绝不让旧结果背书新配置。 #[derive(Debug, Clone, Default, PartialEq, Eq)] @@ -175,6 +193,17 @@ pub struct SshHostEditor { pub save_password: bool, pub show_password: bool, pub auth: SshAuthMode, + /// 图标 id(`ui::os_icons`)。空串 = 自动识别。 + pub icon: String, + /// 头像的弹出选择列表是否展开。 + pub icon_picker: bool, + /// 选择器的搜索词。列表开着的时候键盘归它,敲进来的字落在这里。 + pub icon_filter: String, + /// 搜索词的光标。搜索框是正经输入框:插入落在光标处、有选区、能复制 + /// 粘贴——节律与表单字段共用组件层的同一套(`TextCursor`)。 + pub(super) icon_filter_cursor: TextCursor, + /// 选择器列表滚过去的行数。 + pub icon_scroll: usize, pub private_keys: Vec, pub field: SshEditorField, pub focus: crate::ux::FocusIndex, @@ -261,6 +290,9 @@ pub struct SshFieldMetrics { pub password_x: f32, /// 一列的推进宽度(物理像素)。 pub cell_w: f32, + /// 名字那一格的列宽。身份条上的名字比其余字段大一档,光标定位、拖选、 + /// 光标绘制全都按列算——这里少给一份,点在第 8 个字上会落到第 6 个。 + pub label_cell_w: f32, } impl SshFieldMetrics { @@ -272,6 +304,14 @@ impl SshFieldMetrics { SshEditorField::Password => self.password_x, } } + + /// 该字段一列有多宽。 + pub fn cell_w_of(&self, field: SshEditorField) -> f32 { + match field { + SshEditorField::Label => self.label_cell_w, + _ => self.cell_w, + } + } } #[derive(Debug, Clone)] @@ -291,6 +331,24 @@ pub struct SshEditorRects { pub test_status: (f32, f32, f32, f32), pub primary: (f32, f32, f32, f32), pub cancel: (f32, f32, f32, f32), + /// 身份头像(点它开合图标选择器)。 + pub avatar: (f32, f32, f32, f32), + /// 弹出选择器的整块外框。列表收起时是零矩形。 + /// + /// 它同时是**吞点击的挡板**:落在这块里但没命中任何一行的点(标题、 + /// 搜索框、边距)不能穿透过去点到下面的字段——浮层盖住的地方就不再属于 + /// 下面那一层了。 + pub icon_popup: (f32, f32, f32, f32), + /// 弹层顶部的搜索框,以及它的文字起点与列宽——点击换算光标位置时, + /// 输入侧必须拿绘制侧同一份换算,否则点在哪一格光标落不到哪一格。 + pub icon_search: (f32, f32, f32, f32), + pub icon_search_text_x: f32, + pub icon_search_cell_w: f32, + /// 可点的选项行:`None` = 「自动识别」,`Some(i)` = `CATALOG[i]`。 + pub icon_rows: Vec<(Option, (f32, f32, f32, f32))>, + /// 列表最多能滚到第几行。滚动量由输入侧改、由布局侧算——上限只有排完版 + /// 才知道(行高不等,还随搜索词变),所以从这里回传给输入侧夹取。 + pub icon_max_scroll: usize, pub metrics: SshFieldMetrics, } diff --git a/nebula_app/src/display/state.rs b/nebula_app/src/display/state.rs index 04b7ffe..802d90e 100644 --- a/nebula_app/src/display/state.rs +++ b/nebula_app/src/display/state.rs @@ -71,6 +71,9 @@ pub enum NebulaConfirm { /// under persistent controls is opt-in because low-contrast images can /// make caption buttons, tabs and SSH navigation harder to read. EnableBackgroundImageCoverChrome, + /// 「拖拽调节侧栏」开启前的一次性告知:宽度拖动会实时重排终端,低配 + /// 机器或超大回滚缓冲下可能掉帧(用户裁定:开启必须先明确警告)。 + EnablePanelResize, InstallRequiredFont { directory: PathBuf, }, @@ -149,6 +152,17 @@ pub struct NebulaPaneState { pub last_committed: String, pub awaiting_input: bool, pub finished_unseen: bool, + /// AI CLI 停下来等用户批准(claude 的 `Notification` hook)。和 + /// `finished_unseen` 分开:那个是"回合做完了,轮到你",这个是"它卡在 + /// 半路上,不点头就不动"——后者才需要手掌徽章催人。 + pub needs_attention: bool, + /// 上一条命令以非零码收尾且还没被看到。此前失败和成功共用一颗圆点, + /// 标签上根本读不出"那条跑挂了"。 + pub failed_unseen: bool, + /// 命令成功收尾的时刻,用来放那一下对勾闪现(见 `BADGE_FLASH`)。 + /// 闪完落回圆点——对勾说"刚成的",圆点说"有结果没看",是同一件事的 + /// 两个阶段。 + pub finished_at: Option, pub pending_ssh_host: Option, /// 助手错误恢复的建议条状态(spec 001);`None` = 无条。 pub ai_fix: Option, diff --git a/nebula_app/src/display/terminal_math.rs b/nebula_app/src/display/terminal_math.rs index 01a9df3..370df47 100644 --- a/nebula_app/src/display/terminal_math.rs +++ b/nebula_app/src/display/terminal_math.rs @@ -1276,10 +1276,7 @@ pub(super) struct CoverageMask { } impl CoverageMask { - pub(super) fn build( - overlays: &[FormulaOverlay], - prepared: &[Option], - ) -> Self { + pub(super) fn build(overlays: &[FormulaOverlay], prepared: &[Option]) -> Self { let mut rows: BTreeMap> = BTreeMap::new(); for (overlay, prepared) in overlays.iter().zip(prepared) { if prepared.is_none() { diff --git a/nebula_app/src/display/ui/guardrails.rs b/nebula_app/src/display/ui/guardrails.rs index 6fab8b2..0e03a93 100644 --- a/nebula_app/src/display/ui/guardrails.rs +++ b/nebula_app/src/display/ui/guardrails.rs @@ -1,282 +1,290 @@ -//! 把设计裁定从「文档里的建议」变成「测试里的红线」。 -//! -//! # 为什么需要它 -//! -//! 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_* 配方", - 69, - 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)", - 47, - 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 }; - let lines: Vec<&str> = src.lines().collect(); - for (index, line) in lines.iter().enumerate() { - if !line.contains("UiQuad::glow(") { - continue; - } - // 星云品牌位的粒子/脉冲本来就该发光——那是画面主体,不是在给 - // 浮层垫高度。豁免必须在紧邻的上一行显式写出标记,所以它不会 - // 悄悄扩散:想用就得先写下理由,reviewer 一眼看得见。 - let exempt = index > 0 && lines[index - 1].contains("brand-glow:"); - if !exempt { - 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} 应该被判违规"); - } - } -} +//! 把设计裁定从「文档里的建议」变成「测试里的红线」。 +//! +//! # 为什么需要它 +//! +//! 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_* 配方", + 69, + 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)", + 45, + 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 }; + let lines: Vec<&str> = src.lines().collect(); + for (index, line) in lines.iter().enumerate() { + if !line.contains("UiQuad::glow(") { + continue; + } + // 星云品牌位的粒子/脉冲本来就该发光——那是画面主体,不是在给 + // 浮层垫高度。豁免必须在紧邻的上一行显式写出标记,所以它不会 + // 悄悄扩散:想用就得先写下理由,reviewer 一眼看得见。 + let exempt = index > 0 && lines[index - 1].contains("brand-glow:"); + if !exempt { + 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/icons.rs b/nebula_app/src/display/ui/icons.rs index 203ab57..06b25ce 100644 --- a/nebula_app/src/display/ui/icons.rs +++ b/nebula_app/src/display/ui/icons.rs @@ -86,7 +86,12 @@ pub(crate) fn push_add(quads: &mut Vec, rect: (f32, f32, f32, f32), scal /// Vertical three-dot "more" mark centered in `rect`. Shares the "+" mark's /// visual weight so the pair reads as one control family. -pub(crate) fn push_more(quads: &mut Vec, rect: (f32, f32, f32, f32), scale: f32, ink: Rgba) { +pub(crate) fn push_more( + quads: &mut Vec, + rect: (f32, f32, f32, f32), + scale: f32, + ink: Rgba, +) { let (x, y, width, height) = rect; if width <= 0.0 || height <= 0.0 { return; @@ -267,7 +272,8 @@ pub(crate) fn push_chevron( let arm = 3.9 * scale; let drop = 2.1 * scale; let stroke = (1.4 * scale).max(1.0); - let (from_y, mid_y) = if up { (center_y + drop, center_y - drop) } else { (center_y - drop, center_y + drop) }; + let (from_y, mid_y) = + if up { (center_y + drop, center_y - drop) } else { (center_y - drop, center_y + drop) }; push_segment(quads, (center_x - arm, from_y), (center_x, mid_y), stroke, ink); push_segment(quads, (center_x, mid_y), (center_x + arm, from_y), stroke, ink); } @@ -296,3 +302,223 @@ pub(crate) fn push_check( ink, ); } + +/// 举起的手掌:「停下来等你」。标签徽章用它区别于「回合完成」的圆点。 +/// +/// # 为什么是实心而不是描边 +/// +/// 参照的原型(otty)画的是橙色描边、内部挖空的手掌轮廓。我们做不到: +/// 描边在这里只能靠 [`push_sidebar_toggle`] 那种「外层实心 + 内层填背景色」 +/// 的挖空手法,而手指只有 2px 宽,两侧各让出 1px 描边就什么都不剩了。 +/// 那条路要么把图标撑到 20px 以上,要么手指糊成一片——所以这里走实心, +/// 靠**形状简化**保清晰度。 +/// +/// # 指缝是这个图标的成败点 +/// +/// 几何按 13×13 的逻辑网格设计,中指最长、小指最短——手的辨识度几乎全在 +/// 那条参差的指尖轮廓上,四根等长会读成一把梳子。 +/// +/// 但真正决定它是不是"一只手"的是**指缝**:第一版手指宽 1.9、间距 2.3, +/// 缝只剩 0.4px,100%–200% DPI 下四指全糊成一个团块。现在宽 1.6、间距 +/// 2.8(缝 1.2),并且每个 quad 都走 [`UiQuad::pixel_snapped`]——1.2px 的 +/// 缝若跨在像素边界上就是两条半透明的灰边,等于没有;吸附到整像素后 +/// 100% DPI 也能看清四指。这条和 draw_ui_text 的整像素锚点是同一条铁律。 +/// +/// # 现状:未接线 +/// +/// 用户 08-02 裁定这一版形不满意(小尺寸下指缝仍读不成手),标签徽章的 +/// 「等你批准」暂时退回 warn 色圆点,见 `chrome.rs` 的 `attention` 分支。 +/// 几何与踩过的坑都留在这儿,等重画时接着用。 +#[allow(dead_code)] +pub(crate) fn push_hand( + quads: &mut Vec, + center_x: f32, + center_y: f32, + scale: f32, + ink: Rgba, +) { + let s = |v: f32| v * scale; + let finger_w = s(2.2).max(1.4); + + // 四指:粗、短、紧——缝只留一条细线(0.7)。第一版指宽 1.6、缝 1.2, + // 画出来是一把耙子:手的胖瘦不在指长,在**指占比**。指尖高低差保持 + // 拱形(中指最高、小指最矮),这是"手"而不是"梳子"的全部线索。 + for (dx, tip, len) in + [(-4.35, -4.2, 5.4), (-1.45, -5.2, 6.4), (1.45, -4.7, 5.9), (4.35, -3.3, 4.5)] + { + quads.push( + UiQuad::solid( + center_x + s(dx) - finger_w * 0.5, + center_y + s(tip), + finger_w, + s(len), + finger_w * 0.5, + ink, + ) + .pixel_snapped(), + ); + } + + // 掌与拇指的圆角从**自身高度**派生,不走 tokens::radius 阶梯:那道阶梯 + // 描述的是容器(浮层/控件/chip)的层级语言,而这里的圆头是有机形状的 + // 一部分——指腹和掌根本来就该随手指粗细走,跟着卡片圆角一起调反而错。 + // + // 掌接住四指的整块,够高才圆得像掌根;边指与掌缘平齐,读作连体的手 + // 而不是"块上插齿"。 + let palm_h = s(6.0); + quads.push( + UiQuad::solid(center_x + s(-5.45), center_y, s(10.9), palm_h, palm_h * 0.47, ink) + .pixel_snapped(), + ); + + // 拇指:向左伸出的粗短一截,圆头即半高。没有它,剩下的部分读作一把刷子。 + let thumb_h = s(2.8); + quads.push( + UiQuad::solid(center_x + s(-8.2), center_y + s(1.0), s(4.4), thumb_h, thumb_h * 0.5, ink) + .pixel_snapped(), + ); +} + +/// 忙碌指示器:一整圈暗色轨道 + 一段绕行的亮弧。 +/// +/// # 怎么在没有描边和旋转的管线里画出一个**真空心**的环 +/// +/// 原型是 `border-top-color` 的旋转圆环。`UiQuad` 既没有描边也不能旋转。 +/// +/// 中间那版用挖空法:实心圆叠一个"底色"的小圆。它的错在于**底色是猜的** +/// ——spinner 画在标签行上,而行底可能是面板色、悬浮色或选中药丸,挖空色 +/// 一旦猜错,中间就不是空的,而是一块颜色不对的实心圆盖在上面。屏幕上看到 +/// 的就是一颗深色圆点。 +/// +/// 现在整圈都由点铺成,**只画环本身,中间根本没画东西**——所以它在任何底 +/// 上都是真的空心。两个教训叠在这同一个函数里: +/// +/// 1. **间距要远小于点径**(这里取半个点径,50% 重叠),点与点才连成弧; +/// 第一版间距 2.6px 而点径只有 2.1px,缝比点还宽,看到的是一圈珠子。 +/// 2. **点必须是不透明色**。半透明的点一重叠,重叠区 alpha 翻倍、颜色加 +/// 深——环上亮暗相间,又是一圈珠子,这次的珠子是叠加缝画出来的。所以 +/// 轨道与亮弧先各自和 `base`(环所在处的底色,须不透明)合成成不透明 +/// 色再画;中间依然什么都没画,空心不靠猜底色。 +/// +/// `phase` 取 `0..1` 的一圈。挂在挂钟上而不是累加帧增量:掉帧只让它顿一下, +/// 不会让它变慢——"转得比别处慢"恰恰最容易被读成已经卡死。 +#[allow(clippy::too_many_arguments)] +pub(crate) fn push_spinner( + quads: &mut Vec, + center_x: f32, + center_y: f32, + radius: f32, + phase: f32, + track: Rgba, + head: Rgba, + base: Rgba, +) { + let track = super::surface::over(track, base); + let head = super::surface::over(head, base); + let stroke = (radius * 0.30).max(1.0); + // 点铺在轨道中线上:半径减半个笔画,环的外缘才正好落在 `radius` 上。 + let mid = radius - stroke * 0.5; + let steps = ((mid * std::f32::consts::TAU / (stroke * 0.5)).ceil() as usize).clamp(24, 96); + // 亮弧占整圈的三分之一:短了读不出方向,长了整圈都在发亮就不像在转。 + const ARC: f32 = 0.34; + for step in 0..steps { + let at = step as f32 / steps as f32; + let angle = at * std::f32::consts::TAU - std::f32::consts::FRAC_PI_2; + // 离头部多远(沿转动方向往回数),0 = 正是头部。 + let behind = (phase - at).rem_euclid(1.0); + let t = (1.0 - behind / ARC).clamp(0.0, 1.0); + let mix = |a: u8, b: u8| (a as f32 + (b as f32 - a as f32) * t).round() as u8; + quads.push(UiQuad::solid( + center_x + mid * angle.cos() - stroke * 0.5, + center_y + mid * angle.sin() - stroke * 0.5, + stroke, + stroke, + stroke * 0.5, + Rgba::new( + mix(track.r, head.r), + mix(track.g, head.g), + mix(track.b, head.b), + mix(track.a, head.a), + ), + )); + } +} + +/// 警示三角:命令以非零码退出。 +/// +/// UiQuad 没有多边形填充,所以三角靠**逐行收窄的横条**堆出来——行高取 1 物理 +/// 像素,斜边就是一串整像素台阶,和字形栅格化的处理同源。行数按尺寸算,不写 +/// 死:低 DPI 下八行足够,高 DPI 下再多几行斜边才不显毛糙。 +/// +/// 中间那道竖杠是感叹号,用底色挖空([`blend_over`] 的用途),比另画一层墨迹 +/// 更稳——三角内部是实心的,任何叠加色都会透出下面的红。 +pub(crate) fn push_alert( + quads: &mut Vec, + center_x: f32, + center_y: f32, + scale: f32, + ink: Rgba, + cutout: Rgba, +) { + let s = |v: f32| v * scale; + let half_w = s(6.2); + let height = s(10.4); + let top = center_y - height * 0.5; + let step = (scale.round()).max(1.0); + let rows = (height / step).ceil().max(6.0) as usize; + + for row in 0..rows { + let t = row as f32 / rows as f32; + // 顶端留一点宽度而不是收成尖,尖角在小尺寸下只会变成一个孤立的 + // 半透明点。 + let w = half_w * (0.18 + 0.82 * t); + quads.push( + UiQuad::solid(center_x - w, top + row as f32 * step, w * 2.0, step + 0.5, 0.0, ink) + .pixel_snapped(), + ); + } + + // 感叹号:一竖 + 一点,都用底色挖空。 + let bar_w = (s(1.5)).max(1.0); + quads.push( + UiQuad::solid(center_x - bar_w * 0.5, center_y - s(1.6), bar_w, s(3.4), 0.0, cutout) + .pixel_snapped(), + ); + let dot = (s(1.5)).max(1.0); + quads.push( + UiQuad::solid(center_x - dot * 0.5, center_y + s(2.8), dot, dot, dot * 0.5, cutout) + .pixel_snapped(), + ); +} + +/// 实心圆底 + 挖空的对勾:命令成功收尾的那一下。 +/// +/// 和 [`push_check`] 的区别是它自带底盘——徽章位上一个裸勾太轻,压不住 +/// 旁边那颗同尺寸的圆点;有了底盘,它读起来才是"圆点完成态"的同一族。 +pub(crate) fn push_check_badge( + quads: &mut Vec, + center_x: f32, + center_y: f32, + scale: f32, + ink: Rgba, + cutout: Rgba, +) { + let s = |v: f32| v * scale; + let d = s(11.0); + quads.push(UiQuad::solid(center_x - d * 0.5, center_y - d * 0.5, d, d, d * 0.5, ink)); + let stroke = (s(1.6)).max(1.0); + push_segment( + quads, + (center_x - s(2.6), center_y + s(0.1)), + (center_x - s(0.7), center_y + s(2.1)), + stroke, + cutout, + ); + push_segment( + quads, + (center_x - s(0.7), center_y + s(2.1)), + (center_x + s(2.9), center_y - s(2.2)), + stroke, + cutout, + ); +} diff --git a/nebula_app/src/display/ui/keycap.rs b/nebula_app/src/display/ui/keycap.rs index 2597777..a9abbd3 100644 --- a/nebula_app/src/display/ui/keycap.rs +++ b/nebula_app/src/display/ui/keycap.rs @@ -63,15 +63,7 @@ pub fn layout_combo( } /// The one chip recipe: hairline ring + panel + surface, chip radius. -pub fn push_chip( - quads: &mut Vec, - sk: &Skin, - x: f32, - y: f32, - w: f32, - h: f32, - scale: f32, -) { +pub fn push_chip(quads: &mut Vec, sk: &Skin, x: f32, y: f32, w: f32, h: f32, scale: f32) { 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)); diff --git a/nebula_app/src/display/ui/mod.rs b/nebula_app/src/display/ui/mod.rs index 15b7bce..7cd81ee 100644 --- a/nebula_app/src/display/ui/mod.rs +++ b/nebula_app/src/display/ui/mod.rs @@ -1,59 +1,60 @@ -//! 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`] —— 面板/卡片/描边/遮罩的配方(本层的核心) -//! - [`text_field`] —— 输入框的光标、选区、命中测试(唯一有可变状态的组件) -//! - [`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 text_field; -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`] —— 面板/卡片/描边/遮罩的配方(本层的核心) +//! - [`text_field`] —— 输入框的光标、选区、命中测试(唯一有可变状态的组件) +//! - [`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 os_icons; +pub mod surface; +pub mod text_field; +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 fd37e68..03f4e58 100644 --- a/nebula_app/src/display/ui/surface.rs +++ b/nebula_app/src/display/ui/surface.rs @@ -1,360 +1,362 @@ -//! 浮层配方:面板、卡片、描边、遮罩。本层的核心。 -//! -//! # 为什么需要它 -//! -//! 2026-07-29 侦察:`(x-1, y-1, w+2, h+2)` + hairline + 填充这套描边配方在 -//! `display/` 下手写了 **95 处**,八个浮层用了**六种**圆角。结果是任何一条视觉 -//! 规则(明度阶梯、阴影、遮罩、圆角)都无法一次改全——这正是"配色改不干净"的 -//! 结构性原因。 -//! -//! 这里的函数是那些配方的唯一来源。新的浮层不应该再手写 quad 序列。 -//! -//! # 遮罩的二分(重要) -//! -//! [`Elevation`] 把「是否画遮罩」编码进类型,而不是留给调用方决定。理由是遮罩 -//! 传达的是一份**模态承诺**——「我打断了你,请先处理我」。随手开关的浮层给出这 -//! 份承诺,会凭空抬高用户的心理成本,还会遮住它自己经常需要被参考的上下文。 -//! -//! 参照产品的命令面板给过像素证据:面板打开时背景终端文字和侧栏亮度完全 -//! 未变,浮起靠的是「明度对比 + 大扩散阴影」,不是压暗背景。 -//! 判据见 [`Elevation`] 各变体的文档:可 Esc、无后果、随手开关的是 -//! popover,要求决策、有后果的是 modal。 - -use super::theme::Skin; -use super::tokens::{control, elevation, radius}; -use crate::renderer::ui::{Rgba, UiQuad}; - -/// `(x, y, width, height)`,逻辑像素。 -pub type Rect = (f32, f32, f32, f32); - -/// 浮层的高度层级。决定阴影扩散、圆角,以及**是否画遮罩**。 -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Elevation { - /// 小浮层:右键菜单、下拉 popup、tooltip。不画遮罩。 - Menu, - /// 大浮层:命令面板、shell 选择器。可 Esc、无后果、随手开关—— - /// **不画遮罩**,靠明度对比与大扩散阴影浮起。 - Popover, - /// 模态:确认框、设置页、SSH 编辑器。要求决策、有后果, - /// 画遮罩(冷调压暗,不是白雾)。 - Modal, -} - -impl Elevation { - /// `(blur, offset_y)`,逻辑像素。阴影扩散必须与被托举的面积成比例: - /// 一组适合小菜单的参数放在整块命令面板下面就托不住。 - #[inline] - fn shadow(self) -> (f32, f32) { - match self { - Self::Menu => (elevation::MENU_BLUR, elevation::MENU_OFFSET_Y), - Self::Popover => (elevation::POPOVER_BLUR, elevation::POPOVER_OFFSET_Y), - Self::Modal => (elevation::MODAL_BLUR, elevation::MODAL_OFFSET_Y), - } - } - - /// 只有模态阻断交互,所以只有模态画遮罩。 - #[inline] - pub fn dims_background(self) -> bool { - matches!(self, Self::Modal) - } - - /// 面板底是否必须完全不透明。 - /// - /// 菜单和下拉紧贴着它们遮住的那一行内容,`Skin::panel` 那 4% 的透明度 - /// 会让底下的文字隐隐透上来——在选项文字背后叠一层错位的鬼影,正是 - /// 界面读起来"脏"的那种脏。命令面板和模态离内容远、且四周有阴影过渡, - /// 留一点透视反而自然。 - #[inline] - fn needs_opaque_fill(self) -> bool { - matches!(self, Self::Menu) - } -} - -/// 浮层内容块的状态。 -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CardState { - Default, - Hover, - /// 当前选中项。强调色预算一屏只花一次,就花在这里—— - /// 不要同时给推荐卡、导航 pill 也染色。 - Selected, -} - -/// 按入场进度衰减 alpha。`progress` 取 0..1,1 为完全不透明。 -#[inline] -pub fn fade(color: Rgba, progress: f32) -> Rgba { - if progress >= 1.0 { - return color; - } - Rgba::new(color.r, color.g, color.b, (color.a as f32 * progress.clamp(0.0, 1.0)).round() as u8) -} - -/// `top` 压在 `base` 上的合成色,alpha 取 `base` 的。 -/// -/// 用来把"半透明叠加色 + 不透明底"预先算成一个色,画一个 quad 而不是两个。 -/// 这不只是省一次绘制:见 [`push_group`],中间那层必须不透明,否则底下的 -/// 描边环会从整块面板里渗出来。 -#[inline] -pub fn over(top: Rgba, base: Rgba) -> Rgba { - let a = top.a as f32 / 255.0; - let mix = |t: u8, b: u8| (t as f32 * a + b as f32 * (1.0 - a)).round().clamp(0.0, 255.0) as u8; - Rgba::new(mix(top.r, base.r), mix(top.g, base.g), mix(top.b, base.b), base.a) -} - -/// 发丝描边环。替代散落各处的手写 `(x-1, y-1, w+2, h+2)`。 -/// -/// 外圆角自动取 `radius + hairline`,保证内外弧**同心**——手写处常常内外用同一 -/// 个半径,那样描边在圆角处会变粗,是"边缘发毛"的来源之一。 -/// -/// `radius` 传入的是**内**圆角(已乘过 scale)。 -pub fn push_stroke(quads: &mut Vec, rect: Rect, radius: f32, scale: f32, color: Rgba) { - let hairline = (control::HAIRLINE * scale).max(1.0); - let (x, y, w, h) = rect; - quads.push(UiQuad::solid( - x - hairline, - y - hairline, - w + hairline * 2.0, - h + hairline * 2.0, - radius + hairline, - color, - )); -} - -/// 浮层的底:遮罩(仅 [`Elevation::Modal`])→ 外阴影 → 发丝环 → 面板填充。 -/// -/// `viewport` 是整窗尺寸 `(width, height)`,只有模态用得上。 -/// `progress` 是入场动画进度,所有颜色按它衰减。 -pub fn push_surface( - quads: &mut Vec, - rect: Rect, - viewport: (f32, f32), - scale: f32, - sk: &Skin, - level: Elevation, - progress: f32, -) { - push_surface_in( - quads, - rect, - (0.0, 0.0, viewport.0, viewport.1), - 0.0, - scale, - sk, - level, - progress, - ); -} - -/// 同 [`push_surface`],但遮罩只铺满 `veil_rect` 而不是整窗,并带 `veil_radius` -/// 的圆角。 -/// -/// 用于那些语义上属于某块区域的模态:SSH 主机编辑器是"对终端做的事",遮罩 -/// 盖住终端卡就够了。连侧栏和标题栏一起罩黑会把它读成"整个应用被阻断",而 -/// 那时侧栏其实仍然是可见的上下文。 -/// -/// 圆角是必须的:终端是一张圆角卡,直角遮罩会在四角各露出一块,读起来像糊 -/// 歪了一层贴纸。 -#[allow(clippy::too_many_arguments)] -pub fn push_surface_in( - quads: &mut Vec, - rect: Rect, - veil_rect: Rect, - veil_radius: f32, - scale: f32, - sk: &Skin, - level: Elevation, - progress: f32, -) { - let (x, y, w, h) = rect; - let corner = radius::OVERLAY * scale; - - if level.dims_background() { - quads.push(UiQuad::solid( - veil_rect.0, - veil_rect.1, - veil_rect.2, - veil_rect.3, - veil_radius, - fade(sk.veil, progress), - )); - } - - // 外阴影承担 Z 轴层级;发丝环只负责在浅色背景上定义边界。两者分工不同, - // 缺了阴影就只剩一条线,浮层会"贴"在背景上而不是浮起来。 - let (blur, offset_y) = level.shadow(); - let shadow_alpha = if sk.is_light { - elevation::SHADOW_ALPHA_LIGHT - } else { - elevation::SHADOW_ALPHA_DARK - }; - quads.push(UiQuad::shadow( - x, - y, - w, - h, - corner, - blur * scale, - offset_y * scale, - fade(Rgba::new(0, 0, 0, shadow_alpha), progress), - )); - - push_stroke(quads, rect, corner, scale, fade(sk.hairline, progress)); - 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))); -} - -/// 浮层内部的内容块。 -pub fn push_card(quads: &mut Vec, rect: Rect, scale: f32, sk: &Skin, state: CardState) { - let (x, y, w, h) = rect; - let corner = radius::OVERLAY * scale; - let fill = match state { - CardState::Default => sk.card, - CardState::Hover => sk.hover, - CardState::Selected => sk.accent_soft, - }; - quads.push(UiQuad::solid(x, y, w, h, corner, fill)); -} - -/// 带发丝描边的分组卡片:把同组字段收成一块可扫读的区域。 -/// -/// **不要写成 `push_stroke` + `push_card`。** [`push_stroke`] 画的是一个比 -/// 目标大 1px 的**实心**圆角矩形,靠上层填充盖住中心才形成"环"。`panel` 和 -/// `input` 都不透明,盖得住;而 `card` 只有 4.5% 不透明度,盖不住——描边色 -/// 有 95% 透过整张卡片,把它压深整整一档。 -/// -/// 2026-07-31 量到的实迹(Nebula 深色):panel (29,31,40) 经描边泄漏变成 -/// (50,52,62),再叠 card 得 (62,65,74),而原型是 (56,61,73)。所以这里把 -/// `card` 与 `panel` 预先合成成一个**不透明**色,一个 quad 画完,描边环也 -/// 就只剩它该有的那 1px。 -pub fn push_group(quads: &mut Vec, rect: Rect, scale: f32, sk: &Skin, progress: f32) { - let (x, y, w, h) = rect; - let corner = radius::OVERLAY * scale; - push_stroke(quads, rect, corner, scale, fade(sk.hairline, progress)); - quads.push(UiQuad::solid(x, y, w, h, corner, fade(over(sk.card, sk.panel), progress))); -} - -/// 下沉表面(文本输入框)。聚焦时描边换成强调色——焦点不能只靠颜色深浅表示。 -pub fn push_input(quads: &mut Vec, rect: Rect, scale: f32, sk: &Skin, focused: bool) { - let (x, y, w, h) = rect; - let corner = radius::CONTROL * scale; - let stroke = if focused { - Rgba::new(sk.accent.r, sk.accent.g, sk.accent.b, 255) - } else { - sk.hairline - }; - push_stroke(quads, rect, corner, scale, stroke); - quads.push(UiQuad::solid(x, y, w, h, corner, sk.input)); -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::display::ui::theme::NebulaTheme; - - fn light() -> Skin { - NebulaTheme::SilverLight.skin() - } - - #[test] - fn a_group_card_seals_the_stroke_ring_with_an_opaque_fill() { - // 这条锁的是 2026-07-31 那个 bug:`push_stroke` 画的是实心矩形, - // 靠上层填充盖住中心才成环。card 只有 4.5% 不透明度,直接叠上去 - // 会让描边色渗满整张卡片,把它压深一整档。所以内芯必须不透明。 - for skin in [light(), NebulaTheme::Nebula.skin()] { - let mut quads = Vec::new(); - push_group(&mut quads, (40.0, 40.0, 300.0, 120.0), 1.0, &skin, 1.0); - - assert_eq!(quads.len(), 2, "只该有描边环和内芯两个 quad"); - assert_eq!(quads[1].color0.a, 255, "内芯必须不透明,否则描边渗出来"); - } - } - - #[test] - fn a_group_card_reads_one_step_above_its_panel() { - // card 是"比底板亮一档"的叠加色。合成之后这个方向必须还在—— - // 深色主题叠白、浅色主题叠 slate,两边的符号是相反的。 - let dark = NebulaTheme::Nebula.skin(); - let fill = over(dark.card, dark.panel); - assert!(fill.r > dark.panel.r, "深色下卡片要比面板亮"); - - let sun = light(); - let fill = over(sun.card, sun.panel); - assert!(fill.r < sun.panel.r, "浅色下卡片要比面板暗"); - } - - #[test] - fn stroke_ring_stays_concentric_with_its_fill() { - // 内外弧同心的判据:外半径 - 内半径 == 描边宽度。不满足时描边在圆角 - // 处会变粗,边缘发毛。 - let mut quads = Vec::new(); - let rect = (100.0, 50.0, 200.0, 80.0); - push_stroke(&mut quads, rect, 8.0, 1.0, Rgba::new(0, 0, 0, 40)); - - let ring = &quads[0]; - assert_eq!(ring.radius - 8.0, 1.0, "外圆角必须等于内圆角 + 描边宽"); - assert_eq!((ring.x, ring.y), (99.0, 49.0)); - assert_eq!((ring.width, ring.height), (202.0, 82.0)); - } - - #[test] - fn stroke_hairline_never_falls_below_one_physical_pixel() { - // 缩放小于 1 时 hairline 会算出亚像素宽度,在整数像素栅格上会消失。 - let mut quads = Vec::new(); - push_stroke(&mut quads, (0.0, 0.0, 10.0, 10.0), 4.0, 0.5, Rgba::new(0, 0, 0, 40)); - assert_eq!(quads[0].width, 12.0, "描边宽应被钳到 1px,而不是 0.5px"); - } - - #[test] - fn only_modal_dims_the_background() { - // 裁定三的回归防线:popover 加遮罩会凭空给出「我打断了你」的承诺, - // 并遮住它自己经常需要被参考的上下文。 - assert!(!Elevation::Menu.dims_background()); - assert!(!Elevation::Popover.dims_background()); - assert!(Elevation::Modal.dims_background()); - - let sk = light(); - for level in [Elevation::Menu, Elevation::Popover] { - let mut quads = Vec::new(); - push_surface(&mut quads, (10.0, 10.0, 100.0, 60.0), (800.0, 600.0), 1.0, &sk, level, 1.0); - assert!( - !quads.iter().any(|q| q.width >= 800.0 && q.height >= 600.0), - "{level:?} 不该产生任何整窗遮罩 quad", - ); - assert_eq!(quads.len(), 3, "{level:?} 应只有阴影 + 发丝环 + 填充"); - } - - let mut quads = Vec::new(); - push_surface( - &mut quads, - (10.0, 10.0, 100.0, 60.0), - (800.0, 600.0), - 1.0, - &sk, - Elevation::Modal, - 1.0, - ); - let veil = &quads[0]; - assert_eq!((veil.x, veil.y, veil.width, veil.height), (0.0, 0.0, 800.0, 600.0)); - } - - #[test] - fn shadow_spread_scales_with_surface_size() { - // 小菜单的阴影参数放在整块面板下面托不住,所以按层级分档。 - let (menu_blur, _) = Elevation::Menu.shadow(); - let (popover_blur, _) = Elevation::Popover.shadow(); - let (modal_blur, _) = Elevation::Modal.shadow(); - assert!(menu_blur < popover_blur && popover_blur < modal_blur); - } - - #[test] - fn fade_is_identity_at_full_progress() { - let c = Rgba::new(10, 20, 30, 200); - assert_eq!(fade(c, 1.0).a, 200); - assert_eq!(fade(c, 0.5).a, 100); - assert_eq!(fade(c, 0.0).a, 0); - } -} +//! 浮层配方:面板、卡片、描边、遮罩。本层的核心。 +//! +//! # 为什么需要它 +//! +//! 2026-07-29 侦察:`(x-1, y-1, w+2, h+2)` + hairline + 填充这套描边配方在 +//! `display/` 下手写了 **95 处**,八个浮层用了**六种**圆角。结果是任何一条视觉 +//! 规则(明度阶梯、阴影、遮罩、圆角)都无法一次改全——这正是"配色改不干净"的 +//! 结构性原因。 +//! +//! 这里的函数是那些配方的唯一来源。新的浮层不应该再手写 quad 序列。 +//! +//! # 遮罩的二分(重要) +//! +//! [`Elevation`] 把「是否画遮罩」编码进类型,而不是留给调用方决定。理由是遮罩 +//! 传达的是一份**模态承诺**——「我打断了你,请先处理我」。随手开关的浮层给出这 +//! 份承诺,会凭空抬高用户的心理成本,还会遮住它自己经常需要被参考的上下文。 +//! +//! 参照产品的命令面板给过像素证据:面板打开时背景终端文字和侧栏亮度完全 +//! 未变,浮起靠的是「明度对比 + 大扩散阴影」,不是压暗背景。 +//! 判据见 [`Elevation`] 各变体的文档:可 Esc、无后果、随手开关的是 +//! popover,要求决策、有后果的是 modal。 + +use super::theme::Skin; +use super::tokens::{control, elevation, radius}; +use crate::renderer::ui::{Rgba, UiQuad}; + +/// `(x, y, width, height)`,逻辑像素。 +pub type Rect = (f32, f32, f32, f32); + +/// 浮层的高度层级。决定阴影扩散、圆角,以及**是否画遮罩**。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Elevation { + /// 小浮层:右键菜单、下拉 popup、tooltip。不画遮罩。 + Menu, + /// 大浮层:命令面板、shell 选择器。可 Esc、无后果、随手开关—— + /// **不画遮罩**,靠明度对比与大扩散阴影浮起。 + Popover, + /// 模态:确认框、设置页、SSH 编辑器。要求决策、有后果, + /// 画遮罩(冷调压暗,不是白雾)。 + Modal, +} + +impl Elevation { + /// `(blur, offset_y)`,逻辑像素。阴影扩散必须与被托举的面积成比例: + /// 一组适合小菜单的参数放在整块命令面板下面就托不住。 + #[inline] + fn shadow(self) -> (f32, f32) { + match self { + Self::Menu => (elevation::MENU_BLUR, elevation::MENU_OFFSET_Y), + Self::Popover => (elevation::POPOVER_BLUR, elevation::POPOVER_OFFSET_Y), + Self::Modal => (elevation::MODAL_BLUR, elevation::MODAL_OFFSET_Y), + } + } + + /// 只有模态阻断交互,所以只有模态画遮罩。 + #[inline] + pub fn dims_background(self) -> bool { + matches!(self, Self::Modal) + } + + /// 面板底是否必须完全不透明。 + /// + /// 菜单和下拉紧贴着它们遮住的那一行内容,`Skin::panel` 那 4% 的透明度 + /// 会让底下的文字隐隐透上来——在选项文字背后叠一层错位的鬼影,正是 + /// 界面读起来"脏"的那种脏。命令面板和模态离内容远、且四周有阴影过渡, + /// 留一点透视反而自然。 + #[inline] + fn needs_opaque_fill(self) -> bool { + matches!(self, Self::Menu) + } +} + +/// 浮层内容块的状态。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CardState { + Default, + Hover, + /// 当前选中项。强调色预算一屏只花一次,就花在这里—— + /// 不要同时给推荐卡、导航 pill 也染色。 + Selected, +} + +/// 按入场进度衰减 alpha。`progress` 取 0..1,1 为完全不透明。 +#[inline] +pub fn fade(color: Rgba, progress: f32) -> Rgba { + if progress >= 1.0 { + return color; + } + Rgba::new(color.r, color.g, color.b, (color.a as f32 * progress.clamp(0.0, 1.0)).round() as u8) +} + +/// `top` 压在 `base` 上的合成色,alpha 取 `base` 的。 +/// +/// 用来把"半透明叠加色 + 不透明底"预先算成一个色,画一个 quad 而不是两个。 +/// 这不只是省一次绘制:见 [`push_group`],中间那层必须不透明,否则底下的 +/// 描边环会从整块面板里渗出来。 +#[inline] +pub fn over(top: Rgba, base: Rgba) -> Rgba { + let a = top.a as f32 / 255.0; + let mix = |t: u8, b: u8| (t as f32 * a + b as f32 * (1.0 - a)).round().clamp(0.0, 255.0) as u8; + Rgba::new(mix(top.r, base.r), mix(top.g, base.g), mix(top.b, base.b), base.a) +} + +/// 发丝描边环。替代散落各处的手写 `(x-1, y-1, w+2, h+2)`。 +/// +/// 外圆角自动取 `radius + hairline`,保证内外弧**同心**——手写处常常内外用同一 +/// 个半径,那样描边在圆角处会变粗,是"边缘发毛"的来源之一。 +/// +/// `radius` 传入的是**内**圆角(已乘过 scale)。 +pub fn push_stroke(quads: &mut Vec, rect: Rect, radius: f32, scale: f32, color: Rgba) { + let hairline = (control::HAIRLINE * scale).max(1.0); + let (x, y, w, h) = rect; + quads.push(UiQuad::solid( + x - hairline, + y - hairline, + w + hairline * 2.0, + h + hairline * 2.0, + radius + hairline, + color, + )); +} + +/// 浮层的底:遮罩(仅 [`Elevation::Modal`])→ 外阴影 → 发丝环 → 面板填充。 +/// +/// `viewport` 是整窗尺寸 `(width, height)`,只有模态用得上。 +/// `progress` 是入场动画进度,所有颜色按它衰减。 +pub fn push_surface( + quads: &mut Vec, + rect: Rect, + viewport: (f32, f32), + scale: f32, + sk: &Skin, + level: Elevation, + progress: f32, +) { + push_surface_in( + quads, + rect, + (0.0, 0.0, viewport.0, viewport.1), + 0.0, + scale, + sk, + level, + progress, + ); +} + +/// 同 [`push_surface`],但遮罩只铺满 `veil_rect` 而不是整窗,并带 `veil_radius` +/// 的圆角。 +/// +/// 用于那些语义上属于某块区域的模态:SSH 主机编辑器是"对终端做的事",遮罩 +/// 盖住终端卡就够了。连侧栏和标题栏一起罩黑会把它读成"整个应用被阻断",而 +/// 那时侧栏其实仍然是可见的上下文。 +/// +/// 圆角是必须的:终端是一张圆角卡,直角遮罩会在四角各露出一块,读起来像糊 +/// 歪了一层贴纸。 +#[allow(clippy::too_many_arguments)] +pub fn push_surface_in( + quads: &mut Vec, + rect: Rect, + veil_rect: Rect, + veil_radius: f32, + scale: f32, + sk: &Skin, + level: Elevation, + progress: f32, +) { + let (x, y, w, h) = rect; + let corner = radius::OVERLAY * scale; + + if level.dims_background() { + quads.push(UiQuad::solid( + veil_rect.0, + veil_rect.1, + veil_rect.2, + veil_rect.3, + veil_radius, + fade(sk.veil, progress), + )); + } + + // 外阴影承担 Z 轴层级;发丝环只负责在浅色背景上定义边界。两者分工不同, + // 缺了阴影就只剩一条线,浮层会"贴"在背景上而不是浮起来。 + let (blur, offset_y) = level.shadow(); + let shadow_alpha = + if sk.is_light { elevation::SHADOW_ALPHA_LIGHT } else { elevation::SHADOW_ALPHA_DARK }; + quads.push(UiQuad::shadow( + x, + y, + w, + h, + corner, + blur * scale, + offset_y * scale, + fade(Rgba::new(0, 0, 0, shadow_alpha), progress), + )); + + push_stroke(quads, rect, corner, scale, fade(sk.hairline, progress)); + 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))); +} + +/// 浮层内部的内容块。 +pub fn push_card(quads: &mut Vec, rect: Rect, scale: f32, sk: &Skin, state: CardState) { + let (x, y, w, h) = rect; + let corner = radius::OVERLAY * scale; + let fill = match state { + CardState::Default => sk.card, + CardState::Hover => sk.hover, + CardState::Selected => sk.accent_soft, + }; + quads.push(UiQuad::solid(x, y, w, h, corner, fill)); +} + +/// 带发丝描边的分组卡片:把同组字段收成一块可扫读的区域。 +/// +/// **不要写成 `push_stroke` + `push_card`。** [`push_stroke`] 画的是一个比 +/// 目标大 1px 的**实心**圆角矩形,靠上层填充盖住中心才形成"环"。`panel` 和 +/// `input` 都不透明,盖得住;而 `card` 只有 4.5% 不透明度,盖不住——描边色 +/// 有 95% 透过整张卡片,把它压深整整一档。 +/// +/// 2026-07-31 量到的实迹(Nebula 深色):panel (29,31,40) 经描边泄漏变成 +/// (50,52,62),再叠 card 得 (62,65,74),而原型是 (56,61,73)。所以这里把 +/// `card` 与 `panel` 预先合成成一个**不透明**色,一个 quad 画完,描边环也 +/// 就只剩它该有的那 1px。 +pub fn push_group(quads: &mut Vec, rect: Rect, scale: f32, sk: &Skin, progress: f32) { + let (x, y, w, h) = rect; + let corner = radius::OVERLAY * scale; + push_stroke(quads, rect, corner, scale, fade(sk.hairline, progress)); + quads.push(UiQuad::solid(x, y, w, h, corner, fade(over(sk.card, sk.panel), progress))); +} + +/// 下沉表面(文本输入框)。聚焦时描边换成强调色——焦点不能只靠颜色深浅表示。 +pub fn push_input(quads: &mut Vec, rect: Rect, scale: f32, sk: &Skin, focused: bool) { + let (x, y, w, h) = rect; + let corner = radius::CONTROL * scale; + let stroke = + if focused { Rgba::new(sk.accent.r, sk.accent.g, sk.accent.b, 255) } else { sk.hairline }; + push_stroke(quads, rect, corner, scale, stroke); + quads.push(UiQuad::solid(x, y, w, h, corner, sk.input)); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::display::ui::theme::NebulaTheme; + + fn light() -> Skin { + NebulaTheme::SilverLight.skin() + } + + #[test] + fn a_group_card_seals_the_stroke_ring_with_an_opaque_fill() { + // 这条锁的是 2026-07-31 那个 bug:`push_stroke` 画的是实心矩形, + // 靠上层填充盖住中心才成环。card 只有 4.5% 不透明度,直接叠上去 + // 会让描边色渗满整张卡片,把它压深一整档。所以内芯必须不透明。 + for skin in [light(), NebulaTheme::Nebula.skin()] { + let mut quads = Vec::new(); + push_group(&mut quads, (40.0, 40.0, 300.0, 120.0), 1.0, &skin, 1.0); + + assert_eq!(quads.len(), 2, "只该有描边环和内芯两个 quad"); + assert_eq!(quads[1].color0.a, 255, "内芯必须不透明,否则描边渗出来"); + } + } + + #[test] + fn a_group_card_reads_one_step_above_its_panel() { + // card 是"比底板亮一档"的叠加色。合成之后这个方向必须还在—— + // 深色主题叠白、浅色主题叠 slate,两边的符号是相反的。 + let dark = NebulaTheme::Nebula.skin(); + let fill = over(dark.card, dark.panel); + assert!(fill.r > dark.panel.r, "深色下卡片要比面板亮"); + + let sun = light(); + let fill = over(sun.card, sun.panel); + assert!(fill.r < sun.panel.r, "浅色下卡片要比面板暗"); + } + + #[test] + fn stroke_ring_stays_concentric_with_its_fill() { + // 内外弧同心的判据:外半径 - 内半径 == 描边宽度。不满足时描边在圆角 + // 处会变粗,边缘发毛。 + let mut quads = Vec::new(); + let rect = (100.0, 50.0, 200.0, 80.0); + push_stroke(&mut quads, rect, 8.0, 1.0, Rgba::new(0, 0, 0, 40)); + + let ring = &quads[0]; + assert_eq!(ring.radius - 8.0, 1.0, "外圆角必须等于内圆角 + 描边宽"); + assert_eq!((ring.x, ring.y), (99.0, 49.0)); + assert_eq!((ring.width, ring.height), (202.0, 82.0)); + } + + #[test] + fn stroke_hairline_never_falls_below_one_physical_pixel() { + // 缩放小于 1 时 hairline 会算出亚像素宽度,在整数像素栅格上会消失。 + let mut quads = Vec::new(); + push_stroke(&mut quads, (0.0, 0.0, 10.0, 10.0), 4.0, 0.5, Rgba::new(0, 0, 0, 40)); + assert_eq!(quads[0].width, 12.0, "描边宽应被钳到 1px,而不是 0.5px"); + } + + #[test] + fn only_modal_dims_the_background() { + // 裁定三的回归防线:popover 加遮罩会凭空给出「我打断了你」的承诺, + // 并遮住它自己经常需要被参考的上下文。 + assert!(!Elevation::Menu.dims_background()); + assert!(!Elevation::Popover.dims_background()); + assert!(Elevation::Modal.dims_background()); + + let sk = light(); + for level in [Elevation::Menu, Elevation::Popover] { + let mut quads = Vec::new(); + push_surface( + &mut quads, + (10.0, 10.0, 100.0, 60.0), + (800.0, 600.0), + 1.0, + &sk, + level, + 1.0, + ); + assert!( + !quads.iter().any(|q| q.width >= 800.0 && q.height >= 600.0), + "{level:?} 不该产生任何整窗遮罩 quad", + ); + assert_eq!(quads.len(), 3, "{level:?} 应只有阴影 + 发丝环 + 填充"); + } + + let mut quads = Vec::new(); + push_surface( + &mut quads, + (10.0, 10.0, 100.0, 60.0), + (800.0, 600.0), + 1.0, + &sk, + Elevation::Modal, + 1.0, + ); + let veil = &quads[0]; + assert_eq!((veil.x, veil.y, veil.width, veil.height), (0.0, 0.0, 800.0, 600.0)); + } + + #[test] + fn shadow_spread_scales_with_surface_size() { + // 小菜单的阴影参数放在整块面板下面托不住,所以按层级分档。 + let (menu_blur, _) = Elevation::Menu.shadow(); + let (popover_blur, _) = Elevation::Popover.shadow(); + let (modal_blur, _) = Elevation::Modal.shadow(); + assert!(menu_blur < popover_blur && popover_blur < modal_blur); + } + + #[test] + fn fade_is_identity_at_full_progress() { + let c = Rgba::new(10, 20, 30, 200); + assert_eq!(fade(c, 1.0).a, 200); + assert_eq!(fade(c, 0.5).a, 100); + assert_eq!(fade(c, 0.0).a, 0); + } +} diff --git a/nebula_app/src/display/ui/theme.rs b/nebula_app/src/display/ui/theme.rs index b766c6f..508c79d 100644 --- a/nebula_app/src/display/ui/theme.rs +++ b/nebula_app/src/display/ui/theme.rs @@ -1,691 +1,716 @@ -//! Nebula theme system — the single source of truth for every chrome color. -//! -//! Everything visual that is NOT terminal-grid content reads from here: -//! the seven built-in themes (from the low-saturation powerline design -//! sheet: the deep-blue default plus three light/dark pairs), each theme's -//! chrome palette ([`NebulaPalette`]) and its full overlay ink set -//! ([`Skin`]). The settings modal, confirm dialogs, the command palette, -//! resize HUD, scrollbar and the tab/window chrome all pull their colors -//! from these two structs — no component keeps a private color constant, so -//! a theme switch (including light ↔ dark) restyles every surface at once. -//! -//! Design language (from the sheet): low-saturation surfaces, hierarchy by -//! brightness not borders, ONE accent per theme, semantic red reserved for -//! destructive actions. Light themes flip the whole ink set to dark-on-light -//! rather than dimming the dark inks. -//! -//! Adding a theme = one enum variant + one `palette()` arm + one `accent()` -//! arm (+ a card slot in the settings grid and a palette action). The -//! [`Skin`] derives from those automatically via `is_light`. - -use crate::display::color::{List, Rgb}; -use crate::renderer::ui::Rgba; -use nebula_terminal::vte::ansi::NamedColor; - -/// First 256-color palette slot claimed for the powerline prompt chips -/// (16..=23: icon bg/fg, path bg/fg, branch bg/fg, time bg/fg). Chosen at the -/// very start of the 6×6×6 cube — the darkest corner, rarely load-bearing for -/// TUIs — so hijacking eight slots stays invisible in practice. -pub(crate) const POWERLINE_SLOT0: usize = 16; - -/// Built-in Nebula chrome themes exposed from the settings panel — the seven -/// looks from the design sheet: the deep-blue default plus three light/dark -/// low-saturation pairs (silver/steel, limestone/coal, linen/moss). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum NebulaTheme { - Nebula, - SilverLight, - SteelDark, - LimestoneLight, - CoalDark, - LinenLight, - MossDark, -} - -impl Default for NebulaTheme { - fn default() -> Self { - Self::Nebula - } -} - -impl NebulaTheme { - /// Resolve the light/dark member of the user's chosen theme family. - /// - /// Nebula is an intentionally standalone dark theme. When automatic mode - /// needs a light counterpart, Silver is the closest neutral match; the - /// original Nebula preference is kept separately so switching back to a - /// dark system appearance restores it instead of silently changing the - /// user's choice to Steel. - pub(crate) fn for_system_appearance(self, is_light: bool) -> Self { - match (self, is_light) { - (Self::Nebula, true) => Self::SilverLight, - (Self::Nebula, false) => Self::Nebula, - (Self::SilverLight | Self::SteelDark, true) => Self::SilverLight, - (Self::SilverLight | Self::SteelDark, false) => Self::SteelDark, - (Self::LimestoneLight | Self::CoalDark, true) => Self::LimestoneLight, - (Self::LimestoneLight | Self::CoalDark, false) => Self::CoalDark, - (Self::LinenLight | Self::MossDark, true) => Self::LinenLight, - (Self::LinenLight | Self::MossDark, false) => Self::MossDark, - } - } - - pub(crate) fn label(self) -> &'static str { - match self { - Self::Nebula => "Nebula", - Self::SilverLight => "Silver Light", - Self::SteelDark => "Steel Dark", - Self::LimestoneLight => "Limestone", - Self::CoalDark => "Coal Dark", - Self::LinenLight => "Linen Light", - Self::MossDark => "Moss Dark", - } - } - - pub(crate) fn prompt_name(self) -> &'static str { - match self { - Self::Nebula => "Nebula", - Self::SilverLight => "SilverLight", - Self::SteelDark => "SteelDark", - Self::LimestoneLight => "LimestoneLight", - Self::CoalDark => "CoalDark", - Self::LinenLight => "LinenLight", - Self::MossDark => "MossDark", - } - } - - /// Inverse of [`prompt_name`](Self::prompt_name); used to restore the - /// persisted theme from `nebula_settings.txt`. - pub(crate) fn from_prompt_name(name: &str) -> Option { - Some(match name { - "Nebula" => Self::Nebula, - "SilverLight" => Self::SilverLight, - "SteelDark" => Self::SteelDark, - "LimestoneLight" => Self::LimestoneLight, - "CoalDark" => Self::CoalDark, - "LinenLight" => Self::LinenLight, - "MossDark" => Self::MossDark, - _ => return None, - }) - } - - /// Shorter label for theme cards so long names fit within a single card. - pub(crate) fn short_label(self) -> &'static str { - match self { - Self::SilverLight => "Silver", - Self::LimestoneLight => "Limestone", - Self::LinenLight => "Linen", - Self::SteelDark => "Steel", - Self::CoalDark => "Coal", - Self::MossDark => "Moss", - _ => self.label(), - } - } - - /// The theme's single accent color — selection rings, toggles-on, slider - /// fill and the prompt caret. Kept in lock-step with the powerline `$accent` - /// bridge (see `tty::windows`) so chrome, settings panel and shell prompt - /// all shift together when the theme changes. Each value is chosen to - /// contrast its own theme surface (light themes get a dark accent, dark - /// themes a light one). - pub(crate) fn accent(self) -> Rgb { - match self { - Self::Nebula => Rgb::new(82, 168, 255), - Self::SilverLight => Rgb::new(73, 80, 87), - Self::SteelDark => Rgb::new(148, 163, 184), - Self::LimestoneLight => Rgb::new(88, 85, 76), - Self::CoalDark => Rgb::new(212, 212, 212), - Self::LinenLight => Rgb::new(95, 99, 95), - Self::MossDark => Rgb::new(163, 179, 163), - } - } - - /// Ink set for the settings page's miniature theme cards: each card is a - /// tiny terminal window painted in ITS OWN theme's colors, so the picker - /// answers "how do background, text and highlights sit together" instead - /// of just "what color is the panel". - /// - /// Light themes reuse the exact inks [`Self::apply_term_colors`] installs. - /// Dark themes take their real foreground/ANSI set from the user's scheme - /// at runtime — the cards use one fixed neutral stand-in so all dark cards - /// preview alike regardless of the configured scheme. - pub(crate) fn card_ink(self) -> CardInk { - if self.palette().is_light { - CardInk { - fg: Rgb::new(36, 41, 47), // = light Foreground (#24292f) - } - } else { - CardInk { fg: Rgb::new(214, 219, 227) } - } - } - - /// Rebuild the terminal color table for this theme on top of the user's - /// configured scheme (`defaults`). - /// - /// Every theme moves the default background — that is what OSC 11 reports, - /// and TUIs like Claude Code / lazygit key their light/dark mode off it. - /// Light themes additionally replace the foreground and the ANSI-16 set - /// with a low-saturation light scheme: the configured (dark-ground) colors - /// are pale by design and unreadable on a pale background. - pub(crate) fn apply_term_colors(self, colors: &mut List, defaults: &List) { - *colors = *defaults; - let p = self.palette(); - colors[NamedColor::Background] = p.term_bg; - // Powerline prompt slots: the injected prompt paints its segment chips - // with indexed colors 16..=23 instead of baked-in truecolor, so a - // theme switch remaps the palette and every chip ALREADY PRINTED in - // scrollback recolors instantly — indexed cells resolve the palette at - // draw time; truecolor is frozen the moment it is printed. - for (i, rgb) in self.powerline_colors().into_iter().enumerate() { - colors[POWERLINE_SLOT0 + i] = rgb; - } - if !p.is_light { - return; - } - - colors[NamedColor::Foreground] = Rgb::new(36, 41, 47); // #24292f - // GitHub Primer Light ANSI-16 (from the premium-light design sheet): - // deep ink hues tuned for a pure-white ground. BrightWhite is a - // gray on purpose — true white would vanish on the white terminal. - const LIGHT_ANSI: [(NamedColor, Rgb); 16] = [ - (NamedColor::Black, Rgb::new(36, 41, 47)), // #24292f - (NamedColor::Red, Rgb::new(207, 34, 46)), // #cf222e - (NamedColor::Green, Rgb::new(26, 127, 55)), // #1a7f37 - (NamedColor::Yellow, Rgb::new(154, 103, 0)), // #9a6700 - (NamedColor::Blue, Rgb::new(9, 105, 218)), // #0969da - (NamedColor::Magenta, Rgb::new(130, 80, 223)), // #8250df - (NamedColor::Cyan, Rgb::new(27, 124, 131)), // #1b7c83 - (NamedColor::White, Rgb::new(110, 119, 129)), // #6e7781 - (NamedColor::BrightBlack, Rgb::new(87, 96, 106)), // #57606a - (NamedColor::BrightRed, Rgb::new(164, 14, 38)), // #a40e26 - (NamedColor::BrightGreen, Rgb::new(45, 164, 78)), // #2da44e - (NamedColor::BrightYellow, Rgb::new(191, 135, 0)), // #bf8700 - (NamedColor::BrightBlue, Rgb::new(33, 139, 255)), // #218bff - (NamedColor::BrightMagenta, Rgb::new(164, 117, 249)), // #a475f9 - (NamedColor::BrightCyan, Rgb::new(49, 146, 170)), // #3192aa - (NamedColor::BrightWhite, Rgb::new(140, 149, 159)), // #8c959f - ]; - for (name, rgb) in LIGHT_ANSI { - colors[name] = rgb; - } - } - - /// Segment colors for the injected powerline prompt, published into the - /// 256-color palette at [`POWERLINE_SLOT0`]`..+8` by [`Self::apply_term_colors`]. - /// Order: icon bg/fg, path bg/fg, branch bg/fg, time bg/fg — one flat color - /// per chip (the old per-character truecolor gradient could never follow a - /// theme switch retroactively, which users read as "the prompt is stuck"). - pub(crate) fn powerline_colors(self) -> [Rgb; 8] { - match self { - Self::Nebula => [ - Rgb::new(57, 75, 112), - Rgb::new(192, 202, 245), - Rgb::new(41, 52, 82), - Rgb::new(169, 177, 214), - Rgb::new(47, 79, 79), - Rgb::new(139, 213, 202), - Rgb::new(29, 33, 46), - Rgb::new(100, 116, 139), - ], - Self::SilverLight => [ - Rgb::new(229, 231, 235), - Rgb::new(55, 65, 81), - Rgb::new(243, 244, 246), - Rgb::new(55, 65, 81), - Rgb::new(224, 242, 254), - Rgb::new(3, 105, 161), - Rgb::new(249, 250, 251), - Rgb::new(107, 114, 128), - ], - Self::SteelDark => [ - Rgb::new(71, 85, 105), - Rgb::new(241, 245, 249), - Rgb::new(51, 65, 85), - Rgb::new(203, 213, 225), - Rgb::new(59, 82, 73), - Rgb::new(163, 184, 153), - Rgb::new(40, 44, 56), - Rgb::new(148, 163, 184), - ], - Self::LimestoneLight => [ - Rgb::new(214, 211, 209), - Rgb::new(250, 250, 249), - Rgb::new(231, 229, 228), - Rgb::new(68, 64, 60), - Rgb::new(200, 198, 167), - Rgb::new(41, 37, 36), - Rgb::new(235, 233, 230), - Rgb::new(163, 160, 151), - ], - Self::CoalDark => [ - Rgb::new(82, 82, 82), - Rgb::new(245, 245, 245), - Rgb::new(64, 64, 64), - Rgb::new(212, 212, 212), - Rgb::new(74, 79, 65), - Rgb::new(181, 181, 166), - Rgb::new(48, 48, 48), - Rgb::new(115, 115, 115), - ], - Self::LinenLight => [ - Rgb::new(212, 212, 208), - Rgb::new(255, 255, 255), - Rgb::new(229, 229, 223), - Rgb::new(63, 63, 63), - Rgb::new(181, 196, 177), - Rgb::new(45, 45, 45), - Rgb::new(236, 236, 230), - Rgb::new(176, 179, 176), - ], - Self::MossDark => [ - Rgb::new(75, 85, 72), - Rgb::new(240, 253, 244), - Rgb::new(59, 66, 56), - Rgb::new(220, 252, 231), - Rgb::new(60, 79, 60), - Rgb::new(187, 247, 208), - Rgb::new(42, 47, 42), - Rgb::new(107, 114, 107), - ], - } - } - - pub(crate) fn palette(self) -> NebulaPalette { - match self { - Self::Nebula => NebulaPalette { - panel: Rgba::new(34, 38, 48, 224), - pill: Rgba::new(43, 48, 59, 218), - tab_stroke_l: Rgba::new(150, 157, 188, 132), - tab_bg_l: Rgba::new(65, 72, 88, 230), - tab_bg_r: Rgba::new(48, 54, 67, 226), - edge_l: Rgba::new(169, 152, 188, 180), - edge_r: Rgba::new(125, 178, 194, 180), - edge_glow_l: Rgba::new(169, 152, 188, 24), - glow_l: Rgba::new(169, 152, 188, 14), - glow_r: Rgba::new(125, 178, 194, 14), - is_light: false, - term_bg: Rgb::new(15, 17, 26), - shell_bg: Rgb::new(34, 38, 48), - }, - // Cool silver — the light half of the steel pair. Chrome layers - // follow the premium-light sheet: sidebar #f3f4f6 over app-bg - // #f9fafb, terminal pure white for maximum contrast. - Self::SilverLight => NebulaPalette { - // Neutral silver, blue removed: the panel/tab surfaces sit on a - // true-neutral gray ramp (was Tailwind's blue-leaning gray-100), - // and the active-tab halo is a soft neutral shadow instead of a - // blue wash — a pure-white pill lifting off a flat gray gutter. - panel: Rgba::new(245, 245, 246, 236), - pill: Rgba::new(233, 233, 234, 230), - tab_stroke_l: Rgba::new(198, 198, 200, 150), - tab_bg_l: Rgba::new(255, 255, 255, 242), - tab_bg_r: Rgba::new(250, 250, 251, 236), - edge_l: Rgba::new(110, 112, 116, 170), - edge_r: Rgba::new(118, 121, 126, 180), - edge_glow_l: Rgba::new(118, 121, 126, 18), - // Ambient glows are OFF on light themes: a ~4% alpha radial - // gradient over a pale backdrop lands on very few 8-bit steps, - // and the quantization contours read as blurry gray "lines" - // (invisible on the dark themes' deep backgrounds). - glow_l: Rgba::new(82, 168, 255, 0), - glow_r: Rgba::new(73, 80, 87, 0), - is_light: true, - // Pure white terminal on every light theme (premium-light - // sheet): highest contrast for the Primer ANSI ink set. - term_bg: Rgb::new(255, 255, 255), - // Premium-light app-bg layer (#f3f4f6-ish): the white terminal - // card floats on this neutral silver. - shell_bg: Rgb::new(243, 244, 246), - }, - // Warm limestone — the light half of the coal pair. - Self::LimestoneLight => NebulaPalette { - panel: Rgba::new(240, 239, 235, 236), - pill: Rgba::new(231, 229, 224, 230), - tab_stroke_l: Rgba::new(163, 160, 151, 150), - tab_bg_l: Rgba::new(255, 255, 255, 242), - tab_bg_r: Rgba::new(247, 246, 242, 236), - edge_l: Rgba::new(88, 85, 76, 160), - edge_r: Rgba::new(206, 178, 126, 190), - edge_glow_l: Rgba::new(206, 178, 126, 20), - // Ambient glow off on light themes (8-bit banding, see Silver). - glow_l: Rgba::new(206, 178, 126, 0), - glow_r: Rgba::new(88, 85, 76, 0), - is_light: true, - term_bg: Rgb::new(255, 255, 255), - shell_bg: Rgb::new(240, 239, 235), - }, - // Soft linen — the light half of the moss pair. - Self::LinenLight => NebulaPalette { - panel: Rgba::new(242, 242, 236, 236), - pill: Rgba::new(233, 233, 227, 230), - tab_stroke_l: Rgba::new(176, 179, 176, 150), - tab_bg_l: Rgba::new(255, 255, 255, 242), - tab_bg_r: Rgba::new(251, 251, 246, 236), - edge_l: Rgba::new(95, 99, 95, 160), - edge_r: Rgba::new(149, 175, 149, 190), - edge_glow_l: Rgba::new(149, 175, 149, 20), - // Ambient glow off on light themes (8-bit banding, see Silver). - glow_l: Rgba::new(149, 175, 149, 0), - glow_r: Rgba::new(95, 99, 95, 0), - is_light: true, - term_bg: Rgb::new(255, 255, 255), - shell_bg: Rgb::new(242, 242, 236), - }, - // The three dark themes from the floating-pill design sheet - // (steel blue-gray / coal warm-gold / moss green), low-saturation - // accents per the powerline sheet. - Self::SteelDark => NebulaPalette { - panel: Rgba::new(22, 24, 30, 224), - pill: Rgba::new(30, 33, 41, 218), - tab_stroke_l: Rgba::new(148, 163, 184, 124), - tab_bg_l: Rgba::new(52, 58, 72, 230), - tab_bg_r: Rgba::new(38, 43, 54, 226), - edge_l: Rgba::new(148, 163, 184, 170), - edge_r: Rgba::new(82, 168, 255, 168), - edge_glow_l: Rgba::new(148, 163, 184, 20), - glow_l: Rgba::new(148, 163, 184, 12), - glow_r: Rgba::new(82, 168, 255, 12), - is_light: false, - term_bg: Rgb::new(26, 28, 36), - shell_bg: Rgb::new(22, 24, 30), - }, - Self::CoalDark => NebulaPalette { - panel: Rgba::new(22, 22, 22, 224), - pill: Rgba::new(30, 30, 30, 218), - tab_stroke_l: Rgba::new(186, 186, 182, 120), - tab_bg_l: Rgba::new(56, 56, 54, 230), - tab_bg_r: Rgba::new(41, 41, 40, 226), - edge_l: Rgba::new(206, 178, 126, 172), - edge_r: Rgba::new(212, 212, 212, 148), - edge_glow_l: Rgba::new(206, 178, 126, 22), - glow_l: Rgba::new(206, 178, 126, 12), - glow_r: Rgba::new(212, 212, 212, 12), - is_light: false, - term_bg: Rgb::new(23, 23, 23), - shell_bg: Rgb::new(22, 22, 22), - }, - Self::MossDark => NebulaPalette { - panel: Rgba::new(25, 28, 25, 224), - pill: Rgba::new(33, 37, 33, 218), - tab_stroke_l: Rgba::new(163, 179, 163, 124), - tab_bg_l: Rgba::new(54, 61, 54, 230), - tab_bg_r: Rgba::new(40, 46, 40, 226), - edge_l: Rgba::new(149, 175, 149, 172), - edge_r: Rgba::new(163, 179, 163, 158), - edge_glow_l: Rgba::new(149, 175, 149, 22), - glow_l: Rgba::new(149, 175, 149, 12), - glow_r: Rgba::new(163, 179, 163, 12), - is_light: false, - term_bg: Rgb::new(30, 33, 30), - shell_bg: Rgb::new(25, 28, 25), - }, - } - } - - /// Theme-derived ink/surface tokens for every floating chrome layer. - /// See [`Skin`] for what each token means. - pub(crate) fn skin(self) -> Skin { - let p = self.palette(); - let a = self.accent(); - let t = p.term_bg; - // 浮层底从**窗口外壳** `shell_bg` 提亮 14 级,不是从终端底色 - // (2026-07-31 修正)。 - // - // 之前用的是 `term_bg`,理由写的是"浮层要比内容层亮"。方向没错, - // 起点错了:Nebula 的外壳 (34,38,48) 比终端 (15,17,26) 亮 20,于是 - // 从终端起跳的浮层落在 (29,31,40),比它实际漂浮的那张外壳还暗 5 级 - // ——弹窗陷进背景里,正是它想避免的那件事。原型的 (48,53,65) 就是 - // 外壳 +14。 - // - // 当初避开 `p.panel` 是对的(那是 chrome 面板色,Steel 下比终端暗), - // 但 `shell_bg` 是另一个字段,四个深色主题下都能让浮层高出终端 9–33 - // 级,方向对得上。 - let lift = |c: u8| c.saturating_add(14); - let shell = p.shell_bg; - if p.is_light { - Skin { - // 浅色的三层不是单调递增的明度阶梯,而是**凹槽**(2026-07-31 - // 裁定,推翻 07-29 的"浮层比内容更暗"):浮层底与输入面同为 - // 纯白,中间的分组卡片压暗一档挖出凹槽,输入框从凹槽里浮回 - // 表面。 - // - // 07-29 那版把方向做反了——panel 241 / card 254 / input 240, - // 卡片成了最亮的一层,输入框反而陷进卡片里。视觉上读成"白盒 - // 子套白盒子",输入框只能靠一条淡描边撑出来,而描边一多画面 - // 就碎。阶梯本身没错,错在浅色下**最亮的那层应该是可输入的 - // 表面**,不是承载它的容器。 - // - // 压暗用 slate 叠加而不是降低白的明度:纯黑叠白底得到零色相 - // 的死灰,掺蓝的冷调灰才不显脏(同 hairline/surface 的理由)。 - // alpha 取 255:原型的 panel 是不透明的,而且 `push_stroke` - // 靠上层填充盖住中心才形成描边环——底板只要留一丝透明,那圈 - // 描边就会从整块面板里渗出来。 - panel: Rgba::new(255, 255, 255, 255), - input: Rgba::new(255, 255, 255, 255), - card: Rgba::new(100, 116, 139, 11), // slate-500 @ 4.5% - // modal 专用(popover 不画遮罩,见设计文档裁定三)。冷调压暗 - // 而不是白雾:白雾 75% 把背景提亮到和白弹窗同明度,弹窗反而 - // 浮不起来,且糊掉全部上下文。20% 压暗下背景仍然可读。 - veil: Rgba::new(15, 23, 42, 56), // slate-900 @ 22% - ink: Rgb::new(51, 65, 85), // slate-700 - ink_dim: Rgb::new(100, 116, 139), // slate-500 - ink_strong: Rgb::new(15, 23, 42), // slate-900 - ink_faint: Rgb::new(148, 163, 184), // slate-400 - // Light accents are dark grays — pale ink on top. - ink_on_accent: Rgb::new(248, 250, 252), - icon: Rgb::new(71, 85, 105), // slate-600 - icon_hover: Rgb::new(15, 23, 42), // slate-900 - // 浅色下的强调色是**中性深灰**,不跟主题走(2026-07-31 用户裁定, - // 对齐原型)。浅色底本身亮度就高,饱和的主题色压上去会抢过内容 - // ——焦点框、主按钮、勾选框同时用一个艳色,画面立刻吵起来。原型 - // 里这三处全是同一个深灰,"柔和"就是从这来的。 - // - // 深色主题不受影响:那边底暗,主题 accent 是唯一的亮点,反而 - // 是画面的锚。 - accent: Rgb::new(73, 80, 87), - accent_soft: Rgba::new(73, 80, 87, 36), // 14% - // 浅色的红要比深色那份更沉:同一个红压在白底上,明度对比大得多, - // 照搬深色值会艳到从整张卡片里跳出来。 - danger: Rgba::new(178, 58, 72, 255), - // 2026-07-29 用户裁定:中性灰在屏幕上永远显脏。此前这几个 - // 叠加色是纯黑 rgba(0,0,0,.05~.19),而纯黑叠在白底上只能得 - // 到**零色相**的死灰——这就是"不干净"的物理来源。现在改叠 - // Slate(掺 3–6% 蓝的冷调灰):浅色主题叠深 slate,深色主题 - // 叠浅 slate,两边用不同基色,叠加后色相才不会被纯黑/纯白冲 - // 淡。alpha 按 (255-底)/(基色-底) 换算过,明度与旧值持平, - // 变的只是色温。 - hairline: Rgba::new(51, 65, 85, 30), // slate-700 @ 12% - surface: Rgba::new(100, 116, 139, 20), // slate-500 @ 8% - hover: Rgba::new(100, 116, 139, 33), - hover_strong: Rgba::new(100, 116, 139, 52), - track_off: Rgba::new(100, 116, 139, 78), - knob_off: Rgba::new(255, 255, 255, 255), - knob_on: Rgba::new(248, 250, 252, 255), // slate-50 - scrollbar_thumb: Rgba::new(71, 85, 105, 0), // slate-600 - is_light: true, - } - } else { - Skin { - // 深色下浮层的方向与浅色**相反**:比内容层更亮。 - // 统一表述是「越靠前,与内容层的对比越强」,方向由主题决定 - // (深色命令面板比背景亮,浅色命令面板比纯白终端暗)。 - // - // 阶梯:终端 26 → panel 40 → card 54,每层差 14。 - // - // alpha 取 255 而不是 250:原型的 panel 是不透明的,而且 - // `push_stroke` 靠上层填充盖住中心才形成描边环——底板只要 - // 留一丝透明,那圈描边就会从整块面板里渗出来。 - panel: Rgba::new(lift(shell.r), lift(shell.g), lift(shell.b), 255), - // 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, 219), // 86% - card: Rgba::new(255, 255, 255, 11), // 4.5% - // 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 - ink_faint: Rgb::new(100, 116, 139), // slate-500 - // Dark accents are light — near-black ink on top. - ink_on_accent: Rgb::new(15, 23, 42), // slate-900 - icon: Rgb::new(203, 213, 225), // slate-300 - icon_hover: Rgb::new(248, 250, 252), // slate-50 - accent: a, - accent_soft: Rgba::new(a.r, a.g, a.b, 46), - danger: Rgba::new(196, 74, 88, 255), - // 深色主题叠 slate-300 而不是纯白:白是中性色,叠上去会把 - // 底色的色相**冲淡**,一叠就回到死灰。浅色主题叠深 slate、 - // 深色主题叠浅 slate——这就是"深浅两套灰不一样"的技术原因。 - hairline: Rgba::new(203, 213, 225, 30), - surface: Rgba::new(203, 213, 225, 15), - hover: Rgba::new(203, 213, 225, 35), - hover_strong: Rgba::new(203, 213, 225, 53), - track_off: Rgba::new(203, 213, 225, 45), - knob_off: Rgba::new(203, 213, 225, 255), // slate-300 - knob_on: Rgba::new(15, 23, 42, 255), // slate-900 - scrollbar_thumb: Rgba::new(148, 163, 184, 0), // slate-400 - is_light: false, - } - } - } -} - -/// Per-theme chrome palette: the translucent panels, tab pills, edge accents -/// and glows painted by `draw_chrome`, plus the terminal background the theme -/// applies on selection. -#[derive(Debug, Clone, Copy)] -/// The text ink a miniature theme card draws its fake terminal lines with; -/// the highlight bars use the theme's own `edge_l`/`edge_r` brand pair. -/// See [`NebulaTheme::card_ink`]. -pub(crate) struct CardInk { - pub(crate) fg: Rgb, -} - -pub(crate) struct NebulaPalette { - pub(crate) panel: Rgba, - /// Standalone fill for inactive tab rows / the "+" pill. Currently unpainted - /// (inactive rows sit flush on the sidebar; state is the white active pill), - /// kept for reintroducing a per-row background without re-plumbing the palette. - #[allow(dead_code)] - pub(crate) pill: Rgba, - pub(crate) tab_stroke_l: Rgba, - pub(crate) tab_bg_l: Rgba, - pub(crate) tab_bg_r: Rgba, - pub(crate) edge_l: Rgba, - pub(crate) edge_r: Rgba, - pub(crate) edge_glow_l: Rgba, - pub(crate) glow_l: Rgba, - pub(crate) glow_r: Rgba, - /// Light chrome theme: flips the chrome ink set (labels/icons) to dark - /// text so it stays readable on the pale surfaces. - pub(crate) is_light: bool, - /// The theme's default terminal background, applied on selection. - pub(crate) term_bg: Rgb, - /// Opaque window shell base color. The whole window clears to this; the - /// terminal renders as a rounded [`term_bg`] card floating on top, and the - /// chrome (top bar / sidebar) melts into it. Slightly offset from `panel`'s - /// RGB so the translucent panels still read on top of it. - pub(crate) shell_bg: Rgb, -} - -/// Theme-derived skin for every floating chrome layer: the settings modal, -/// confirm dialogs, the command palette, the resize HUD, scrollbar and the -/// chrome ink set. One struct so light themes flip EVERY overlay at once — -/// components must not keep private color constants. -/// -/// Naming: `ink*` are text colors (strong > ink > dim > faint), `panel` / -/// `input` / `surface` are fills from back to front, `hover*` are transient -/// washes stacked on top, and `accent` / `danger` are the only saturated -/// voices (selection/primary vs destructive). -#[derive(Debug, Clone, Copy)] -pub(crate) struct Skin { - /// Near-opaque panel surface (kills the see-through bleed where the - /// shell's own powerline used to collide with overlay labels). - pub(crate) panel: Rgba, - /// Inset/input surface (command palette query box and friends). - pub(crate) input: Rgba, - /// Elevated card fill for picker rows: a soft lift on the gray panel - /// (white cards on light themes, a faint white wash on dark). - pub(crate) card: Rgba, - /// Full-window wash behind modals. 2026-07-29 用户裁定:浅色主题的弹窗 - /// 遮罩用白雾压淡而不是黑幕压暗;深色主题保持黑色调暗。 - pub(crate) veil: Rgba, - /// Primary label ink. - pub(crate) ink: Rgb, - /// Secondary / sub-label ink. - pub(crate) ink_dim: Rgb, - /// Titles, active nav row, selected list rows. - pub(crate) ink_strong: Rgb, - /// Placeholder / hint text (weakest voice). - pub(crate) ink_faint: Rgb, - /// Ink on top of an `accent`-filled control (primary buttons). - pub(crate) ink_on_accent: Rgb, - /// Chrome glyph icons (sidebar toggle, settings gear, tab ×, …). - pub(crate) icon: Rgb, - pub(crate) icon_hover: Rgb, - /// The theme's single accent (selection ring, toggle-on, slider fill). - pub(crate) accent: Rgb, - /// Soft accent wash for the active nav pill and selected card fill. - pub(crate) accent_soft: Rgba, - /// Destructive primary actions (close-busy-pane confirm). Same on both - /// light and dark — semantic red doesn't flip. - pub(crate) danger: Rgba, - /// Edges, separators and quiet control borders. - pub(crate) hairline: Rgba, - /// Faint lift for interactive rows and cards. - pub(crate) surface: Rgba, - /// Hover wash on rows and cards. - pub(crate) hover: Rgba, - /// Stronger hover wash for small icon targets. - pub(crate) hover_strong: Rgba, - /// Toggle-off track / slider rail. - pub(crate) track_off: Rgba, - /// Toggle knob when off / on (chosen to contrast the track). - pub(crate) knob_off: Rgba, - pub(crate) knob_on: Rgba, - /// Scrollbar thumb base color; alpha applied at the call site (drag - /// feedback brightens it). - pub(crate) scrollbar_thumb: Rgba, - /// True on the pale themes — lets renderers pick a brighter bevel and the - /// right slider-thumb ink without re-deriving it from the palette. - pub(crate) is_light: bool, -} - -/// Publish the active theme for the shell prompt bridge: the powerline script -/// polls `%TEMP%\nebula_theme.txt` and recolors its segments to match. Written -/// atomically (tmp + rename) so readers never see a torn value. -pub(crate) fn write_nebula_prompt_theme(theme: NebulaTheme) { - let dir = std::env::temp_dir(); - let path = dir.join("nebula_theme.txt"); - let tmp = dir.join(format!("nebula_theme.{}.tmp", std::process::id())); - - if std::fs::write(&tmp, theme.prompt_name()).is_ok() { - // Windows cannot always rename over an existing file with `std::fs::rename`. - // The prompt script treats a missing/invalid theme as Nebula, so even the - // fallback path stays safe; the temporary file prevents readers from seeing - // partially-written contents. - let _ = std::fs::rename(&tmp, &path).or_else(|_| { - let _ = std::fs::remove_file(&path); - std::fs::rename(&tmp, &path) - }); - } -} - -#[cfg(test)] -mod tests { - use super::NebulaTheme; - - #[test] - fn system_appearance_keeps_the_selected_theme_family() { - assert_eq!(NebulaTheme::Nebula.for_system_appearance(true), NebulaTheme::SilverLight); - assert_eq!(NebulaTheme::Nebula.for_system_appearance(false), NebulaTheme::Nebula); - assert_eq!(NebulaTheme::SilverLight.for_system_appearance(false), NebulaTheme::SteelDark); - assert_eq!(NebulaTheme::SteelDark.for_system_appearance(true), NebulaTheme::SilverLight); - assert_eq!(NebulaTheme::LimestoneLight.for_system_appearance(false), NebulaTheme::CoalDark); - assert_eq!(NebulaTheme::CoalDark.for_system_appearance(true), NebulaTheme::LimestoneLight); - assert_eq!(NebulaTheme::LinenLight.for_system_appearance(false), NebulaTheme::MossDark); - assert_eq!(NebulaTheme::MossDark.for_system_appearance(true), NebulaTheme::LinenLight); - } -} +//! Nebula theme system — the single source of truth for every chrome color. +//! +//! Everything visual that is NOT terminal-grid content reads from here: +//! the seven built-in themes (from the low-saturation powerline design +//! sheet: the deep-blue default plus three light/dark pairs), each theme's +//! chrome palette ([`NebulaPalette`]) and its full overlay ink set +//! ([`Skin`]). The settings modal, confirm dialogs, the command palette, +//! resize HUD, scrollbar and the tab/window chrome all pull their colors +//! from these two structs — no component keeps a private color constant, so +//! a theme switch (including light ↔ dark) restyles every surface at once. +//! +//! Design language (from the sheet): low-saturation surfaces, hierarchy by +//! brightness not borders, ONE accent per theme, semantic red reserved for +//! destructive actions. Light themes flip the whole ink set to dark-on-light +//! rather than dimming the dark inks. +//! +//! Adding a theme = one enum variant + one `palette()` arm + one `accent()` +//! arm (+ a card slot in the settings grid and a palette action). The +//! [`Skin`] derives from those automatically via `is_light`. + +use crate::display::color::{List, Rgb}; +use crate::renderer::ui::Rgba; +use nebula_terminal::vte::ansi::NamedColor; + +/// First 256-color palette slot claimed for the powerline prompt chips +/// (16..=23: icon bg/fg, path bg/fg, branch bg/fg, time bg/fg). Chosen at the +/// very start of the 6×6×6 cube — the darkest corner, rarely load-bearing for +/// TUIs — so hijacking eight slots stays invisible in practice. +pub(crate) const POWERLINE_SLOT0: usize = 16; + +/// Built-in Nebula chrome themes exposed from the settings panel — the seven +/// looks from the design sheet: the deep-blue default plus three light/dark +/// low-saturation pairs (silver/steel, limestone/coal, linen/moss). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NebulaTheme { + Nebula, + SilverLight, + SteelDark, + LimestoneLight, + CoalDark, + LinenLight, + MossDark, +} + +impl Default for NebulaTheme { + fn default() -> Self { + Self::Nebula + } +} + +impl NebulaTheme { + /// Resolve the light/dark member of the user's chosen theme family. + /// + /// Nebula is an intentionally standalone dark theme. When automatic mode + /// needs a light counterpart, Silver is the closest neutral match; the + /// original Nebula preference is kept separately so switching back to a + /// dark system appearance restores it instead of silently changing the + /// user's choice to Steel. + pub(crate) fn for_system_appearance(self, is_light: bool) -> Self { + match (self, is_light) { + (Self::Nebula, true) => Self::SilverLight, + (Self::Nebula, false) => Self::Nebula, + (Self::SilverLight | Self::SteelDark, true) => Self::SilverLight, + (Self::SilverLight | Self::SteelDark, false) => Self::SteelDark, + (Self::LimestoneLight | Self::CoalDark, true) => Self::LimestoneLight, + (Self::LimestoneLight | Self::CoalDark, false) => Self::CoalDark, + (Self::LinenLight | Self::MossDark, true) => Self::LinenLight, + (Self::LinenLight | Self::MossDark, false) => Self::MossDark, + } + } + + pub(crate) fn label(self) -> &'static str { + match self { + Self::Nebula => "Nebula", + Self::SilverLight => "Silver Light", + Self::SteelDark => "Steel Dark", + Self::LimestoneLight => "Limestone", + Self::CoalDark => "Coal Dark", + Self::LinenLight => "Linen Light", + Self::MossDark => "Moss Dark", + } + } + + pub(crate) fn prompt_name(self) -> &'static str { + match self { + Self::Nebula => "Nebula", + Self::SilverLight => "SilverLight", + Self::SteelDark => "SteelDark", + Self::LimestoneLight => "LimestoneLight", + Self::CoalDark => "CoalDark", + Self::LinenLight => "LinenLight", + Self::MossDark => "MossDark", + } + } + + /// Inverse of [`prompt_name`](Self::prompt_name); used to restore the + /// persisted theme from `nebula_settings.txt`. + pub(crate) fn from_prompt_name(name: &str) -> Option { + Some(match name { + "Nebula" => Self::Nebula, + "SilverLight" => Self::SilverLight, + "SteelDark" => Self::SteelDark, + "LimestoneLight" => Self::LimestoneLight, + "CoalDark" => Self::CoalDark, + "LinenLight" => Self::LinenLight, + "MossDark" => Self::MossDark, + _ => return None, + }) + } + + /// Shorter label for theme cards so long names fit within a single card. + pub(crate) fn short_label(self) -> &'static str { + match self { + Self::SilverLight => "Silver", + Self::LimestoneLight => "Limestone", + Self::LinenLight => "Linen", + Self::SteelDark => "Steel", + Self::CoalDark => "Coal", + Self::MossDark => "Moss", + _ => self.label(), + } + } + + /// The theme's single accent color — selection rings, toggles-on, slider + /// fill and the prompt caret. Kept in lock-step with the powerline `$accent` + /// bridge (see `tty::windows`) so chrome, settings panel and shell prompt + /// all shift together when the theme changes. Each value is chosen to + /// contrast its own theme surface (light themes get a dark accent, dark + /// themes a light one). + pub(crate) fn accent(self) -> Rgb { + match self { + Self::Nebula => Rgb::new(82, 168, 255), + Self::SilverLight => Rgb::new(73, 80, 87), + Self::SteelDark => Rgb::new(148, 163, 184), + Self::LimestoneLight => Rgb::new(88, 85, 76), + Self::CoalDark => Rgb::new(212, 212, 212), + Self::LinenLight => Rgb::new(95, 99, 95), + Self::MossDark => Rgb::new(163, 179, 163), + } + } + + /// Ink set for the settings page's miniature theme cards: each card is a + /// tiny terminal window painted in ITS OWN theme's colors, so the picker + /// answers "how do background, text and highlights sit together" instead + /// of just "what color is the panel". + /// + /// Light themes reuse the exact inks [`Self::apply_term_colors`] installs. + /// Dark themes take their real foreground/ANSI set from the user's scheme + /// at runtime — the cards use one fixed neutral stand-in so all dark cards + /// preview alike regardless of the configured scheme. + pub(crate) fn card_ink(self) -> CardInk { + if self.palette().is_light { + CardInk { + fg: Rgb::new(36, 41, 47), // = light Foreground (#24292f) + } + } else { + CardInk { fg: Rgb::new(214, 219, 227) } + } + } + + /// Rebuild the terminal color table for this theme on top of the user's + /// configured scheme (`defaults`). + /// + /// Every theme moves the default background — that is what OSC 11 reports, + /// and TUIs like Claude Code / lazygit key their light/dark mode off it. + /// Light themes additionally replace the foreground and the ANSI-16 set + /// with a low-saturation light scheme: the configured (dark-ground) colors + /// are pale by design and unreadable on a pale background. + pub(crate) fn apply_term_colors(self, colors: &mut List, defaults: &List) { + *colors = *defaults; + let p = self.palette(); + colors[NamedColor::Background] = p.term_bg; + // Powerline prompt slots: the injected prompt paints its segment chips + // with indexed colors 16..=23 instead of baked-in truecolor, so a + // theme switch remaps the palette and every chip ALREADY PRINTED in + // scrollback recolors instantly — indexed cells resolve the palette at + // draw time; truecolor is frozen the moment it is printed. + for (i, rgb) in self.powerline_colors().into_iter().enumerate() { + colors[POWERLINE_SLOT0 + i] = rgb; + } + if !p.is_light { + return; + } + + colors[NamedColor::Foreground] = Rgb::new(36, 41, 47); // #24292f + // GitHub Primer Light ANSI-16 (from the premium-light design sheet): + // deep ink hues tuned for a pure-white ground. BrightWhite is a + // gray on purpose — true white would vanish on the white terminal. + const LIGHT_ANSI: [(NamedColor, Rgb); 16] = [ + (NamedColor::Black, Rgb::new(36, 41, 47)), // #24292f + (NamedColor::Red, Rgb::new(207, 34, 46)), // #cf222e + (NamedColor::Green, Rgb::new(26, 127, 55)), // #1a7f37 + (NamedColor::Yellow, Rgb::new(154, 103, 0)), // #9a6700 + (NamedColor::Blue, Rgb::new(9, 105, 218)), // #0969da + (NamedColor::Magenta, Rgb::new(130, 80, 223)), // #8250df + (NamedColor::Cyan, Rgb::new(27, 124, 131)), // #1b7c83 + (NamedColor::White, Rgb::new(110, 119, 129)), // #6e7781 + (NamedColor::BrightBlack, Rgb::new(87, 96, 106)), // #57606a + (NamedColor::BrightRed, Rgb::new(164, 14, 38)), // #a40e26 + (NamedColor::BrightGreen, Rgb::new(45, 164, 78)), // #2da44e + (NamedColor::BrightYellow, Rgb::new(191, 135, 0)), // #bf8700 + (NamedColor::BrightBlue, Rgb::new(33, 139, 255)), // #218bff + (NamedColor::BrightMagenta, Rgb::new(164, 117, 249)), // #a475f9 + (NamedColor::BrightCyan, Rgb::new(49, 146, 170)), // #3192aa + (NamedColor::BrightWhite, Rgb::new(140, 149, 159)), // #8c959f + ]; + for (name, rgb) in LIGHT_ANSI { + colors[name] = rgb; + } + } + + /// Segment colors for the injected powerline prompt, published into the + /// 256-color palette at [`POWERLINE_SLOT0`]`..+8` by [`Self::apply_term_colors`]. + /// Order: icon bg/fg, path bg/fg, branch bg/fg, time bg/fg — one flat color + /// per chip (the old per-character truecolor gradient could never follow a + /// theme switch retroactively, which users read as "the prompt is stuck"). + pub(crate) fn powerline_colors(self) -> [Rgb; 8] { + match self { + Self::Nebula => [ + Rgb::new(57, 75, 112), + Rgb::new(192, 202, 245), + Rgb::new(41, 52, 82), + Rgb::new(169, 177, 214), + Rgb::new(47, 79, 79), + Rgb::new(139, 213, 202), + Rgb::new(29, 33, 46), + Rgb::new(100, 116, 139), + ], + Self::SilverLight => [ + Rgb::new(229, 231, 235), + Rgb::new(55, 65, 81), + Rgb::new(243, 244, 246), + Rgb::new(55, 65, 81), + Rgb::new(224, 242, 254), + Rgb::new(3, 105, 161), + Rgb::new(249, 250, 251), + Rgb::new(107, 114, 128), + ], + Self::SteelDark => [ + Rgb::new(71, 85, 105), + Rgb::new(241, 245, 249), + Rgb::new(51, 65, 85), + Rgb::new(203, 213, 225), + Rgb::new(59, 82, 73), + Rgb::new(163, 184, 153), + Rgb::new(40, 44, 56), + Rgb::new(148, 163, 184), + ], + Self::LimestoneLight => [ + Rgb::new(214, 211, 209), + Rgb::new(250, 250, 249), + Rgb::new(231, 229, 228), + Rgb::new(68, 64, 60), + Rgb::new(200, 198, 167), + Rgb::new(41, 37, 36), + Rgb::new(235, 233, 230), + Rgb::new(163, 160, 151), + ], + Self::CoalDark => [ + Rgb::new(82, 82, 82), + Rgb::new(245, 245, 245), + Rgb::new(64, 64, 64), + Rgb::new(212, 212, 212), + Rgb::new(74, 79, 65), + Rgb::new(181, 181, 166), + Rgb::new(48, 48, 48), + Rgb::new(115, 115, 115), + ], + Self::LinenLight => [ + Rgb::new(212, 212, 208), + Rgb::new(255, 255, 255), + Rgb::new(229, 229, 223), + Rgb::new(63, 63, 63), + Rgb::new(181, 196, 177), + Rgb::new(45, 45, 45), + Rgb::new(236, 236, 230), + Rgb::new(176, 179, 176), + ], + Self::MossDark => [ + Rgb::new(75, 85, 72), + Rgb::new(240, 253, 244), + Rgb::new(59, 66, 56), + Rgb::new(220, 252, 231), + Rgb::new(60, 79, 60), + Rgb::new(187, 247, 208), + Rgb::new(42, 47, 42), + Rgb::new(107, 114, 107), + ], + } + } + + pub(crate) fn palette(self) -> NebulaPalette { + match self { + Self::Nebula => NebulaPalette { + panel: Rgba::new(34, 38, 48, 224), + pill: Rgba::new(43, 48, 59, 218), + tab_stroke_l: Rgba::new(150, 157, 188, 132), + tab_bg_l: Rgba::new(65, 72, 88, 230), + tab_bg_r: Rgba::new(48, 54, 67, 226), + edge_l: Rgba::new(169, 152, 188, 180), + edge_r: Rgba::new(125, 178, 194, 180), + edge_glow_l: Rgba::new(169, 152, 188, 24), + glow_l: Rgba::new(169, 152, 188, 14), + glow_r: Rgba::new(125, 178, 194, 14), + is_light: false, + term_bg: Rgb::new(15, 17, 26), + shell_bg: Rgb::new(34, 38, 48), + }, + // Cool silver — the light half of the steel pair. Chrome layers + // follow the premium-light sheet: sidebar #f3f4f6 over app-bg + // #f9fafb, terminal pure white for maximum contrast. + Self::SilverLight => NebulaPalette { + // Neutral silver, blue removed: the panel/tab surfaces sit on a + // true-neutral gray ramp (was Tailwind's blue-leaning gray-100), + // and the active-tab halo is a soft neutral shadow instead of a + // blue wash — a pure-white pill lifting off a flat gray gutter. + panel: Rgba::new(245, 245, 246, 236), + pill: Rgba::new(233, 233, 234, 230), + tab_stroke_l: Rgba::new(198, 198, 200, 150), + tab_bg_l: Rgba::new(255, 255, 255, 242), + tab_bg_r: Rgba::new(250, 250, 251, 236), + edge_l: Rgba::new(110, 112, 116, 170), + edge_r: Rgba::new(118, 121, 126, 180), + edge_glow_l: Rgba::new(118, 121, 126, 18), + // Ambient glows are OFF on light themes: a ~4% alpha radial + // gradient over a pale backdrop lands on very few 8-bit steps, + // and the quantization contours read as blurry gray "lines" + // (invisible on the dark themes' deep backgrounds). + glow_l: Rgba::new(82, 168, 255, 0), + glow_r: Rgba::new(73, 80, 87, 0), + is_light: true, + // Pure white terminal on every light theme (premium-light + // sheet): highest contrast for the Primer ANSI ink set. + term_bg: Rgb::new(255, 255, 255), + // Premium-light app-bg layer (#f3f4f6-ish): the white terminal + // card floats on this neutral silver. + shell_bg: Rgb::new(243, 244, 246), + }, + // Warm limestone — the light half of the coal pair. + Self::LimestoneLight => NebulaPalette { + panel: Rgba::new(240, 239, 235, 236), + pill: Rgba::new(231, 229, 224, 230), + tab_stroke_l: Rgba::new(163, 160, 151, 150), + tab_bg_l: Rgba::new(255, 255, 255, 242), + tab_bg_r: Rgba::new(247, 246, 242, 236), + edge_l: Rgba::new(88, 85, 76, 160), + edge_r: Rgba::new(206, 178, 126, 190), + edge_glow_l: Rgba::new(206, 178, 126, 20), + // Ambient glow off on light themes (8-bit banding, see Silver). + glow_l: Rgba::new(206, 178, 126, 0), + glow_r: Rgba::new(88, 85, 76, 0), + is_light: true, + term_bg: Rgb::new(255, 255, 255), + shell_bg: Rgb::new(240, 239, 235), + }, + // Soft linen — the light half of the moss pair. + Self::LinenLight => NebulaPalette { + panel: Rgba::new(242, 242, 236, 236), + pill: Rgba::new(233, 233, 227, 230), + tab_stroke_l: Rgba::new(176, 179, 176, 150), + tab_bg_l: Rgba::new(255, 255, 255, 242), + tab_bg_r: Rgba::new(251, 251, 246, 236), + edge_l: Rgba::new(95, 99, 95, 160), + edge_r: Rgba::new(149, 175, 149, 190), + edge_glow_l: Rgba::new(149, 175, 149, 20), + // Ambient glow off on light themes (8-bit banding, see Silver). + glow_l: Rgba::new(149, 175, 149, 0), + glow_r: Rgba::new(95, 99, 95, 0), + is_light: true, + term_bg: Rgb::new(255, 255, 255), + shell_bg: Rgb::new(242, 242, 236), + }, + // The three dark themes from the floating-pill design sheet + // (steel blue-gray / coal warm-gold / moss green), low-saturation + // accents per the powerline sheet. + Self::SteelDark => NebulaPalette { + panel: Rgba::new(22, 24, 30, 224), + pill: Rgba::new(30, 33, 41, 218), + tab_stroke_l: Rgba::new(148, 163, 184, 124), + tab_bg_l: Rgba::new(52, 58, 72, 230), + tab_bg_r: Rgba::new(38, 43, 54, 226), + edge_l: Rgba::new(148, 163, 184, 170), + edge_r: Rgba::new(82, 168, 255, 168), + edge_glow_l: Rgba::new(148, 163, 184, 20), + glow_l: Rgba::new(148, 163, 184, 12), + glow_r: Rgba::new(82, 168, 255, 12), + is_light: false, + term_bg: Rgb::new(26, 28, 36), + shell_bg: Rgb::new(22, 24, 30), + }, + Self::CoalDark => NebulaPalette { + panel: Rgba::new(22, 22, 22, 224), + pill: Rgba::new(30, 30, 30, 218), + tab_stroke_l: Rgba::new(186, 186, 182, 120), + tab_bg_l: Rgba::new(56, 56, 54, 230), + tab_bg_r: Rgba::new(41, 41, 40, 226), + edge_l: Rgba::new(206, 178, 126, 172), + edge_r: Rgba::new(212, 212, 212, 148), + edge_glow_l: Rgba::new(206, 178, 126, 22), + glow_l: Rgba::new(206, 178, 126, 12), + glow_r: Rgba::new(212, 212, 212, 12), + is_light: false, + term_bg: Rgb::new(23, 23, 23), + shell_bg: Rgb::new(22, 22, 22), + }, + Self::MossDark => NebulaPalette { + panel: Rgba::new(25, 28, 25, 224), + pill: Rgba::new(33, 37, 33, 218), + tab_stroke_l: Rgba::new(163, 179, 163, 124), + tab_bg_l: Rgba::new(54, 61, 54, 230), + tab_bg_r: Rgba::new(40, 46, 40, 226), + edge_l: Rgba::new(149, 175, 149, 172), + edge_r: Rgba::new(163, 179, 163, 158), + edge_glow_l: Rgba::new(149, 175, 149, 22), + glow_l: Rgba::new(149, 175, 149, 12), + glow_r: Rgba::new(163, 179, 163, 12), + is_light: false, + term_bg: Rgb::new(30, 33, 30), + shell_bg: Rgb::new(25, 28, 25), + }, + } + } + + /// Theme-derived ink/surface tokens for every floating chrome layer. + /// See [`Skin`] for what each token means. + pub(crate) fn skin(self) -> Skin { + let p = self.palette(); + let a = self.accent(); + let t = p.term_bg; + // 浮层底从**窗口外壳** `shell_bg` 提亮 14 级,不是从终端底色 + // (2026-07-31 修正)。 + // + // 之前用的是 `term_bg`,理由写的是"浮层要比内容层亮"。方向没错, + // 起点错了:Nebula 的外壳 (34,38,48) 比终端 (15,17,26) 亮 20,于是 + // 从终端起跳的浮层落在 (29,31,40),比它实际漂浮的那张外壳还暗 5 级 + // ——弹窗陷进背景里,正是它想避免的那件事。原型的 (48,53,65) 就是 + // 外壳 +14。 + // + // 当初避开 `p.panel` 是对的(那是 chrome 面板色,Steel 下比终端暗), + // 但 `shell_bg` 是另一个字段,四个深色主题下都能让浮层高出终端 9–33 + // 级,方向对得上。 + // 提亮按**比例**而不是加常数。旧版 `c + 14` 在 Nebula 外壳 + // (34,38,48) 上给出 (48,52,62),而原型 panel 是 (48,53,65)——差的 + // 全在蓝:外壳的冷调来自蓝比红高 14 级,每个通道同加一个常数后, + // 蓝的**相对**占比被摊薄,色温朝中性灰漂,深色底上读出来就是"发 + // 绿"。乘法把三个通道按同一比例抬,蓝仍然领先同样的比例,冷调 + // 保得住;对 Coal 那种三通道相等的外壳,两种算法给出同一个数。 + // 系数取「均值 +14」换算:亮度阶梯跟旧版持平,变的只是色温。 + let shell = p.shell_bg; + let shell_avg = ((shell.r as f32 + shell.g as f32 + shell.b as f32) / 3.0).max(1.0); + let lift_factor = (shell_avg + 14.0) / shell_avg; + let lift = |c: u8| ((c as f32 * lift_factor).round().min(255.0)) as u8; + if p.is_light { + Skin { + // 浅色的三层不是单调递增的明度阶梯,而是**凹槽**(2026-07-31 + // 裁定,推翻 07-29 的"浮层比内容更暗"):浮层底与输入面同为 + // 纯白,中间的分组卡片压暗一档挖出凹槽,输入框从凹槽里浮回 + // 表面。 + // + // 07-29 那版把方向做反了——panel 241 / card 254 / input 240, + // 卡片成了最亮的一层,输入框反而陷进卡片里。视觉上读成"白盒 + // 子套白盒子",输入框只能靠一条淡描边撑出来,而描边一多画面 + // 就碎。阶梯本身没错,错在浅色下**最亮的那层应该是可输入的 + // 表面**,不是承载它的容器。 + // + // 压暗用 slate 叠加而不是降低白的明度:纯黑叠白底得到零色相 + // 的死灰,掺蓝的冷调灰才不显脏(同 hairline/surface 的理由)。 + // alpha 取 255:原型的 panel 是不透明的,而且 `push_stroke` + // 靠上层填充盖住中心才形成描边环——底板只要留一丝透明,那圈 + // 描边就会从整块面板里渗出来。 + panel: Rgba::new(255, 255, 255, 255), + input: Rgba::new(255, 255, 255, 255), + card: Rgba::new(100, 116, 139, 11), // slate-500 @ 4.5% + // modal 专用(popover 不画遮罩,见设计文档裁定三)。冷调压暗 + // 而不是白雾:白雾 75% 把背景提亮到和白弹窗同明度,弹窗反而 + // 浮不起来,且糊掉全部上下文。20% 压暗下背景仍然可读。 + veil: Rgba::new(15, 23, 42, 56), // slate-900 @ 22% + ink: Rgb::new(51, 65, 85), // slate-700 + ink_dim: Rgb::new(100, 116, 139), // slate-500 + ink_strong: Rgb::new(15, 23, 42), // slate-900 + ink_faint: Rgb::new(148, 163, 184), // slate-400 + // Light accents are dark grays — pale ink on top. + ink_on_accent: Rgb::new(248, 250, 252), + icon: Rgb::new(71, 85, 105), // slate-600 + icon_hover: Rgb::new(15, 23, 42), // slate-900 + // 浅色下的强调色是**中性深灰**,不跟主题走(2026-07-31 用户裁定, + // 对齐原型)。浅色底本身亮度就高,饱和的主题色压上去会抢过内容 + // ——焦点框、主按钮、勾选框同时用一个艳色,画面立刻吵起来。原型 + // 里这三处全是同一个深灰,"柔和"就是从这来的。 + // + // 深色主题不受影响:那边底暗,主题 accent 是唯一的亮点,反而 + // 是画面的锚。 + accent: Rgb::new(73, 80, 87), + accent_soft: Rgba::new(73, 80, 87, 36), // 14% + // 浅色的红要比深色那份更沉:同一个红压在白底上,明度对比大得多, + // 照搬深色值会艳到从整张卡片里跳出来。 + danger: Rgba::new(178, 58, 72, 255), + // 成功 / 等待与 danger 同构:语义色各备深浅两套,浅底上一律 + // 更沉——同一个饱和色压在高明度背景上明度对比大得多,照搬深色 + // 那份会艳到从界面里跳出来。 + // + // 它们不跟主题 accent 走:accent 是品牌位(Nebula 蓝、Moss + // 绿),而"失败/完成/等你"是**语义**,在绿主题下把警示色也调 + // 成绿的,等于把警告说成一切正常。 + ok: Rgba::new(84, 140, 158, 255), // 青,与原型 --ok 同值 + warn: Rgba::new(217, 119, 6, 255), // amber-600 + // 2026-07-29 用户裁定:中性灰在屏幕上永远显脏。此前这几个 + // 叠加色是纯黑 rgba(0,0,0,.05~.19),而纯黑叠在白底上只能得 + // 到**零色相**的死灰——这就是"不干净"的物理来源。现在改叠 + // Slate(掺 3–6% 蓝的冷调灰):浅色主题叠深 slate,深色主题 + // 叠浅 slate,两边用不同基色,叠加后色相才不会被纯黑/纯白冲 + // 淡。alpha 按 (255-底)/(基色-底) 换算过,明度与旧值持平, + // 变的只是色温。 + hairline: Rgba::new(51, 65, 85, 30), // slate-700 @ 12% + surface: Rgba::new(100, 116, 139, 20), // slate-500 @ 8% + hover: Rgba::new(100, 116, 139, 33), + hover_strong: Rgba::new(100, 116, 139, 52), + track_off: Rgba::new(100, 116, 139, 78), + knob_off: Rgba::new(255, 255, 255, 255), + knob_on: Rgba::new(248, 250, 252, 255), // slate-50 + scrollbar_thumb: Rgba::new(71, 85, 105, 0), // slate-600 + is_light: true, + } + } else { + Skin { + // 深色下浮层的方向与浅色**相反**:比内容层更亮。 + // 统一表述是「越靠前,与内容层的对比越强」,方向由主题决定 + // (深色命令面板比背景亮,浅色命令面板比纯白终端暗)。 + // + // 阶梯:终端 26 → panel 40 → card 54,每层差 14。 + // + // alpha 取 255 而不是 250:原型的 panel 是不透明的,而且 + // `push_stroke` 靠上层填充盖住中心才形成描边环——底板只要 + // 留一丝透明,那圈描边就会从整块面板里渗出来。 + panel: Rgba::new(lift(shell.r), lift(shell.g), lift(shell.b), 255), + // 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, 219), // 86% + card: Rgba::new(255, 255, 255, 11), // 4.5% + // 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 + ink_faint: Rgb::new(100, 116, 139), // slate-500 + // Dark accents are light — near-black ink on top. + ink_on_accent: Rgb::new(15, 23, 42), // slate-900 + icon: Rgb::new(203, 213, 225), // slate-300 + icon_hover: Rgb::new(248, 250, 252), // slate-50 + accent: a, + accent_soft: Rgba::new(a.r, a.g, a.b, 46), + danger: Rgba::new(196, 74, 88, 255), + ok: Rgba::new(125, 178, 194, 255), // 青,与原型 --ok 同值 + warn: Rgba::new(245, 158, 11, 255), // amber-500 + // 深色主题叠 slate-300 而不是纯白:白是中性色,叠上去会把 + // 底色的色相**冲淡**,一叠就回到死灰。浅色主题叠深 slate、 + // 深色主题叠浅 slate——这就是"深浅两套灰不一样"的技术原因。 + hairline: Rgba::new(203, 213, 225, 30), + surface: Rgba::new(203, 213, 225, 15), + hover: Rgba::new(203, 213, 225, 35), + hover_strong: Rgba::new(203, 213, 225, 53), + track_off: Rgba::new(203, 213, 225, 45), + knob_off: Rgba::new(203, 213, 225, 255), // slate-300 + knob_on: Rgba::new(15, 23, 42, 255), // slate-900 + scrollbar_thumb: Rgba::new(148, 163, 184, 0), // slate-400 + is_light: false, + } + } + } +} + +/// Per-theme chrome palette: the translucent panels, tab pills, edge accents +/// and glows painted by `draw_chrome`, plus the terminal background the theme +/// applies on selection. +#[derive(Debug, Clone, Copy)] +/// The text ink a miniature theme card draws its fake terminal lines with; +/// the highlight bars use the theme's own `edge_l`/`edge_r` brand pair. +/// See [`NebulaTheme::card_ink`]. +pub(crate) struct CardInk { + pub(crate) fg: Rgb, +} + +pub(crate) struct NebulaPalette { + pub(crate) panel: Rgba, + /// Standalone fill for inactive tab rows / the "+" pill. Currently unpainted + /// (inactive rows sit flush on the sidebar; state is the white active pill), + /// kept for reintroducing a per-row background without re-plumbing the palette. + #[allow(dead_code)] + pub(crate) pill: Rgba, + pub(crate) tab_stroke_l: Rgba, + pub(crate) tab_bg_l: Rgba, + pub(crate) tab_bg_r: Rgba, + pub(crate) edge_l: Rgba, + pub(crate) edge_r: Rgba, + pub(crate) edge_glow_l: Rgba, + pub(crate) glow_l: Rgba, + pub(crate) glow_r: Rgba, + /// Light chrome theme: flips the chrome ink set (labels/icons) to dark + /// text so it stays readable on the pale surfaces. + pub(crate) is_light: bool, + /// The theme's default terminal background, applied on selection. + pub(crate) term_bg: Rgb, + /// Opaque window shell base color. The whole window clears to this; the + /// terminal renders as a rounded [`term_bg`] card floating on top, and the + /// chrome (top bar / sidebar) melts into it. Slightly offset from `panel`'s + /// RGB so the translucent panels still read on top of it. + pub(crate) shell_bg: Rgb, +} + +/// Theme-derived skin for every floating chrome layer: the settings modal, +/// confirm dialogs, the command palette, the resize HUD, scrollbar and the +/// chrome ink set. One struct so light themes flip EVERY overlay at once — +/// components must not keep private color constants. +/// +/// Naming: `ink*` are text colors (strong > ink > dim > faint), `panel` / +/// `input` / `surface` are fills from back to front, `hover*` are transient +/// washes stacked on top, and `accent` / `danger` are the only saturated +/// voices (selection/primary vs destructive). +#[derive(Debug, Clone, Copy)] +pub(crate) struct Skin { + /// Near-opaque panel surface (kills the see-through bleed where the + /// shell's own powerline used to collide with overlay labels). + pub(crate) panel: Rgba, + /// Inset/input surface (command palette query box and friends). + pub(crate) input: Rgba, + /// Elevated card fill for picker rows: a soft lift on the gray panel + /// (white cards on light themes, a faint white wash on dark). + pub(crate) card: Rgba, + /// Full-window wash behind modals. 2026-07-29 用户裁定:浅色主题的弹窗 + /// 遮罩用白雾压淡而不是黑幕压暗;深色主题保持黑色调暗。 + pub(crate) veil: Rgba, + /// Primary label ink. + pub(crate) ink: Rgb, + /// Secondary / sub-label ink. + pub(crate) ink_dim: Rgb, + /// Titles, active nav row, selected list rows. + pub(crate) ink_strong: Rgb, + /// Placeholder / hint text (weakest voice). + pub(crate) ink_faint: Rgb, + /// Ink on top of an `accent`-filled control (primary buttons). + pub(crate) ink_on_accent: Rgb, + /// Chrome glyph icons (sidebar toggle, settings gear, tab ×, …). + pub(crate) icon: Rgb, + pub(crate) icon_hover: Rgb, + /// The theme's single accent (selection ring, toggle-on, slider fill). + pub(crate) accent: Rgb, + /// Soft accent wash for the active nav pill and selected card fill. + pub(crate) accent_soft: Rgba, + /// Destructive primary actions (close-busy-pane confirm). Same on both + /// light and dark — semantic red doesn't flip. + pub(crate) danger: Rgba, + /// 成功完成。与 [`Skin::danger`] / [`Skin::warn`] 一组,是仅有的三个 + /// **语义**色——它们表达状态,不表达品牌,所以不随主题 accent 变。 + pub(crate) ok: Rgba, + /// 停下来等用户(授权、确认、密码)。 + pub(crate) warn: Rgba, + /// Edges, separators and quiet control borders. + pub(crate) hairline: Rgba, + /// Faint lift for interactive rows and cards. + pub(crate) surface: Rgba, + /// Hover wash on rows and cards. + pub(crate) hover: Rgba, + /// Stronger hover wash for small icon targets. + pub(crate) hover_strong: Rgba, + /// Toggle-off track / slider rail. + pub(crate) track_off: Rgba, + /// Toggle knob when off / on (chosen to contrast the track). + pub(crate) knob_off: Rgba, + pub(crate) knob_on: Rgba, + /// Scrollbar thumb base color; alpha applied at the call site (drag + /// feedback brightens it). + pub(crate) scrollbar_thumb: Rgba, + /// True on the pale themes — lets renderers pick a brighter bevel and the + /// right slider-thumb ink without re-deriving it from the palette. + pub(crate) is_light: bool, +} + +/// Publish the active theme for the shell prompt bridge: the powerline script +/// polls `%TEMP%\nebula_theme.txt` and recolors its segments to match. Written +/// atomically (tmp + rename) so readers never see a torn value. +pub(crate) fn write_nebula_prompt_theme(theme: NebulaTheme) { + let dir = std::env::temp_dir(); + let path = dir.join("nebula_theme.txt"); + let tmp = dir.join(format!("nebula_theme.{}.tmp", std::process::id())); + + if std::fs::write(&tmp, theme.prompt_name()).is_ok() { + // Windows cannot always rename over an existing file with `std::fs::rename`. + // The prompt script treats a missing/invalid theme as Nebula, so even the + // fallback path stays safe; the temporary file prevents readers from seeing + // partially-written contents. + let _ = std::fs::rename(&tmp, &path).or_else(|_| { + let _ = std::fs::remove_file(&path); + std::fs::rename(&tmp, &path) + }); + } +} + +#[cfg(test)] +mod tests { + use super::NebulaTheme; + + #[test] + fn system_appearance_keeps_the_selected_theme_family() { + assert_eq!(NebulaTheme::Nebula.for_system_appearance(true), NebulaTheme::SilverLight); + assert_eq!(NebulaTheme::Nebula.for_system_appearance(false), NebulaTheme::Nebula); + assert_eq!(NebulaTheme::SilverLight.for_system_appearance(false), NebulaTheme::SteelDark); + assert_eq!(NebulaTheme::SteelDark.for_system_appearance(true), NebulaTheme::SilverLight); + assert_eq!(NebulaTheme::LimestoneLight.for_system_appearance(false), NebulaTheme::CoalDark); + assert_eq!(NebulaTheme::CoalDark.for_system_appearance(true), NebulaTheme::LimestoneLight); + assert_eq!(NebulaTheme::LinenLight.for_system_appearance(false), NebulaTheme::MossDark); + assert_eq!(NebulaTheme::MossDark.for_system_appearance(true), NebulaTheme::LinenLight); + } +} diff --git a/nebula_app/src/display/ui/widgets.rs b/nebula_app/src/display/ui/widgets.rs index 289d80b..af84503 100644 --- a/nebula_app/src/display/ui/widgets.rs +++ b/nebula_app/src/display/ui/widgets.rs @@ -185,11 +185,17 @@ pub(crate) fn combobox_popup_rect( pub(crate) fn popup_row_rect(popup: Rect, index: usize, scale: f32) -> Rect { let s = |v: f32| v * scale; - (popup.0 + s(4.0), popup.1 + s(4.0) + index as f32 * s(POPUP_ROW_H), popup.2 - s(8.0), s(POPUP_ROW_H)) + ( + popup.0 + s(4.0), + popup.1 + s(4.0) + index as f32 * s(POPUP_ROW_H), + popup.2 - s(8.0), + s(POPUP_ROW_H), + ) } pub(crate) fn popup_row_at(popup: Rect, count: usize, scale: f32, x: f32, y: f32) -> Option { - (0..count).find(|&index| crate::display::contains_rect(popup_row_rect(popup, index, scale), x, y)) + (0..count) + .find(|&index| crate::display::contains_rect(popup_row_rect(popup, index, scale), x, y)) } /// The floating option list: opaque plate + soft shadow so it reads as a @@ -226,14 +232,7 @@ pub(crate) fn push_combobox_popup( } else { continue; }; - quads.push(UiQuad::solid( - rx, - ry + s(2.0), - rw, - rh - s(4.0), - radius::CONTROL * scale, - fill, - )); + quads.push(UiQuad::solid(rx, ry + s(2.0), rw, rh - s(4.0), radius::CONTROL * scale, fill)); } } diff --git a/nebula_app/src/display/window.rs b/nebula_app/src/display/window.rs index ef6c402..a96cf7a 100644 --- a/nebula_app/src/display/window.rs +++ b/nebula_app/src/display/window.rs @@ -764,7 +764,8 @@ fn apply_windows_backdrop(window: &WinitWindow, enabled: bool) { } #[cfg(all(test, windows))] -mod windows_startup_tests { use super::startup_state_for_creation; +mod windows_startup_tests { + use super::startup_state_for_creation; use crate::config::window::{StartupMode, WindowConfig}; #[test] diff --git a/nebula_app/src/event.rs b/nebula_app/src/event.rs index ef4ed72..05b0eb8 100644 --- a/nebula_app/src/event.rs +++ b/nebula_app/src/event.rs @@ -56,7 +56,7 @@ use crate::display::window::{ImeInhibitor, Window}; use crate::display::{Display, Preedit, SizeInfo}; use crate::input::{self, ActionContext as _}; use crate::logging::{LOG_TARGET_CONFIG, LOG_TARGET_WINIT}; -use crate::message_bar::{Message, MessageBuffer}; +use crate::message_bar::{Message, MessageBuffer, MessageType}; #[cfg(unix)] use crate::polling::ipc::{self, SocketReply}; use crate::scheduler::{Scheduler, TimerId, Topic}; @@ -233,21 +233,44 @@ impl Processor { // start exactly that instead of yesterday's tabs. let plain_launch = window_options.terminal_options.working_directory.is_none() && window_options.terminal_options.command().is_none(); - let boot = if plain_launch { - crate::session::load() - .filter(crate::session::should_restore) - .map(|mut session| { + // 恢复行为归设置·高级→会话管(默认开)。关掉只是不回放——快照照写, + // 工作区导出与崩溃现场诊断都还在。 + let mut notice = None; + let restore = if plain_launch && crate::display::restore_session_enabled() { + match crate::session::load() { + Some(mut session) if crate::session::should_restore(&session) => { + if crate::session::was_crash(&session) { + notice = Some(format!( + "已从上次异常退出恢复 {} 个标签(进程未正常收尾)。", + session.tabs.len() + )); + } // Count this launch against the crash-loop breaker; the // first successful autosave (1 Hz tick) resets it. crate::session::mark_boot_attempt(&mut session); - session - }) - .map_or(WindowBoot::Fresh, WindowBoot::Restore) + Some(session) + }, + // 断路器跳闸:连续三次启动都没活到第一次自动保存。把这份 + // 会话隔离出去再干净启动,否则一秒后的自动保存就会盖掉这份 + // 「一恢复就崩」的唯一现场。 + Some(session) if !session.tabs.is_empty() => { + notice = Some(match crate::session::quarantine() { + Some(path) => format!( + "连续三次启动失败,已跳过会话恢复;上次的会话保存在 {}。", + path.display() + ), + None => "连续三次启动失败,已跳过会话恢复。".to_owned(), + }); + None + }, + _ => None, + } } else { - WindowBoot::Fresh + None }; + let boot = restore.map_or(WindowBoot::Fresh, WindowBoot::Restore); - let window_context = WindowContext::initial( + let mut window_context = WindowContext::initial( event_loop, self.proxy.clone(), self.config.clone(), @@ -255,6 +278,11 @@ impl Processor { boot, )?; + // 恢复提示走消息栏:不打断输入,但下一次按键前一定看得见。 + if let Some(text) = notice { + window_context.message_buffer.push(Message::new(text, MessageType::Warning)); + } + self.gl_config = Some(window_context.display.gl_context().config()); self.windows.insert(window_context.id(), window_context); @@ -357,12 +385,11 @@ impl Processor { /// the candidate before releasing the old key keeps the existing shortcut /// alive when the OS rejects a conflicting or malformed candidate. fn apply_quick_terminal_hotkey(&mut self, requested: &str) -> Result<(), String> { - let new_hotkey = requested - .parse::() - .map_err(|err| format!("快捷键格式无效:{err}"))?; + let new_hotkey = + requested.parse::().map_err(|err| format!("快捷键格式无效:{err}"))?; if self.quick_hotkey == Some(new_hotkey) { self.quick_hotkey_combo = requested.to_owned(); - return Ok(()) + return Ok(()); } let mut manager = self.global_hotkey.take().or_else(|| GlobalHotKeyManager::new().ok()); @@ -402,12 +429,9 @@ impl Processor { let result = self.apply_quick_terminal_hotkey(&hotkey); if let Some(window_context) = self.windows.get_mut(&window_id) { match result { - Ok(()) => window_context.display.quick_hotkey_registration_done( - &hotkey, - true, - None, - &old, - ), + Ok(()) => { + window_context.display.quick_hotkey_registration_done(&hotkey, true, None, &old) + }, Err(err) => window_context.display.quick_hotkey_registration_done( &hotkey, false, @@ -630,8 +654,7 @@ impl ApplicationHandler for Processor { (EventType::NebulaSync { push }, _) => { let proxy = self.proxy.clone(); std::thread::spawn(move || { - let result = - if push { crate::sync::push() } else { crate::sync::pull() }; + let result = if push { crate::sync::push() } else { crate::sync::pull() }; crate::sync::warn_result(&result); let (message, error, history_changed) = match result { Ok(outcome) => (outcome.message, false, outcome.history_changed), @@ -653,12 +676,9 @@ impl ApplicationHandler for Processor { let result = self.apply_quick_terminal_hotkey(&hotkey); if let Some(window_context) = self.windows.get_mut(window_id) { match result { - Ok(()) => window_context.display.quick_hotkey_registration_done( - &hotkey, - true, - None, - &old, - ), + Ok(()) => window_context + .display + .quick_hotkey_registration_done(&hotkey, true, None, &old), Err(err) => window_context.display.quick_hotkey_registration_done( &hotkey, false, @@ -671,13 +691,7 @@ impl ApplicationHandler for Processor { } }, ( - EventType::SshTestDone { - request_id, - destination, - ok, - message, - elapsed_ms, - }, + EventType::SshTestDone { request_id, destination, ok, message, elapsed_ms }, Some(window_id), ) => { if let Some(window_context) = self.windows.get_mut(window_id) { @@ -2424,8 +2438,7 @@ impl input::Processor> { branch: self.ctx.nebula_state.branch.clone(), output_tail: crate::ai_assistant::redact_secrets(&self.grid_output_tail(24, 2000)), }; - self.ctx.nebula_state.ai_fix = - Some(crate::ai_assistant::AiFixState::Pending { seq }); + self.ctx.nebula_state.ai_fix = Some(crate::ai_assistant::AiFixState::Pending { seq }); crate::ai_assistant::spawn_fix_request(self.ctx.event_proxy.clone(), cfg, request); self.ctx.mark_dirty(); } @@ -2658,6 +2671,18 @@ impl input::Processor> { // Sidebar dot until the tab gets looked at // (cleared instantly for the visible tab). self.ctx.nebula_state.finished_unseen = true; + // 成败分流:非零码走警示三角,零码走"刚完成" + // 的对勾闪现。裸 133;D 没带码(第三方集成), + // 那种情况只当作完成,不敢报错。 + match exit_code { + Some(code) if code != 0 => { + self.ctx.nebula_state.failed_unseen = true; + }, + _ => { + self.ctx.nebula_state.finished_at = + Some(std::time::Instant::now()); + }, + } if !self.ctx.display.window.has_focus() { crate::notify::deliver( &self.ctx.display.window, diff --git a/nebula_app/src/event/types.rs b/nebula_app/src/event/types.rs index 110f0b4..e5554b2 100644 --- a/nebula_app/src/event/types.rs +++ b/nebula_app/src/event/types.rs @@ -115,7 +115,10 @@ pub enum TabRequest { New, NewAtDirectory(PathBuf), NewProfile(usize), - NewShell { name: String, shell: nebula_terminal::tty::Shell }, + NewShell { + name: String, + shell: nebula_terminal::tty::Shell, + }, NewSsh(String), OpenDoc(PathBuf), OpenSettings, @@ -127,15 +130,27 @@ pub enum TabRequest { SelectPrev, Select(usize), SelectLast, - Move { from: usize, to: usize }, + Move { + from: usize, + to: usize, + }, SplitToggle(crate::display::SplitDirection), - SplitIndex { index: usize, direction: crate::display::SplitDirection }, - DockSplit { source: usize, nav: crate::display::SplitNav }, + SplitIndex { + index: usize, + direction: crate::display::SplitDirection, + }, + DockSplit { + source: usize, + nav: crate::display::SplitNav, + }, FocusSplit(crate::display::SplitNav), ToggleZoom, BeginRename(usize), CommitRename(String), - SetColor { index: usize, color: Option }, + SetColor { + index: usize, + color: Option, + }, CancelRename, /// Save every terminal tab as a workspace file. ExportWorkspace, diff --git a/nebula_app/src/file_uri.rs b/nebula_app/src/file_uri.rs index f4ef41d..9828068 100644 --- a/nebula_app/src/file_uri.rs +++ b/nebula_app/src/file_uri.rs @@ -35,10 +35,7 @@ pub fn file_uri_to_local_path(uri: &str) -> Option { /// Test seam for [`file_uri_to_local_path`]; `drive_exists` is injected so unit /// tests don't depend on which drive letters the host machine happens to have. -fn file_uri_to_local_path_with( - uri: &str, - drive_exists: impl Fn(char) -> bool, -) -> Option { +fn file_uri_to_local_path_with(uri: &str, drive_exists: impl Fn(char) -> bool) -> Option { // Case-insensitive `file:` scheme check without allocating. let rest = strip_scheme(uri)?; @@ -79,9 +76,7 @@ fn host_is_local(host: &str) -> bool { return true; } // `ls --hyperlink` uses the machine's hostname; treat our own name as local. - std::env::var("COMPUTERNAME") - .ok() - .is_some_and(|name| name.eq_ignore_ascii_case(host)) + std::env::var("COMPUTERNAME").ok().is_some_and(|name| name.eq_ignore_ascii_case(host)) } /// Convert a decoded, local, posix-looking URI path into a Windows path. @@ -222,10 +217,7 @@ mod tests { #[test] fn utf8_filename_roundtrips() { // "文档" percent-encoded as UTF-8. - assert_eq!( - t("file:///D:/%E6%96%87%E6%A1%A3/x.md"), - Some("D:\\文档\\x.md".into()) - ); + assert_eq!(t("file:///D:/%E6%96%87%E6%A1%A3/x.md"), Some("D:\\文档\\x.md".into())); } #[test] @@ -267,7 +259,10 @@ mod tests { #[test] fn remote_host_becomes_unc() { - assert_eq!(t("file://fileserver/share/doc.txt"), Some(r"\\fileserver\share\doc.txt".into())); + assert_eq!( + t("file://fileserver/share/doc.txt"), + Some(r"\\fileserver\share\doc.txt".into()) + ); } #[test] diff --git a/nebula_app/src/font_install.rs b/nebula_app/src/font_install.rs index ab1352a..dd36539 100644 --- a/nebula_app/src/font_install.rs +++ b/nebula_app/src/font_install.rs @@ -63,9 +63,9 @@ pub fn store_imported_font(source: &Path) -> Result { } fn supported_font_extension(path: &Path) -> bool { - path.extension() - .and_then(|value| value.to_str()) - .is_some_and(|value| matches!(value.to_ascii_lowercase().as_str(), "ttf" | "otf" | "ttc" | "otc")) + path.extension().and_then(|value| value.to_str()).is_some_and(|value| { + matches!(value.to_ascii_lowercase().as_str(), "ttf" | "otf" | "ttc" | "otc") + }) } fn packaged_font_directory(executable: &Path) -> PathBuf { diff --git a/nebula_app/src/input/action.rs b/nebula_app/src/input/action.rs index 32c42a3..2f637c8 100644 --- a/nebula_app/src/input/action.rs +++ b/nebula_app/src/input/action.rs @@ -386,8 +386,7 @@ impl Execute for Action { if let Some(destination) = ctx.nebula_ssh_destination().map(str::to_owned) { ctx.nebula_open_sftp(destination); } else { - ctx.display() - .toggle_side_panel(crate::display::side_panel::PanelView::Files); + ctx.display().toggle_side_panel(crate::display::side_panel::PanelView::Files); } ctx.mark_dirty(); }, diff --git a/nebula_app/src/input/chrome.rs b/nebula_app/src/input/chrome.rs index 67797b8..b3d6007 100644 --- a/nebula_app/src/input/chrome.rs +++ b/nebula_app/src/input/chrome.rs @@ -53,6 +53,9 @@ impl> Processor { NebulaConfirm::EnableBackgroundImageCoverChrome => { self.ctx.display().confirm_background_image_cover_chrome(); }, + NebulaConfirm::EnablePanelResize => { + self.ctx.display().confirm_panel_resize(); + }, NebulaConfirm::ClosePane { .. } => { self.ctx.nebula_tab(crate::event::TabRequest::Close); }, @@ -716,6 +719,12 @@ impl> Processor { self.ctx.mark_dirty(); return; } + // 面板分界线拖拽(设置·交互开关启用时)先于其余 chrome 命中: + // 分界线热区骑在侧栏行 / 抽屉内容的边上,先到先得。 + if let Some(kind) = self.ctx.display().panel_resize_hit(x, y) { + self.ctx.display().begin_panel_drag(kind); + return; + } let settings_open = self.ctx.display().settings_open(); let settings_section = self.ctx.display().settings_section(); let settings_scroll = self.ctx.display().settings_scroll(); @@ -807,9 +816,9 @@ impl> Processor { return; }, crate::display::SettingsHit::CursorShapeDropdown => { - self.ctx - .display() - .toggle_settings_dropdown(crate::display::SettingsDropdown::CursorShape); + self.ctx.display().toggle_settings_dropdown( + crate::display::SettingsDropdown::CursorShape, + ); self.ctx.mark_dirty(); return; }, @@ -831,6 +840,11 @@ impl> Processor { self.ctx.mark_dirty(); return; }, + crate::display::SettingsHit::PanelResizeToggle => { + self.ctx.display().request_toggle_panel_resize(); + self.ctx.mark_dirty(); + return; + }, crate::display::SettingsHit::CjkBoldToggle => { self.ctx.display().toggle_cjk_bold_regular(); self.ctx.mark_dirty(); @@ -924,6 +938,11 @@ impl> Processor { self.ctx.mark_dirty(); return; }, + crate::display::SettingsHit::RestoreSessionToggle => { + self.ctx.display().toggle_restore_session(); + self.ctx.mark_dirty(); + return; + }, crate::display::SettingsHit::SyncInput(index) => { self.ctx.display().focus_sync_field(index); self.ctx.mark_dirty(); @@ -987,11 +1006,9 @@ impl> Processor { return; }, crate::display::SettingsHit::BackgroundImageFit => { - self.ctx - .display() - .toggle_settings_dropdown( - crate::display::SettingsDropdown::BackgroundFit, - ); + self.ctx.display().toggle_settings_dropdown( + crate::display::SettingsDropdown::BackgroundFit, + ); self.ctx.mark_dirty(); return; }, diff --git a/nebula_app/src/input/keyboard.rs b/nebula_app/src/input/keyboard.rs index af9c81e..bdb2b93 100644 --- a/nebula_app/src/input/keyboard.rs +++ b/nebula_app/src/input/keyboard.rs @@ -358,6 +358,81 @@ impl> Processor { if self.ctx.display().nebula_ssh_editor.is_some() { if self.ctx.display().ssh_editor_active() { + // 图标列表开着时它独占键盘:打字进搜索、Esc 只收列表、回车挑 + // 第一项。不这样的话,敲 "deb" 会落进正在编辑的名字里,而 Esc + // 会连整张表单一起关掉——用户只是想收起一个列表。 + if self.ctx.display().ssh_editor_icon_picker_open() { + match &key.logical_key { + Key::Named(NamedKey::Escape) => { + self.ctx.display().ssh_editor_close_icon_picker(); + }, + Key::Named(NamedKey::Enter) => { + self.ctx.display().ssh_editor_icon_pick_first() + }, + Key::Named(NamedKey::Backspace) => { + self.ctx.display().ssh_editor_icon_filter_edit(None) + }, + Key::Named(NamedKey::Delete) => { + self.ctx.display().ssh_editor_icon_filter_delete_forward() + }, + Key::Named(NamedKey::ArrowDown) => { + self.ctx.display().ssh_editor_icon_scroll(1) + }, + Key::Named(NamedKey::ArrowUp) => { + self.ctx.display().ssh_editor_icon_scroll(-1) + }, + // 搜索框是正经输入框:移动光标、Shift 扩选、Home/End、 + // Ctrl+A/C/V 一样不缺——这套组合键属于肌肉记忆,缺一个 + // 都会被读成"这个框是坏的"。 + Key::Named(NamedKey::ArrowLeft) => { + self.ctx.display().ssh_editor_icon_filter_move(false, mods.shift_key()) + }, + Key::Named(NamedKey::ArrowRight) => { + self.ctx.display().ssh_editor_icon_filter_move(true, mods.shift_key()) + }, + Key::Named(NamedKey::Home) => { + self.ctx.display().ssh_editor_icon_filter_jump(false, mods.shift_key()) + }, + Key::Named(NamedKey::End) => { + self.ctx.display().ssh_editor_icon_filter_jump(true, mods.shift_key()) + }, + Key::Character(c) + if mods.control_key() + && !mods.alt_key() + && c.eq_ignore_ascii_case("a") => + { + self.ctx.display().ssh_editor_icon_filter_select_all(); + }, + Key::Character(c) + if mods.control_key() + && !mods.alt_key() + && c.eq_ignore_ascii_case("c") => + { + if let Some(text) = + self.ctx.display().ssh_editor_icon_filter_selected_text() + { + self.ctx.clipboard_mut().store(ClipboardType::Clipboard, text); + } + }, + Key::Character(c) + if mods.control_key() + && !mods.alt_key() + && c.eq_ignore_ascii_case("v") => + { + let text = self.ctx.clipboard_mut().load(ClipboardType::Clipboard); + self.ctx.display().ssh_editor_icon_filter_edit(Some(&text)); + }, + Key::Character(c) if !mods.control_key() && !mods.alt_key() => { + self.ctx.display().ssh_editor_icon_filter_edit(Some(c)) + }, + Key::Named(NamedKey::Space) => { + self.ctx.display().ssh_editor_icon_filter_edit(Some(" ")) + }, + _ => {}, + } + self.ctx.mark_dirty(); + return; + } match &key.logical_key { Key::Named(NamedKey::Escape) => self.ctx.display().close_ssh_editor(), Key::Named(NamedKey::Enter) => self.ctx.display().ssh_editor_activate_focus(), @@ -365,9 +440,7 @@ impl> Processor { self.ctx.display().ssh_editor_next_field(mods.shift_key()) }, Key::Named(NamedKey::Backspace) => self.ctx.display().ssh_editor_backspace(), - Key::Named(NamedKey::Delete) => { - self.ctx.display().ssh_editor_delete_forward() - }, + Key::Named(NamedKey::Delete) => self.ctx.display().ssh_editor_delete_forward(), // 光标导航。按住 Shift 是扩选——这套组合键在 Windows 上 // 属于肌肉记忆,缺一个都会被读成"这个输入框是坏的"。 Key::Named(NamedKey::ArrowLeft) => { @@ -697,7 +770,21 @@ impl> Processor { use crate::event::TabRequest; match action { NewTab => self.ctx.nebula_tab(TabRequest::New), + CopyCwd => { + if let Some(path) = self.ctx.display().focused_cwd_string() { + self.ctx.clipboard_mut().store(ClipboardType::Clipboard, path); + } + }, + RevealCwd => self.ctx.display().reveal_focused_cwd(), + ToggleSidebar => self.ctx.display().toggle_sidebar(), + TogglePanelResize => self.ctx.display().request_toggle_panel_resize(), OpenDirectoryPicker => self.ctx.display().open_directory_picker(), + OpenAiSessionPicker => self.ctx.display().open_ai_session_palette(), + ResumeAiSession(command) => { + // 非 bracketed:resume 是要**执行**的命令行,bracketed 包裹 + // 会让部分 shell 把它按纯文本粘着不跑。 + self.ctx.paste(&format!("{command}\r"), false); + }, CloseTab => self.ctx.nebula_tab(TabRequest::Close), NextTab => self.ctx.nebula_tab(TabRequest::SelectNext), PrevTab => self.ctx.nebula_tab(TabRequest::SelectPrev), diff --git a/nebula_app/src/input/mouse.rs b/nebula_app/src/input/mouse.rs index 30379e2..0b18323 100644 --- a/nebula_app/src/input/mouse.rs +++ b/nebula_app/src/input/mouse.rs @@ -90,6 +90,7 @@ impl> Processor { crate::display::SshEditorHit::Destination | crate::display::SshEditorHit::Port | crate::display::SshEditorHit::Label + | crate::display::SshEditorHit::IconSearch | crate::display::SshEditorHit::Password => CursorIcon::Text, crate::display::SshEditorHit::SaveToggleBox | crate::display::SshEditorHit::SaveToggleLabel @@ -98,11 +99,16 @@ impl> Processor { | crate::display::SshEditorHit::Auth(_) | crate::display::SshEditorHit::AddPrivateKey | crate::display::SshEditorHit::RemovePrivateKey(_) + | crate::display::SshEditorHit::Avatar + | crate::display::SshEditorHit::IconOption(_) | crate::display::SshEditorHit::Test | crate::display::SshEditorHit::Primary | crate::display::SshEditorHit::Cancel => CursorIcon::Pointer, crate::display::SshEditorHit::TestStatus => CursorIcon::Help, - crate::display::SshEditorHit::None => CursorIcon::Default, + // 浮层的空白处不是可点的东西,指针就该是普通箭头——给它一个 + // 手形会让人一直在那儿试着点。 + crate::display::SshEditorHit::IconPopupChrome + | crate::display::SshEditorHit::None => CursorIcon::Default, }; self.ctx.window().set_mouse_cursor(cursor); return; @@ -127,6 +133,24 @@ impl> Processor { return; } + // Nebula: an active panel-divider drag maps pointer motion onto the + // sidebar / drawer width or the SSH HOSTS band(设置·交互的拖拽调节; + // 宽度类在 display 端按节流窗口 reflow)。 + if let Some(drag) = self.ctx.display().nebula_panel_drag { + if lmb_pressed { + self.ctx.display().update_panel_drag(x as f32, y as f32); + use crate::display::PanelDragKind; + self.ctx.window().set_mouse_cursor(match drag.kind { + PanelDragKind::HostsBand => CursorIcon::NsResize, + _ => CursorIcon::EwResize, + }); + self.ctx.mark_dirty(); + return; + } + // Button no longer held (release happened elsewhere): finish up. + self.ctx.display().end_panel_drag(); + } + // Nebula: while the left button holds the scrollback thumb, pointer // motion maps 1:1 onto the display offset (scrollbar drag). if let Some(grab) = self.ctx.display().nebula_scrollbar_drag { @@ -257,6 +281,16 @@ impl> Processor { self.ctx.window().set_mouse_cursor(icon); return; } + // 面板分界线的悬浮光标(开关开启时):左右拖=EwResize,HOSTS 分界 + // =NsResize。放在窗口边缘之后、chrome/设置命中之前——热区最窄的赢。 + if let Some(kind) = self.ctx.display().panel_resize_hit(x as f32, y as f32) { + use crate::display::PanelDragKind; + self.ctx.window().set_mouse_cursor(match kind { + PanelDragKind::HostsBand => CursorIcon::NsResize, + _ => CursorIcon::EwResize, + }); + return; + } match settings_hover { crate::display::SettingsHit::Toggle | crate::display::SettingsHit::Nav(_) @@ -276,6 +310,7 @@ impl> Processor { | crate::display::SettingsHit::PowerlineToggle | crate::display::SettingsHit::BlurToggle | crate::display::SettingsHit::KeepSessionToggle + | crate::display::SettingsHit::RestoreSessionToggle | crate::display::SettingsHit::BackgroundColor | crate::display::SettingsHit::BackgroundImage | crate::display::SettingsHit::BackgroundImageClear @@ -291,6 +326,7 @@ impl> Processor { | crate::display::SettingsHit::CursorShapeOption(_) | crate::display::SettingsHit::CursorBlinkToggle | crate::display::SettingsHit::CopyOnSelectToggle + | crate::display::SettingsHit::PanelResizeToggle | crate::display::SettingsHit::CjkBoldToggle | crate::display::SettingsHit::KeymapRow(_) | crate::display::SettingsHit::TabRevealDropdown @@ -354,8 +390,7 @@ impl> Processor { self.ctx.mark_dirty(); } if self.ctx.display().ssh_connect_covers(x as f32, y as f32) { - let cursor = - if hit.is_none() { CursorIcon::Default } else { CursorIcon::Pointer }; + let cursor = if hit.is_none() { CursorIcon::Default } else { CursorIcon::Pointer }; self.ctx.window().set_mouse_cursor(cursor); return; } @@ -772,6 +807,11 @@ impl> Processor { return; } } + // 面板分界线拖拽收尾:最终应用 + 持久化。 + if self.ctx.display().end_panel_drag() { + self.ctx.mark_dirty(); + return; + } // Let go of the scrollback thumb. if self.ctx.display().nebula_scrollbar_drag.take().is_some() { self.ctx.mark_dirty(); @@ -848,6 +888,19 @@ impl> Processor { } pub fn mouse_wheel_input(&mut self, delta: MouseScrollDelta, phase: TouchPhase) { + // 图标选择器开着时滚轮归它。它是最上面的那一层,而下面是一张模态 + // 表单——滚轮落到终端回滚里去,是"滚了却什么都没动"的那种坏掉。 + if self.ctx.display().ssh_editor_icon_picker_open() { + let lines = match delta { + MouseScrollDelta::LineDelta(_, lines) => -lines.signum() as i32, + MouseScrollDelta::PixelDelta(pos) => -(pos.y.signum() as i32), + }; + if lines != 0 { + self.ctx.display().ssh_editor_icon_scroll(lines); + } + return; + } + // Ctrl+wheel zooms the terminal font (Windows Terminal / browser // convention). Checked before every scroll consumer so zoom wins over // page/drawer/grid scrolling while the modifier is held. diff --git a/nebula_app/src/main.rs b/nebula_app/src/main.rs index 23d09a1..04e71b4 100644 --- a/nebula_app/src/main.rs +++ b/nebula_app/src/main.rs @@ -29,6 +29,7 @@ use nebula_terminal::tty; mod ai_assistant; mod ai_hook; +mod ai_sessions; mod atomic_file; mod cli; mod clipboard; @@ -66,7 +67,8 @@ mod scheduler; mod session; mod shell_detect; #[cfg(windows)] -mod ssh;#[cfg(windows)] +mod ssh; +#[cfg(windows)] mod ssh_credentials; #[cfg(windows)] mod ssh_profiles; diff --git a/nebula_app/src/migrate/mod.rs b/nebula_app/src/migrate/mod.rs index bfa7229..1ba0c19 100644 --- a/nebula_app/src/migrate/mod.rs +++ b/nebula_app/src/migrate/mod.rs @@ -107,10 +107,11 @@ fn migrate_toml(toml: String) -> Result { }; // Move `draw_bold_text_with_bright_colors` to its own section. - move_value(&mut document, &["draw_bold_text_with_bright_colors"], &[ - "colors", - "draw_bold_text_with_bright_colors", - ])?; + move_value( + &mut document, + &["draw_bold_text_with_bright_colors"], + &["colors", "draw_bold_text_with_bright_colors"], + )?; // Move bindings to their own section. move_value(&mut document, &["key_bindings"], &["keyboard", "bindings"])?; @@ -300,11 +301,11 @@ not_moved = 9 let mut document = input.parse::().unwrap(); move_value(&mut document, &["root_value"], &["new_table", "root_value"]).unwrap(); - move_value(&mut document, &["table", "table_value"], &[ - "preexisting", - "subtable", - "new_name", - ]) + move_value( + &mut document, + &["table", "table_value"], + &["preexisting", "subtable", "new_name"], + ) .unwrap(); let output = document.to_string(); diff --git a/nebula_app/src/process_tree.rs b/nebula_app/src/process_tree.rs index 534c214..4f23654 100644 --- a/nebula_app/src/process_tree.rs +++ b/nebula_app/src/process_tree.rs @@ -39,10 +39,7 @@ const STATELESS: &[&str] = &[ /// (`busy_process_in` 优先用 pane 已知的 `running_program`);这里只负责 /// 没有那份身份信息时把名字擦干净。 pub fn display_name(exe: &str) -> String { - exe.strip_suffix(".exe") - .or_else(|| exe.strip_suffix(".EXE")) - .unwrap_or(exe) - .to_owned() + exe.strip_suffix(".exe").or_else(|| exe.strip_suffix(".EXE")).unwrap_or(exe).to_owned() } /// First non-stateless process under `root_pid` (the pane's shell), or `None` diff --git a/nebula_app/src/renderer/mod.rs b/nebula_app/src/renderer/mod.rs index 4624034..62fc555 100644 --- a/nebula_app/src/renderer/mod.rs +++ b/nebula_app/src/renderer/mod.rs @@ -477,8 +477,7 @@ impl Renderer { // descent (see `load_glyph`), and Δ = (ui_cell_h + ui_descent) · // (scale − 1) matches the doc formula at the base zoom. let ui_metrics = glyph_cache.ui_font_metrics(); - let anchor_y = - (y + (size_info.cell_height() + ui_metrics.descent) * (scale - 1.0)).round(); + let anchor_y = (y + (size_info.cell_height() + ui_metrics.descent) * (scale - 1.0)).round(); let advance = ui_metrics.average_advance as f32 * scale; let mut pen_x = x; @@ -515,32 +514,32 @@ impl Renderer { glyph_cache.ui_font_metrics().average_advance as f32 * scale } -/// Sampled breadcrumb for chrome-text rasterization (1/512 calls): records -/// the terminal font px, the UI role px actually drawn and the cell the text -/// steps by, into `ui_anchor.log`. Field diagnosis of "the sidebar zooms with -/// the terminal" needs the ACTUAL rasterized size from user machines. -fn chrome_text_probe(terminal: crossfont::Size, ui_role: crossfont::Size, cell_w: f32) { - use std::sync::atomic::{AtomicU32, Ordering}; - static COUNT: AtomicU32 = AtomicU32::new(0); - if COUNT.fetch_add(1, Ordering::Relaxed) % 512 != 0 { - return; + /// Sampled breadcrumb for chrome-text rasterization (1/512 calls): records + /// the terminal font px, the UI role px actually drawn and the cell the text + /// steps by, into `ui_anchor.log`. Field diagnosis of "the sidebar zooms with + /// the terminal" needs the ACTUAL rasterized size from user machines. + fn chrome_text_probe(terminal: crossfont::Size, ui_role: crossfont::Size, cell_w: f32) { + use std::sync::atomic::{AtomicU32, Ordering}; + static COUNT: AtomicU32 = AtomicU32::new(0); + if COUNT.fetch_add(1, Ordering::Relaxed) % 512 != 0 { + return; + } + let line = format!( + "[chrome_text] term_px={:.1} drawn_px={:.1} step_cell_w={cell_w:.1}\n", + terminal.as_px(), + ui_role.as_px(), + ); + if let Ok(mut file) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(crate::display::nebula_data_dir().join("ui_anchor.log")) + { + use std::io::Write as _; + let _ = file.write_all(line.as_bytes()); + } } - let line = format!( - "[chrome_text] term_px={:.1} drawn_px={:.1} step_cell_w={cell_w:.1}\n", - terminal.as_px(), - ui_role.as_px(), - ); - if let Ok(mut file) = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(crate::display::nebula_data_dir().join("ui_anchor.log")) - { - use std::io::Write as _; - let _ = file.write_all(line.as_bytes()); - } -} -/// Chrome text at the glyph cache's CURRENT font size, stepped by the + /// Chrome text at the glyph cache's CURRENT font size, stepped by the /// passed `SizeInfo` cells. Internal: [`Self::draw_chrome_text_styled`] /// wraps this with the UI compensation ratio; [`Self::draw_doc_text_tracked`] /// calls it directly so document text keeps following the terminal zoom. @@ -875,7 +874,16 @@ fn chrome_text_probe(terminal: crossfont::Size, ui_role: crossfont::Size, cell_w ); } - self.image_renderer.draw(size_info, path, opacity, fit, alignment, target, clip, clip_radius); + self.image_renderer.draw( + size_info, + path, + opacity, + fit, + alignment, + target, + clip, + clip_radius, + ); // The image pass rebinds TEXTURE_2D behind the text renderer's back; // drop its cached atlas binding or every later glyph goes invisible. self.invalidate_text_texture_cache(); diff --git a/nebula_app/src/renderer/rects.rs b/nebula_app/src/renderer/rects.rs index c919ac2..a6ca373 100644 --- a/nebula_app/src/renderer/rects.rs +++ b/nebula_app/src/renderer/rects.rs @@ -514,9 +514,8 @@ mod tests { #[test] fn grid_bottom_uses_independent_bottom_reserve() { - let size = SizeInfo::new_fully_asymmetric( - 1000.0, 1000.0, 10.0, 20.0, 260.0, 20.0, 64.0, 16.0, - ); + let size = + SizeInfo::new_fully_asymmetric(1000.0, 1000.0, 10.0, 20.0, 260.0, 20.0, 64.0, 16.0); assert_eq!(size.screen_lines(), 46); assert_eq!(grid_bottom(&size, 1000.0), 16.0); } diff --git a/nebula_app/src/renderer/text/builtin_font.rs b/nebula_app/src/renderer/text/builtin_font.rs index 30305a1..77624f9 100644 --- a/nebula_app/src/renderer/text/builtin_font.rs +++ b/nebula_app/src/renderer/text/builtin_font.rs @@ -418,9 +418,8 @@ mod tests { assert!(builtin_glyph(character, &METRICS, &offset, &glyph_offset).is_some()); } - for character in ('\u{e0a0}'..'\u{e0b0}') - .chain('\u{e0b5}'..'\u{e0b6}') - .chain('\u{e0b7}'..'\u{e0c0}') + for character in + ('\u{e0a0}'..'\u{e0b0}').chain('\u{e0b5}'..'\u{e0b6}').chain('\u{e0b7}'..'\u{e0c0}') { assert!(builtin_glyph(character, &METRICS, &offset, &glyph_offset).is_none()); } diff --git a/nebula_app/src/renderer/text/builtin_font/glyphs.rs b/nebula_app/src/renderer/text/builtin_font/glyphs.rs index aa7f6d3..b1fa17e 100644 --- a/nebula_app/src/renderer/text/builtin_font/glyphs.rs +++ b/nebula_app/src/renderer/text/builtin_font/glyphs.rs @@ -11,12 +11,16 @@ use crossfont::{BitmapBuffer, Metrics, RasterizedGlyph}; use crate::config::ui_config::Delta; use super::{ - COLOR_FILL, COLOR_FILL_ALPHA_STEP_1, COLOR_FILL_ALPHA_STEP_2, COLOR_FILL_ALPHA_STEP_3, - Canvas, Pixel, POWERLINE_ARROW_LTR, POWERLINE_ARROW_RTL, POWERLINE_ROUND_LTR, - POWERLINE_TRIANGLE_LTR, POWERLINE_TRIANGLE_RTL, calculate_stroke_size, line_equation, + COLOR_FILL, COLOR_FILL_ALPHA_STEP_1, COLOR_FILL_ALPHA_STEP_2, COLOR_FILL_ALPHA_STEP_3, Canvas, + POWERLINE_ARROW_LTR, POWERLINE_ARROW_RTL, POWERLINE_ROUND_LTR, POWERLINE_TRIANGLE_LTR, + POWERLINE_TRIANGLE_RTL, Pixel, calculate_stroke_size, line_equation, }; -pub(super) fn box_drawing(character: char, metrics: &Metrics, offset: &Delta) -> RasterizedGlyph { +pub(super) fn box_drawing( + character: char, + metrics: &Metrics, + offset: &Delta, +) -> RasterizedGlyph { // Ensure that width and height is at least one. let height = (metrics.line_height as i32 + offset.y as i32).max(1) as usize; let width = (metrics.average_advance as i32 + offset.x as i32).max(1) as usize; diff --git a/nebula_app/src/renderer/text/glyph_cache.rs b/nebula_app/src/renderer/text/glyph_cache.rs index b916634..db11217 100644 --- a/nebula_app/src/renderer/text/glyph_cache.rs +++ b/nebula_app/src/renderer/text/glyph_cache.rs @@ -419,7 +419,8 @@ impl GlyphCache { } /// Reset currently cached data in both GL and the registry to default state. - pub fn reset_glyph_cache(&mut self, loader: &mut L) { loader.clear(); + pub fn reset_glyph_cache(&mut self, loader: &mut L) { + loader.clear(); self.cache = Default::default(); self.load_common_glyphs(loader); diff --git a/nebula_app/src/renderer/ui.rs b/nebula_app/src/renderer/ui.rs index e7050a6..2038c4d 100644 --- a/nebula_app/src/renderer/ui.rs +++ b/nebula_app/src/renderer/ui.rs @@ -228,12 +228,7 @@ impl UiQuad { /// Flat-filled gradient polygon from explicit pixel corners /// `[top-left, bottom-left, top-right, bottom-right]` (for powerline slants). #[inline] - pub fn poly( - corners: [[f32; 2]; 4], - color0: Rgba, - color1: Rgba, - gradient: Gradient, - ) -> Self { + pub fn poly(corners: [[f32; 2]; 4], color0: Rgba, color1: Rgba, gradient: Gradient) -> Self { Self { x: 0.0, y: 0.0, @@ -527,7 +522,8 @@ mod tests { #[test] fn shadow_geometry_includes_blur_and_vertical_offset() { - let shadow = UiQuad::shadow(10.0, 20.0, 100.0, 80.0, 8.0, 12.0, 4.0, Rgba::new(0, 0, 0, 54)); + let shadow = + UiQuad::shadow(10.0, 20.0, 100.0, 80.0, 8.0, 12.0, 4.0, Rgba::new(0, 0, 0, 54)); assert_eq!((shadow.x, shadow.y), (-2.0, 12.0)); assert_eq!((shadow.width, shadow.height), (124.0, 104.0)); assert_eq!(shadow.corner_radii, [8.0; 4]); diff --git a/nebula_app/src/session.rs b/nebula_app/src/session.rs index 33c7444..4508842 100644 --- a/nebula_app/src/session.rs +++ b/nebula_app/src/session.rs @@ -1,319 +1,395 @@ -//! Session restore: reopen with the same tabs (and their directories) you had -//! when the window closed, with no "restore?" dialog. -//! -//! A snapshot is written continuously (1 Hz, skipped when nothing changed), so -//! a crash or force-kill still restores to within a second of where you were. -//! `boot_attempts` guards against a restore-crash loop: it's bumped before the -//! restore is attempted and cleared by the first successful autosave, so after -//! three failed launches Nebula starts clean to break the cycle. -//! -//! v2 additionally preserves each tab's custom name and optional color. v3 -//! persists the normal logical window size and maximized state. v4 records the -//! full split tree of every tab (axis, ratio, per-pane cwd) plus the tab's -//! launch identity (shell / profile / SSH destination), and the same schema -//! doubles as the workspace-export file format: `session.json` is simply the -//! automatic, unnamed workspace. - -use std::path::{Path, PathBuf}; - -use serde::{Deserialize, Serialize}; - -use crate::display::color::Rgb; - -/// Highest snapshot format this build understands. -const VERSION: u32 = 4; - -/// Give up restoring after this many launches that never reached a successful -/// autosave (i.e. crashed within the first second). -const MAX_BOOT_ATTEMPTS: u32 = 3; - -/// How a tab's first pane starts. The persistable subset of `TabLaunch`: -/// document and settings tabs never enter a session. Shell and profile -/// launches embed their full command so an exported workspace stays portable -/// even when the target machine's config lists different profiles. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(tag = "kind", rename_all = "snake_case")] -pub enum LaunchSession { - Default, - /// A detected shell from the new-tab dropdown (e.g. a WSL distro). - Shell { name: String, program: String, args: Vec }, - /// A quick-launch profile, embedded rather than referenced by name. - Profile { name: String, command: String, args: Vec, cwd: Option }, - /// A saved SSH destination; restoring reconnects automatically. - Ssh { host: String }, -} - -/// Split axis, mirrored from `display::SplitDirection` so the display layer -/// stays serde-free. -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum SplitAxis { - LeftRight, - TopBottom, -} - -/// A tab's pane tree. Leaves carry each pane's working directory; splits carry -/// the axis and the first child's share in permille — an integer, so autosave -/// change detection and file diffs never trip on f32 serialization noise. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(tag = "kind", rename_all = "snake_case")] -pub enum LayoutSession { - Pane { - cwd: String, - }, - Split { - axis: SplitAxis, - ratio_permille: u16, - first: Box, - second: Box, - }, -} - -impl LayoutSession { - /// Number of panes (leaves) in this tree. - pub fn pane_count(&self) -> usize { - match self { - Self::Pane { .. } => 1, - Self::Split { first, second, .. } => first.pane_count() + second.pane_count(), - } - } - - /// Working directory of the depth-first first leaf — the leaf a restored - /// tab's first pane adopts. - pub fn first_cwd(&self) -> &str { - match self { - Self::Pane { cwd } => cwd, - Self::Split { first, .. } => first.first_cwd(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct TabSession { - /// Working directory of the tab's focused pane. Kept alongside `layout` - /// because the boot path seeds its first pane from it before the tree is - /// rebuilt, and v1–v3 files carry nothing else. - pub cwd: String, - /// User override from inline rename. `None` keeps cwd/title-derived labels. - #[serde(default)] - pub custom_name: Option, - /// User-selected tab light-strip color. `None` follows the current theme. - #[serde(default)] - pub color: Option, - /// v4: how the first pane starts. `None` (older file) means `Default`. - #[serde(default)] - pub launch: Option, - /// v4: the full split tree. `None` (older file) is a single pane at `cwd`. - #[serde(default)] - pub layout: Option, - /// v4: focused leaf as a depth-first index into `layout`. - #[serde(default)] - pub active_pane: usize, -} - -impl TabSession { - /// A v3-shaped tab: one pane at `cwd`, default shell. - pub fn single(cwd: String, custom_name: Option, color: Option) -> Self { - Self { cwd, custom_name, color, launch: None, layout: None, active_pane: 0 } - } -} - -/// Last normal (non-maximized, non-fullscreen) inner size in logical pixels. -/// Logical units keep the perceived size stable when the next launch lands on -/// a monitor with a different DPI scale factor. Write-only since v4: startup -/// always sizes the window from the configured column/line count, so this -/// record is diagnostic and forward-compat data, never replayed at boot. -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -pub struct WindowState { - pub width: u32, - pub height: u32, - #[serde(default)] - pub maximized: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct Session { - pub version: u32, - /// Launches since the last successful autosave (crash-loop breaker). - #[serde(default)] - pub boot_attempts: u32, - pub active_tab: usize, - pub tabs: Vec, - #[serde(default)] - pub window: Option, -} - -impl Session { - pub fn new(active_tab: usize, tabs: Vec) -> Self { - Self { version: VERSION, boot_attempts: 0, active_tab, tabs, window: None } - } -} - -/// `%APPDATA%\Nebula\session.json` (or the `.config` fallback), next to the -/// settings and history files. -fn session_path() -> PathBuf { - crate::display::nebula_data_dir().join("session.json") -} - -/// Parse a session/workspace document, upgrading older versions in place. -fn parse(data: &str) -> Option { - let mut session: Session = serde_json::from_str(data).ok()?; - // Defaults fill fields introduced after v1. Upgrade in memory so the first - // successful autosave (or re-export) rewrites the current format. - if matches!(session.version, 1..=3) { - session.version = VERSION; - } - (session.version == VERSION).then_some(session) -} - -/// Load the previous session, if any and version-compatible. -pub fn load() -> Option { - load_from(&session_path()) -} - -/// Load a session/workspace file from an explicit path (workspace import). -pub fn load_from(path: &Path) -> Option { - parse(&std::fs::read_to_string(path).ok()?) -} - -/// Persist `session`. Best-effort: failures must never take the terminal down. -/// The atomic replace matters here — this file is written every second and a -/// crash mid-write must not cost the very session it exists to restore. -pub fn save(session: &Session) { - if let Ok(json) = serde_json::to_string(session) { - let _ = crate::atomic_file::write(&session_path(), json.as_bytes()); - } -} - -/// Write a session as a named workspace file. Pretty-printed — workspace -/// files are user-visible artifacts meant to be read, diffed and versioned. -pub fn save_to(path: &Path, session: &Session) -> std::io::Result<()> { - let json = serde_json::to_string_pretty(session).map_err(std::io::Error::other)?; - crate::atomic_file::write(path, json.as_bytes()) -} - -/// Whether a loaded session should actually be restored: respects the -/// crash-loop breaker and skips empty sessions (a clean quit — every tab -/// closed one by one — persists an empty tab list on purpose). -pub fn should_restore(session: &Session) -> bool { - session.boot_attempts < MAX_BOOT_ATTEMPTS && !session.tabs.is_empty() -} - -/// A saved cwd as a `PathBuf`, if it still exists on disk. A vanished -/// directory must not sink the pane spawn — ConPTY fails outright on an -/// invalid startup directory — so callers fall back to the default cwd. -pub fn valid_dir(cwd: &str) -> Option { - let cwd = cwd.trim(); - if cwd.is_empty() { - return None; - } - let path = PathBuf::from(cwd); - path.is_dir().then_some(path) -} - -/// Bump the attempt counter on disk before a restore is tried, so a crash -/// during/after restore is counted against the loop breaker. -pub fn mark_boot_attempt(session: &mut Session) { - session.boot_attempts += 1; - save(session); -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn v1_tabs_deserialize_with_default_metadata() { - let json = r#"{"version":1,"boot_attempts":0,"active_tab":0,"tabs":[{"cwd":"D:/work"}]}"#; - let session: Session = serde_json::from_str(json).unwrap(); - assert_eq!(session.version, 1); - assert_eq!(session.tabs[0].custom_name, None); - assert_eq!(session.tabs[0].color, None); - assert_eq!(session.tabs[0].layout, None); - assert_eq!(session.tabs[0].launch, None); - } - - #[test] - fn v2_round_trip_preserves_tab_name_and_color() { - let session = Session::new( - 0, - vec![TabSession::single( - "D:/work".into(), - Some("Backend".into()), - Some(Rgb::new(97, 175, 239)), - )], - ); - let json = serde_json::to_string(&session).unwrap(); - let restored: Session = serde_json::from_str(&json).unwrap(); - assert_eq!(restored, session); - } - - #[test] - fn older_session_without_window_state_stays_compatible() { - let json = r#"{"version":2,"boot_attempts":0,"active_tab":0,"tabs":[{"cwd":"D:/work"}]}"#; - let session: Session = serde_json::from_str(json).unwrap(); - assert_eq!(session.window, None); - } - - #[test] - fn window_state_round_trip_preserves_logical_size_and_maximize() { - let mut session = Session::new(0, Vec::new()); - session.window = Some(WindowState { width: 1280, height: 720, maximized: true }); - let json = serde_json::to_string(&session).unwrap(); - let restored: Session = serde_json::from_str(&json).unwrap(); - assert_eq!(restored.window, session.window); - } - - #[test] - fn v3_file_upgrades_in_place_to_v4() { - let json = r#"{"version":3,"boot_attempts":1,"active_tab":0,"tabs":[{"cwd":"D:/work"}]}"#; - let session = parse(json).expect("v3 must parse"); - assert_eq!(session.version, VERSION); - assert_eq!(session.tabs[0].layout, None); - assert_eq!(session.tabs[0].active_pane, 0); - } - - #[test] - fn v4_split_tree_and_launch_round_trip() { - let mut tab = TabSession::single("D:/work".into(), None, None); - tab.launch = Some(LaunchSession::Ssh { host: "root@203.0.113.7".into() }); - tab.layout = Some(LayoutSession::Split { - axis: SplitAxis::LeftRight, - ratio_permille: 618, - first: Box::new(LayoutSession::Pane { cwd: "D:/work".into() }), - second: Box::new(LayoutSession::Split { - axis: SplitAxis::TopBottom, - ratio_permille: 500, - first: Box::new(LayoutSession::Pane { cwd: "D:/logs".into() }), - second: Box::new(LayoutSession::Pane { cwd: String::new() }), - }), - }); - tab.active_pane = 2; - let session = Session::new(0, vec![tab]); - - let json = serde_json::to_string_pretty(&session).unwrap(); - let restored = parse(&json).expect("v4 must parse"); - assert_eq!(restored, session); - assert_eq!(restored.tabs[0].layout.as_ref().unwrap().pane_count(), 3); - } - - #[test] - fn workspace_files_round_trip_through_save_to_and_load_from() { - let dir = std::env::temp_dir().join("nebula-session-test"); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("roundtrip.nebula-workspace.json"); - - let mut tab = TabSession::single("D:/work".into(), Some("Main".into()), None); - tab.launch = Some(LaunchSession::Shell { - name: "Debian (WSL)".into(), - program: "wsl.exe".into(), - args: vec!["-d".into(), "Debian".into()], - }); - let session = Session::new(0, vec![tab]); - - save_to(&path, &session).expect("save_to must succeed"); - let restored = load_from(&path).expect("load_from must parse"); - assert_eq!(restored, session); - let _ = std::fs::remove_file(&path); - } -} +//! Session restore: reopen with the same tabs (and their directories) you had +//! when the window closed, with no "restore?" dialog. +//! +//! A snapshot is written continuously (1 Hz, skipped when nothing changed), so +//! a crash or force-kill still restores to within a second of where you were. +//! `boot_attempts` guards against a restore-crash loop: it's bumped before the +//! restore is attempted and cleared by the first successful autosave, so after +//! three failed launches Nebula starts clean to break the cycle. +//! +//! v2 additionally preserves each tab's custom name and optional color. v3 +//! persists the normal logical window size and maximized state. v4 records the +//! full split tree of every tab (axis, ratio, per-pane cwd) plus the tab's +//! launch identity (shell / profile / SSH destination), and the same schema +//! doubles as the workspace-export file format: `session.json` is simply the +//! automatic, unnamed workspace. + +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::display::color::Rgb; + +/// Highest snapshot format this build understands. +const VERSION: u32 = 4; + +/// Give up restoring after this many launches that never reached a successful +/// autosave (i.e. crashed within the first second). +const MAX_BOOT_ATTEMPTS: u32 = 3; + +/// How a tab's first pane starts. The persistable subset of `TabLaunch`: +/// document and settings tabs never enter a session. Shell and profile +/// launches embed their full command so an exported workspace stays portable +/// even when the target machine's config lists different profiles. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum LaunchSession { + Default, + /// A detected shell from the new-tab dropdown (e.g. a WSL distro). + Shell { + name: String, + program: String, + args: Vec, + }, + /// A quick-launch profile, embedded rather than referenced by name. + Profile { + name: String, + command: String, + args: Vec, + cwd: Option, + }, + /// A saved SSH destination; restoring reconnects automatically. + Ssh { + host: String, + }, +} + +/// Split axis, mirrored from `display::SplitDirection` so the display layer +/// stays serde-free. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum SplitAxis { + LeftRight, + TopBottom, +} + +/// A tab's pane tree. Leaves carry each pane's working directory; splits carry +/// the axis and the first child's share in permille — an integer, so autosave +/// change detection and file diffs never trip on f32 serialization noise. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum LayoutSession { + Pane { + cwd: String, + }, + Split { + axis: SplitAxis, + ratio_permille: u16, + first: Box, + second: Box, + }, +} + +impl LayoutSession { + /// Number of panes (leaves) in this tree. + pub fn pane_count(&self) -> usize { + match self { + Self::Pane { .. } => 1, + Self::Split { first, second, .. } => first.pane_count() + second.pane_count(), + } + } + + /// Working directory of the depth-first first leaf — the leaf a restored + /// tab's first pane adopts. + pub fn first_cwd(&self) -> &str { + match self { + Self::Pane { cwd } => cwd, + Self::Split { first, .. } => first.first_cwd(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct TabSession { + /// Working directory of the tab's focused pane. Kept alongside `layout` + /// because the boot path seeds its first pane from it before the tree is + /// rebuilt, and v1–v3 files carry nothing else. + pub cwd: String, + /// User override from inline rename. `None` keeps cwd/title-derived labels. + #[serde(default)] + pub custom_name: Option, + /// User-selected tab light-strip color. `None` follows the current theme. + #[serde(default)] + pub color: Option, + /// v4: how the first pane starts. `None` (older file) means `Default`. + #[serde(default)] + pub launch: Option, + /// v4: the full split tree. `None` (older file) is a single pane at `cwd`. + #[serde(default)] + pub layout: Option, + /// v4: focused leaf as a depth-first index into `layout`. + #[serde(default)] + pub active_pane: usize, +} + +impl TabSession { + /// A v3-shaped tab: one pane at `cwd`, default shell. + pub fn single(cwd: String, custom_name: Option, color: Option) -> Self { + Self { cwd, custom_name, color, launch: None, layout: None, active_pane: 0 } + } +} + +/// Last normal (non-maximized, non-fullscreen) inner size in logical pixels. +/// Logical units keep the perceived size stable when the next launch lands on +/// a monitor with a different DPI scale factor. Write-only since v4: startup +/// always sizes the window from the configured column/line count, so this +/// record is diagnostic and forward-compat data, never replayed at boot. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub struct WindowState { + pub width: u32, + pub height: u32, + #[serde(default)] + pub maximized: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Session { + pub version: u32, + /// Launches since the last successful autosave (crash-loop breaker). + #[serde(default)] + pub boot_attempts: u32, + pub active_tab: usize, + pub tabs: Vec, + #[serde(default)] + pub window: Option, + /// 上一次退出有没有走完收尾。1 Hz 自动保存一律写 `false`,只有真正的 + /// 收尾路径(关窗、退出、detach 驻留)在最后一笔写 `true`——所以启动时 + /// 读到 `false` 就说明上次是崩溃、强杀或断电。 + /// + /// 老版本文件没有这个字段,`serde(default)` 会给 `false`:一次性地把 + /// 升级前的最后一次会话判成异常退出。恢复行为完全不变(两种情况都恢复), + /// 代价只是多一条提示,所以不值得为它单开一个版本号。 + #[serde(default)] + pub clean_exit: bool, +} + +impl Session { + pub fn new(active_tab: usize, tabs: Vec) -> Self { + Self { + version: VERSION, + boot_attempts: 0, + active_tab, + tabs, + window: None, + clean_exit: false, + } + } +} + +/// `%APPDATA%\Nebula\session.json` (or the `.config` fallback), next to the +/// settings and history files. +fn session_path() -> PathBuf { + crate::display::nebula_data_dir().join("session.json") +} + +/// Parse a session/workspace document, upgrading older versions in place. +fn parse(data: &str) -> Option { + let mut session: Session = serde_json::from_str(data).ok()?; + // Defaults fill fields introduced after v1. Upgrade in memory so the first + // successful autosave (or re-export) rewrites the current format. + if matches!(session.version, 1..=3) { + session.version = VERSION; + } + (session.version == VERSION).then_some(session) +} + +/// Load the previous session, if any and version-compatible. +pub fn load() -> Option { + load_from(&session_path()) +} + +/// Load a session/workspace file from an explicit path (workspace import). +pub fn load_from(path: &Path) -> Option { + parse(&std::fs::read_to_string(path).ok()?) +} + +/// Persist `session`. Best-effort: failures must never take the terminal down. +/// The atomic replace matters here — this file is written every second and a +/// crash mid-write must not cost the very session it exists to restore. +pub fn save(session: &Session) { + if let Ok(json) = serde_json::to_string(session) { + let _ = crate::atomic_file::write(&session_path(), json.as_bytes()); + } +} + +/// Write a session as a named workspace file. Pretty-printed — workspace +/// files are user-visible artifacts meant to be read, diffed and versioned. +pub fn save_to(path: &Path, session: &Session) -> std::io::Result<()> { + let json = serde_json::to_string_pretty(session).map_err(std::io::Error::other)?; + crate::atomic_file::write(path, json.as_bytes()) +} + +/// Whether a loaded session should actually be restored: respects the +/// crash-loop breaker and skips empty sessions (a clean quit — every tab +/// closed one by one — persists an empty tab list on purpose). +pub fn should_restore(session: &Session) -> bool { + session.boot_attempts < MAX_BOOT_ATTEMPTS && !session.tabs.is_empty() +} + +/// 收尾时的最后一笔快照:先打上 [`Session::clean_exit`] 再落盘。所有正常 +/// 退出路径都必须走这里,否则下次启动会把这次退出误判成崩溃。 +pub fn save_final(session: &mut Session) { + session.clean_exit = true; + save(session); +} + +/// 上次是异常退出(崩溃 / 强杀 / 断电),而不是正常收尾。空会话不算—— +/// 那是一路关标签关干净的正常退出。 +pub fn was_crash(session: &Session) -> bool { + !session.clean_exit && !session.tabs.is_empty() +} + +/// 断路器跳闸(连续 [`MAX_BOOT_ATTEMPTS`] 次启动都没活到第一次自动保存)时, +/// 把这份会话挪到 `session.crashed.json` 再让本次启动走干净路径。 +/// +/// 必须**挪走**而不是留在原地:启动一成功,一秒后的自动保存就会把 +/// `session.json` 盖掉,那份「一恢复就崩」的现场是唯一的诊断材料。顺带 +/// 也让 `boot_attempts` 自然归零,用户不必手工删文件才能恢复正常。 +pub fn quarantine() -> Option { + let from = session_path(); + let to = crate::display::nebula_data_dir().join("session.crashed.json"); + std::fs::copy(&from, &to).ok()?; + let _ = std::fs::remove_file(&from); + Some(to) +} + +/// A saved cwd as a `PathBuf`, if it still exists on disk. A vanished +/// directory must not sink the pane spawn — ConPTY fails outright on an +/// invalid startup directory — so callers fall back to the default cwd. +pub fn valid_dir(cwd: &str) -> Option { + let cwd = cwd.trim(); + if cwd.is_empty() { + return None; + } + let path = PathBuf::from(cwd); + path.is_dir().then_some(path) +} + +/// Bump the attempt counter on disk before a restore is tried, so a crash +/// during/after restore is counted against the loop breaker. +pub fn mark_boot_attempt(session: &mut Session) { + session.boot_attempts += 1; + save(session); +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 崩溃判定的三种现场:自动保存写的半路快照、正常收尾、以及一路关标签 + /// 关到空——只有第一种算异常退出。 + #[test] + fn only_an_unfinished_teardown_counts_as_a_crash() { + let mut running = Session::new(0, vec![TabSession::single("D:/work".into(), None, None)]); + assert!(was_crash(&running), "1 Hz 自动保存写的快照 = 还没走到收尾"); + running.clean_exit = true; + assert!(!was_crash(&running)); + let quit_clean = Session::new(0, vec![]); + assert!(!was_crash(&quit_clean), "空会话是正常退出,不是崩溃"); + } + + /// 升级前的会话文件没有 `clean_exit`,读出来必须是 false(当作异常退出) + /// 而不是解析失败——恢复行为不变,只多一条提示。 + #[test] + fn a_file_written_before_clean_exit_existed_still_parses() { + let json = r#"{"version":4,"boot_attempts":0,"active_tab":0,"tabs":[{"cwd":"D:/work"}]}"#; + let session: Session = serde_json::from_str(json).unwrap(); + assert!(!session.clean_exit); + assert!(should_restore(&session)); + } + + #[test] + fn v1_tabs_deserialize_with_default_metadata() { + let json = r#"{"version":1,"boot_attempts":0,"active_tab":0,"tabs":[{"cwd":"D:/work"}]}"#; + let session: Session = serde_json::from_str(json).unwrap(); + assert_eq!(session.version, 1); + assert_eq!(session.tabs[0].custom_name, None); + assert_eq!(session.tabs[0].color, None); + assert_eq!(session.tabs[0].layout, None); + assert_eq!(session.tabs[0].launch, None); + } + + #[test] + fn v2_round_trip_preserves_tab_name_and_color() { + let session = Session::new( + 0, + vec![TabSession::single( + "D:/work".into(), + Some("Backend".into()), + Some(Rgb::new(97, 175, 239)), + )], + ); + let json = serde_json::to_string(&session).unwrap(); + let restored: Session = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, session); + } + + #[test] + fn older_session_without_window_state_stays_compatible() { + let json = r#"{"version":2,"boot_attempts":0,"active_tab":0,"tabs":[{"cwd":"D:/work"}]}"#; + let session: Session = serde_json::from_str(json).unwrap(); + assert_eq!(session.window, None); + } + + #[test] + fn window_state_round_trip_preserves_logical_size_and_maximize() { + let mut session = Session::new(0, Vec::new()); + session.window = Some(WindowState { width: 1280, height: 720, maximized: true }); + let json = serde_json::to_string(&session).unwrap(); + let restored: Session = serde_json::from_str(&json).unwrap(); + assert_eq!(restored.window, session.window); + } + + #[test] + fn v3_file_upgrades_in_place_to_v4() { + let json = r#"{"version":3,"boot_attempts":1,"active_tab":0,"tabs":[{"cwd":"D:/work"}]}"#; + let session = parse(json).expect("v3 must parse"); + assert_eq!(session.version, VERSION); + assert_eq!(session.tabs[0].layout, None); + assert_eq!(session.tabs[0].active_pane, 0); + } + + #[test] + fn v4_split_tree_and_launch_round_trip() { + let mut tab = TabSession::single("D:/work".into(), None, None); + tab.launch = Some(LaunchSession::Ssh { host: "root@203.0.113.7".into() }); + tab.layout = Some(LayoutSession::Split { + axis: SplitAxis::LeftRight, + ratio_permille: 618, + first: Box::new(LayoutSession::Pane { cwd: "D:/work".into() }), + second: Box::new(LayoutSession::Split { + axis: SplitAxis::TopBottom, + ratio_permille: 500, + first: Box::new(LayoutSession::Pane { cwd: "D:/logs".into() }), + second: Box::new(LayoutSession::Pane { cwd: String::new() }), + }), + }); + tab.active_pane = 2; + let session = Session::new(0, vec![tab]); + + let json = serde_json::to_string_pretty(&session).unwrap(); + let restored = parse(&json).expect("v4 must parse"); + assert_eq!(restored, session); + assert_eq!(restored.tabs[0].layout.as_ref().unwrap().pane_count(), 3); + } + + #[test] + fn workspace_files_round_trip_through_save_to_and_load_from() { + let dir = std::env::temp_dir().join("nebula-session-test"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("roundtrip.nebula-workspace.json"); + + let mut tab = TabSession::single("D:/work".into(), Some("Main".into()), None); + tab.launch = Some(LaunchSession::Shell { + name: "Debian (WSL)".into(), + program: "wsl.exe".into(), + args: vec!["-d".into(), "Debian".into()], + }); + let session = Session::new(0, vec![tab]); + + save_to(&path, &session).expect("save_to must succeed"); + let restored = load_from(&path).expect("load_from must parse"); + assert_eq!(restored, session); + let _ = std::fs::remove_file(&path); + } +} diff --git a/nebula_app/src/shell_detect.rs b/nebula_app/src/shell_detect.rs index 70a94b5..55367de 100644 --- a/nebula_app/src/shell_detect.rs +++ b/nebula_app/src/shell_detect.rs @@ -267,8 +267,7 @@ fn find_pwsh() -> Option { return Some(path); } // Store install exposes an execution alias under WindowsApps. - env_path("LOCALAPPDATA") - .and_then(|root| existing(root.join(r"Microsoft\WindowsApps\pwsh.exe"))) + env_path("LOCALAPPDATA").and_then(|root| existing(root.join(r"Microsoft\WindowsApps\pwsh.exe"))) } #[cfg(windows)] @@ -290,10 +289,9 @@ fn find_git_bash() -> Option { } // Well-known directories (mirrors the PTY layer's own bash lookup). - for candidate in [ - r"C:\Program Files\Git\bin\bash.exe", - r"C:\Program Files (x86)\Git\bin\bash.exe", - ] { + for candidate in + [r"C:\Program Files\Git\bin\bash.exe", r"C:\Program Files (x86)\Git\bin\bash.exe"] + { if let Some(path) = existing(PathBuf::from(candidate)) { return Some(path); } @@ -329,17 +327,74 @@ fn find_nushell() -> Option { }) } +/// 静默 tab 行右侧的 shell 短标:`wsl:Ubuntu` /「WSL · Ubuntu」→ `ubuntu`、 +/// 「PowerShell 7」/ `pwsh` → `pwsh`……口径按人叫得出的短名,不按 exe 名。 +/// 吃 shell 的 settings id 或菜单显示名都行——两条管道喂进来的是哪种取决 +/// 于 tab 的启动方式,短标必须两头一致。 +pub fn shell_short_tag(name_or_id: &str) -> String { + let raw = name_or_id.trim(); + if let Some(distro) = raw.strip_prefix("wsl:") { + return distro.to_ascii_lowercase(); + } + // 菜单显示名「WSL · Ubuntu」:发行版名就是短标。 + if let Some((_, distro)) = raw.split_once('·') { + return distro.trim().to_ascii_lowercase(); + } + let lower = raw.to_ascii_lowercase(); + if lower.contains("powershell") { + // 用户口头就用 pwsh 区分 7 和老 5.1,只有 Windows PowerShell 保留全名。 + return if lower.contains("windows") { "powershell".into() } else { "pwsh".into() }; + } + if lower.contains("bash") { + return "bash".into(); + } + if lower.contains("nushell") { + return "nu".into(); + } + if lower.contains("cmd") || lower.contains("命令提示符") { + return "cmd".into(); + } + // 未知的取首词小写、封顶 10 字符——这是注脚,不是第二个标签名。 + lower.split_whitespace().next().unwrap_or("").chars().take(10).collect() +} + +/// 已注册 WSL 发行版的名字(注册表 `Lxss` 各子键的 `DistributionName`), +/// 字母序。目录选择器用它把 `\\wsl.localhost\<名>` 钉进侧栏——系统的 +/// 「Linux」导航节点不是每台 Win11 都有(issue #12 的现场就没有),钉进 +/// 去的入口才保证在。跳过 docker-desktop 等管道发行版,口径同 shell 菜单。 +#[cfg(windows)] +pub fn wsl_distro_names() -> Vec { + use winreg::RegKey; + use winreg::enums::HKEY_CURRENT_USER; + + let Ok(lxss) = RegKey::predef(HKEY_CURRENT_USER) + .open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Lxss") + else { + return Vec::new(); + }; + let mut names = Vec::new(); + for guid in lxss.enum_keys().flatten() { + let Ok(sub) = lxss.open_subkey(&guid) else { continue }; + let Ok(name) = sub.get_value::("DistributionName") else { continue }; + if name.starts_with("docker-desktop") { + continue; + } + names.push(name); + } + names.sort(); + names +} + #[cfg(windows)] fn find_wsl_distros() -> Vec { use winreg::RegKey; use winreg::enums::HKEY_CURRENT_USER; - let wsl_exe = match env_path("SystemRoot") - .and_then(|root| existing(root.join(r"System32\wsl.exe"))) - { - Some(path) => path, - None => return Vec::new(), - }; + let wsl_exe = + match env_path("SystemRoot").and_then(|root| existing(root.join(r"System32\wsl.exe"))) { + Some(path) => path, + None => return Vec::new(), + }; let lxss = match RegKey::predef(HKEY_CURRENT_USER) .open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Lxss") @@ -383,6 +438,20 @@ fn find_wsl_distros() -> Vec { #[cfg(test)] mod tests { + #[test] + fn shell_short_tags_read_like_what_people_call_them() { + use super::shell_short_tag; + // 两条管道各自的形态都要认:settings id 与菜单显示名。 + assert_eq!(shell_short_tag("wsl:Ubuntu-24.04"), "ubuntu-24.04"); + assert_eq!(shell_short_tag("WSL · Debian"), "debian"); + assert_eq!(shell_short_tag("PowerShell 7"), "pwsh"); + assert_eq!(shell_short_tag("pwsh"), "pwsh"); + assert_eq!(shell_short_tag("Windows PowerShell"), "powershell"); + assert_eq!(shell_short_tag("Git Bash"), "bash"); + assert_eq!(shell_short_tag("Nushell"), "nu"); + assert_eq!(shell_short_tag("CMD"), "cmd"); + } + use super::*; #[test] diff --git a/nebula_app/src/ssh.rs b/nebula_app/src/ssh.rs index f727502..f0f8588 100644 --- a/nebula_app/src/ssh.rs +++ b/nebula_app/src/ssh.rs @@ -156,8 +156,7 @@ pub fn ssh_destination(line: &str) -> Option { if VALUE_OPTS.contains(&b) { // Attached value (`-p2222`) or the next token (`-p 2222`). let attached = &tok[idx + 1..]; - let value = - if attached.is_empty() { tokens.next() } else { Some(attached) }; + let value = if attached.is_empty() { tokens.next() } else { Some(attached) }; match b { b'l' => login_user = value, b'p' => port = value, @@ -426,9 +425,9 @@ fn askpass_destination_from_args(args: &[String]) -> Option { while i < args.len() { let arg = &args[i]; if arg == "--" { - return args.get(i + 1).map(|dest| { - format_destination(dest, login_user.as_deref(), port.as_deref()) - }); + return args + .get(i + 1) + .map(|dest| format_destination(dest, login_user.as_deref(), port.as_deref())); } let bytes = arg.as_bytes(); if bytes.first() == Some(&b'-') && bytes.len() >= 2 { @@ -487,11 +486,7 @@ pub fn build_pane_launch( } else if shell == "cmd" { format!("\"{exe_text}\" ssh -- {destination}\r") } else if matches!(shell.as_str(), "bash" | "git-bash" | "gitbash") { - format!( - "'{}' ssh -- '{}'\r", - git_bash_path(exe), - destination.replace('\'', "'\\''") - ) + format!("'{}' ssh -- '{}'\r", git_bash_path(exe), destination.replace('\'', "'\\''")) } else if shell == "nu" { format!( "^'{}' ssh -- '{}'\r", @@ -499,11 +494,7 @@ pub fn build_pane_launch( destination.replace('\'', "''") ) } else if shell.starts_with("wsl:") || shell == "wsl" { - format!( - "'{}' ssh -- '{}'\r", - wsl_path(exe), - destination.replace('\'', "'\\''") - ) + format!("'{}' ssh -- '{}'\r", wsl_path(exe), destination.replace('\'', "'\\''")) } else { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, @@ -524,10 +515,8 @@ pub fn build_askpass_env( destination: &str, session_id: u64, ) -> SshAskpassEnv { - let attempt_path = std::env::temp_dir().join(format!( - "nebula-ssh-askpass-{}-{session_id}.flag", - std::process::id() - )); + let attempt_path = std::env::temp_dir() + .join(format!("nebula-ssh-askpass-{}-{session_id}.flag", std::process::id())); let _ = std::fs::remove_file(&attempt_path); let mut values = std::collections::HashMap::new(); values.insert("SSH_ASKPASS".into(), exe.to_string_lossy().into_owned()); @@ -535,10 +524,7 @@ pub fn build_askpass_env( values.insert("DISPLAY".into(), "nebula".into()); values.insert("NEBULA_SSH_ASKPASS".into(), "1".into()); values.insert("NEBULA_SSH_DESTINATION".into(), destination.into()); - values.insert( - "NEBULA_SSH_ASKPASS_ATTEMPT".into(), - attempt_path.display().to_string(), - ); + values.insert("NEBULA_SSH_ASKPASS_ATTEMPT".into(), attempt_path.display().to_string()); SshAskpassEnv { values, attempt_path } } @@ -579,9 +565,9 @@ pub fn run(args: Vec) -> i32 { let ssh = find_ssh(); let mut cmd = Command::new(&ssh); let askpass = askpass_destination_from_args(&args).and_then(|destination| { - std::env::current_exe().ok().map(|exe| { - build_askpass_env(&exe, &destination, std::process::id() as u64) - }) + std::env::current_exe() + .ok() + .map(|exe| build_askpass_env(&exe, &destination, std::process::id() as u64)) }); if let Some(context) = &askpass { cmd.envs(&context.values); @@ -741,24 +727,34 @@ mod tests { fn ssh_pane_launch_uses_shell_specific_quoting() { let exe = std::path::Path::new(r"C:\Program Files\Nebula\nebula.exe"); let ps = build_pane_launch("pwsh", exe, "root@example.com").unwrap(); - assert_eq!(String::from_utf8(ps.command).unwrap(), - "& 'C:\\Program Files\\Nebula\\nebula.exe' ssh -- 'root@example.com'\r"); + assert_eq!( + String::from_utf8(ps.command).unwrap(), + "& 'C:\\Program Files\\Nebula\\nebula.exe' ssh -- 'root@example.com'\r" + ); let cmd = build_pane_launch("cmd", exe, "root@example.com").unwrap(); - assert_eq!(String::from_utf8(cmd.command).unwrap(), - "\"C:\\Program Files\\Nebula\\nebula.exe\" ssh -- root@example.com\r"); + assert_eq!( + String::from_utf8(cmd.command).unwrap(), + "\"C:\\Program Files\\Nebula\\nebula.exe\" ssh -- root@example.com\r" + ); let bash = build_pane_launch("git-bash", exe, "root@example.com").unwrap(); - assert_eq!(String::from_utf8(bash.command).unwrap(), - "'/c/Program Files/Nebula/nebula.exe' ssh -- 'root@example.com'\r"); + assert_eq!( + String::from_utf8(bash.command).unwrap(), + "'/c/Program Files/Nebula/nebula.exe' ssh -- 'root@example.com'\r" + ); let nu = build_pane_launch("nu", exe, "root@example.com").unwrap(); - assert_eq!(String::from_utf8(nu.command).unwrap(), - "^'C:\\Program Files\\Nebula\\nebula.exe' ssh -- 'root@example.com'\r"); + assert_eq!( + String::from_utf8(nu.command).unwrap(), + "^'C:\\Program Files\\Nebula\\nebula.exe' ssh -- 'root@example.com'\r" + ); let wsl = build_pane_launch("wsl:Ubuntu", exe, "root@example.com").unwrap(); - assert_eq!(String::from_utf8(wsl.command).unwrap(), - "'/mnt/c/Program Files/Nebula/nebula.exe' ssh -- 'root@example.com'\r"); + assert_eq!( + String::from_utf8(wsl.command).unwrap(), + "'/mnt/c/Program Files/Nebula/nebula.exe' ssh -- 'root@example.com'\r" + ); } #[test] @@ -768,10 +764,16 @@ mod tests { "alice@example.com", 42, ); - assert_eq!(launch.values.get("SSH_ASKPASS").map(String::as_str), Some(r"C:\Nebula\nebula.exe")); + assert_eq!( + launch.values.get("SSH_ASKPASS").map(String::as_str), + Some(r"C:\Nebula\nebula.exe") + ); assert_eq!(launch.values.get("SSH_ASKPASS_REQUIRE").map(String::as_str), Some("force")); assert_eq!(launch.values.get("NEBULA_SSH_ASKPASS").map(String::as_str), Some("1")); - assert_eq!(launch.values.get("NEBULA_SSH_DESTINATION").map(String::as_str), Some("alice@example.com")); + assert_eq!( + launch.values.get("NEBULA_SSH_DESTINATION").map(String::as_str), + Some("alice@example.com") + ); assert!(launch.values["NEBULA_SSH_ASKPASS_ATTEMPT"].contains("42")); } diff --git a/nebula_app/src/ssh_profiles.rs b/nebula_app/src/ssh_profiles.rs index 6b35e30..51cbd04 100644 --- a/nebula_app/src/ssh_profiles.rs +++ b/nebula_app/src/ssh_profiles.rs @@ -1,280 +1,397 @@ -use std::io; -use std::path::{Path, PathBuf}; - -use serde::{Deserialize, Deserializer, Serialize}; -use windows_sys::Win32::Storage::FileSystem::{ - MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW, -}; - -const PROFILE_VERSION: u32 = 1; - -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum SshAuthMode { - #[default] - Auto, - Password, - PublicKey, - KeyboardInteractive, -} - -impl<'de> Deserialize<'de> for SshAuthMode { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let value = String::deserialize(deserializer)?; - Ok(match value.as_str() { - "password" => Self::Password, - "public_key" => Self::PublicKey, - // v0.5 移除了 Agent;旧配置回退到 Auto,避免升级后配置失效。 - "agent" => Self::Auto, - "keyboard_interactive" => Self::KeyboardInteractive, - _ => Self::Auto, - }) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct SshProfileAuth { - pub destination: String, - #[serde(default)] - pub auth: SshAuthMode, - #[serde(default)] - pub private_keys: Vec, - /// 列表里显示的名字。`None` / 空串都表示回落到地址本身,所以旧配置文件 - /// 不需要迁移就能读——`serde(default)` 让缺字段等价于"没起名字"。 - #[serde(default, skip_serializing_if = "Option::is_none")] - pub label: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SshProfiles { - #[serde(default = "profile_version")] - version: u32, - #[serde(default)] - profiles: Vec, -} - -impl Default for SshProfiles { - fn default() -> Self { - Self { version: PROFILE_VERSION, profiles: Vec::new() } - } -} - -impl SshProfiles { - pub fn load(path: &Path) -> io::Result { - let data = match std::fs::read(path) { - Ok(data) => data, - Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Self::default()), - Err(err) => return Err(err), - }; - let mut profiles: Self = serde_json::from_slice(&data) - .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; - for profile in &mut profiles.profiles { - deduplicate_key_paths(&mut profile.private_keys); - } - profiles.version = PROFILE_VERSION; - Ok(profiles) - } - - pub fn save(&self, path: &Path) -> io::Result<()> { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - let data = serde_json::to_vec_pretty(self).map_err(io::Error::other)?; - let temporary = path.with_extension(format!("nebula-tmp-{}", std::process::id())); - std::fs::write(&temporary, data)?; - let result = replace_file(&temporary, path); - if result.is_err() { - let _ = std::fs::remove_file(&temporary); - } - result - } - - pub fn for_destination(&self, destination: &str) -> SshProfileAuth { - self.profiles - .iter() - .find(|profile| profile.destination == destination) - .cloned() - .unwrap_or_else(|| SshProfileAuth { - destination: destination.to_owned(), - auth: SshAuthMode::Auto, - private_keys: Vec::new(), - label: None, - }) - } - - /// 地址 → 显示名。侧栏每帧都要画主机行,不能每帧读一次 JSON,所以调用方 - /// 把结果缓存起来,只在编辑器保存后刷新。 - pub fn labels(&self) -> std::collections::HashMap { - self.profiles - .iter() - .filter_map(|profile| { - let label = profile.label.as_deref()?.trim(); - (!label.is_empty()).then(|| (profile.destination.clone(), label.to_owned())) - }) - .collect() - } - - pub fn upsert(&mut self, mut profile: SshProfileAuth) { - deduplicate_key_paths(&mut profile.private_keys); - if let Some(existing) = - self.profiles.iter_mut().find(|existing| existing.destination == profile.destination) - { - *existing = profile; - } else { - self.profiles.push(profile); - } - } - - pub fn remove(&mut self, destination: &str) { - self.profiles.retain(|profile| profile.destination != destination); - } - - pub fn rename(&mut self, old: &str, new: &str) { - let Some(mut profile) = - self.profiles.iter().find(|profile| profile.destination == old).cloned() - else { - return; - }; - self.remove(old); - profile.destination = new.to_owned(); - self.upsert(profile); - } -} - -fn profile_version() -> u32 { - PROFILE_VERSION -} - -fn deduplicate_key_paths(paths: &mut Vec) { - let mut seen = Vec::::new(); - paths.retain(|path| { - let normalized = path.to_string_lossy().to_lowercase(); - if seen.contains(&normalized) { - false - } else { - seen.push(normalized); - true - } - }); -} - -fn replace_file(source: &Path, destination: &Path) -> io::Result<()> { - let source = wide_path(source); - let destination = wide_path(destination); - let ok = unsafe { - MoveFileExW( - source.as_ptr(), - destination.as_ptr(), - MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, - ) - }; - if ok == 0 { Err(io::Error::last_os_error()) } else { Ok(()) } -} - -fn wide_path(path: &Path) -> Vec { - use std::os::windows::ffi::OsStrExt; - path.as_os_str().encode_wide().chain(std::iter::once(0)).collect() -} - -#[cfg(test)] -mod tests { - use super::{SshAuthMode, SshProfileAuth, SshProfiles}; - use std::path::PathBuf; - - #[test] - fn missing_profile_defaults_to_auto_without_keys() { - let profiles = SshProfiles::default(); - - assert_eq!(profiles.for_destination("dev@example.com").auth, SshAuthMode::Auto); - assert!(profiles.for_destination("dev@example.com").private_keys.is_empty()); - } - - #[test] - fn profile_round_trip_preserves_mode_and_key_order() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("ssh_profiles.json"); - let mut profiles = SshProfiles::default(); - profiles.upsert(SshProfileAuth { - destination: "dev@example.com".to_owned(), - auth: SshAuthMode::PublicKey, - private_keys: vec![PathBuf::from(r"C:\Keys\first"), PathBuf::from(r"C:\Keys\second")], - label: None, - }); - profiles.save(&path).unwrap(); - - let loaded = SshProfiles::load(&path).unwrap(); - let profile = loaded.for_destination("dev@example.com"); - assert_eq!(profile.auth, SshAuthMode::PublicKey); - assert_eq!( - profile.private_keys, - vec![PathBuf::from(r"C:\Keys\first"), PathBuf::from(r"C:\Keys\second")] - ); - } - - #[test] - fn duplicate_windows_key_paths_are_removed_without_reordering() { - let mut profiles = SshProfiles::default(); - profiles.upsert(SshProfileAuth { - destination: "dev@example.com".to_owned(), - auth: SshAuthMode::Auto, - private_keys: vec![ - PathBuf::from(r"C:\Keys\id_ed25519"), - PathBuf::from(r"c:\keys\ID_ED25519"), - PathBuf::from(r"C:\Keys\id_rsa"), - ], - label: None, - }); - - assert_eq!( - profiles.for_destination("dev@example.com").private_keys, - vec![PathBuf::from(r"C:\Keys\id_ed25519"), PathBuf::from(r"C:\Keys\id_rsa")] - ); - } - - #[test] - fn renaming_profile_moves_auth_metadata() { - let mut profiles = SshProfiles::default(); - profiles.upsert(SshProfileAuth { - destination: "old@example.com".to_owned(), - auth: SshAuthMode::PublicKey, - private_keys: vec![PathBuf::from(r"C:\Keys\id_ed25519")], - label: None, - }); - - profiles.rename("old@example.com", "new@example.com"); - - assert_eq!(profiles.for_destination("old@example.com").auth, SshAuthMode::Auto); - assert_eq!(profiles.for_destination("new@example.com").auth, SshAuthMode::PublicKey); - } - - #[test] - fn legacy_agent_mode_migrates_to_auto() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("ssh_profiles.json"); - std::fs::write( - &path, - r#"{"version":1,"profiles":[{"destination":"dev@example.com","auth":"agent","private_keys":[]}]}"#, - ) - .unwrap(); - - let loaded = SshProfiles::load(&path).unwrap(); - assert_eq!(loaded.for_destination("dev@example.com").auth, SshAuthMode::Auto); - } - - #[test] - fn unknown_auth_mode_falls_back_to_auto() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("ssh_profiles.json"); - std::fs::write( - &path, - r#"{"version":1,"profiles":[{"destination":"dev@example.com","auth":"future-mode","private_keys":[]}]}"#, - ) - .unwrap(); - - let loaded = SshProfiles::load(&path).unwrap(); - assert_eq!(loaded.for_destination("dev@example.com").auth, SshAuthMode::Auto); - } -} +use std::io; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Deserializer, Serialize}; +use windows_sys::Win32::Storage::FileSystem::{ + MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW, +}; + +const PROFILE_VERSION: u32 = 1; + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum SshAuthMode { + #[default] + Auto, + Password, + PublicKey, + KeyboardInteractive, +} + +impl<'de> Deserialize<'de> for SshAuthMode { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Ok(match value.as_str() { + "password" => Self::Password, + "public_key" => Self::PublicKey, + // v0.5 移除了 Agent;旧配置回退到 Auto,避免升级后配置失效。 + "agent" => Self::Auto, + "keyboard_interactive" => Self::KeyboardInteractive, + _ => Self::Auto, + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SshProfileAuth { + pub destination: String, + #[serde(default)] + pub auth: SshAuthMode, + #[serde(default)] + pub private_keys: Vec, + /// 列表里显示的名字。`None` / 空串都表示回落到地址本身,所以旧配置文件 + /// 不需要迁移就能读——`serde(default)` 让缺字段等价于"没起名字"。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option, + /// 侧栏图标,取 `ui::os_icons::CATALOG` 里的 id("debian"、"docker"…)。 + /// `None` = 自动:跟着远端认出来的系统走,认不出用通用终端。存 id 而不是 + /// 码位——码位属于某一个字体文件,换字体就作废;id 是我们自己的稳定键。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub icon: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SshProfiles { + #[serde(default = "profile_version")] + version: u32, + #[serde(default)] + profiles: Vec, +} + +impl Default for SshProfiles { + fn default() -> Self { + Self { version: PROFILE_VERSION, profiles: Vec::new() } + } +} + +impl SshProfiles { + pub fn load(path: &Path) -> io::Result { + let data = match std::fs::read(path) { + Ok(data) => data, + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Self::default()), + Err(err) => return Err(err), + }; + let mut profiles: Self = serde_json::from_slice(&data) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; + for profile in &mut profiles.profiles { + deduplicate_key_paths(&mut profile.private_keys); + } + profiles.version = PROFILE_VERSION; + Ok(profiles) + } + + pub fn save(&self, path: &Path) -> io::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let data = serde_json::to_vec_pretty(self).map_err(io::Error::other)?; + let temporary = path.with_extension(format!("nebula-tmp-{}", std::process::id())); + std::fs::write(&temporary, data)?; + let result = replace_file(&temporary, path); + if result.is_err() { + let _ = std::fs::remove_file(&temporary); + } + result + } + + pub fn for_destination(&self, destination: &str) -> SshProfileAuth { + self.profiles + .iter() + .find(|profile| profile.destination == destination) + .cloned() + .unwrap_or_else(|| SshProfileAuth { + destination: destination.to_owned(), + auth: SshAuthMode::Auto, + private_keys: Vec::new(), + label: None, + icon: None, + }) + } + + /// 地址 → 显示名。侧栏每帧都要画主机行,不能每帧读一次 JSON,所以调用方 + /// 把结果缓存起来,只在编辑器保存后刷新。 + pub fn labels(&self) -> std::collections::HashMap { + self.profiles + .iter() + .filter_map(|profile| { + let label = profile.label.as_deref()?.trim(); + (!label.is_empty()).then(|| (profile.destination.clone(), label.to_owned())) + }) + .collect() + } + + /// 地址 → 图标 id。与 [`Self::labels`] 同一缓存策略:侧栏每帧要画,调 + /// 用方缓存住,编辑器保存后刷新。 + pub fn icons(&self) -> std::collections::HashMap { + self.profiles + .iter() + .filter_map(|profile| { + let icon = profile.icon.as_deref()?.trim(); + (!icon.is_empty()).then(|| (profile.destination.clone(), icon.to_owned())) + }) + .collect() + } + + /// 下一个可用的默认标签,形如「主机 6」。 + /// + /// 取现有默认标签里**编号的最大值 +1**,而不是条目总数:删掉中间几台之后 + /// 再新建,用总数必然撞上还在列表里的名字。用户自己起的名字(不匹配这个 + /// 前缀+数字的形式)不参与计算,也就不会被它挤占。 + /// + /// 语言在生成的那一刻定下来就不再变——label 是**用户数据**,跟着界面语言 + /// 来回翻译,等于我们替用户改了他列表里的条目名。 + pub fn next_default_label(&self, prefix: &str) -> String { + let max = self + .profiles + .iter() + .filter_map(|profile| profile.label.as_deref()) + .filter_map(|label| label.trim().strip_prefix(prefix)) + .filter_map(|rest| rest.trim().parse::().ok()) + .max() + .unwrap_or(0); + format!("{prefix} {}", max + 1) + } + + pub fn upsert(&mut self, mut profile: SshProfileAuth) { + deduplicate_key_paths(&mut profile.private_keys); + if let Some(existing) = + self.profiles.iter_mut().find(|existing| existing.destination == profile.destination) + { + *existing = profile; + } else { + self.profiles.push(profile); + } + } + + pub fn remove(&mut self, destination: &str) { + self.profiles.retain(|profile| profile.destination != destination); + } + + pub fn rename(&mut self, old: &str, new: &str) { + let Some(mut profile) = + self.profiles.iter().find(|profile| profile.destination == old).cloned() + else { + return; + }; + self.remove(old); + profile.destination = new.to_owned(); + self.upsert(profile); + } +} + +fn profile_version() -> u32 { + PROFILE_VERSION +} + +fn deduplicate_key_paths(paths: &mut Vec) { + let mut seen = Vec::::new(); + paths.retain(|path| { + let normalized = path.to_string_lossy().to_lowercase(); + if seen.contains(&normalized) { + false + } else { + seen.push(normalized); + true + } + }); +} + +fn replace_file(source: &Path, destination: &Path) -> io::Result<()> { + let source = wide_path(source); + let destination = wide_path(destination); + let ok = unsafe { + MoveFileExW( + source.as_ptr(), + destination.as_ptr(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + }; + if ok == 0 { Err(io::Error::last_os_error()) } else { Ok(()) } +} + +fn wide_path(path: &Path) -> Vec { + use std::os::windows::ffi::OsStrExt; + path.as_os_str().encode_wide().chain(std::iter::once(0)).collect() +} + +#[cfg(test)] +mod tests { + use super::{SshAuthMode, SshProfileAuth, SshProfiles}; + use std::path::PathBuf; + + #[test] + fn missing_profile_defaults_to_auto_without_keys() { + let profiles = SshProfiles::default(); + + assert_eq!(profiles.for_destination("dev@example.com").auth, SshAuthMode::Auto); + assert!(profiles.for_destination("dev@example.com").private_keys.is_empty()); + } + + /// 图标是后加的字段,老配置文件里根本没有这一项——它必须能照读不误, + /// 否则一次升级会让所有人的主机列表报错清空。 + #[test] + fn profiles_written_before_icons_existed_still_load() { + let old = r#"{"version":1,"profiles":[ + {"destination":"root@10.0.0.9","auth":"password","label":"生产库"} + ]}"#; + let profiles: SshProfiles = serde_json::from_str(old).expect("旧配置应当照读不误"); + let profile = profiles.for_destination("root@10.0.0.9"); + assert_eq!(profile.label.as_deref(), Some("生产库")); + assert_eq!(profile.icon, None, "没写过图标 = 自动识别"); + assert!(profiles.icons().is_empty()); + } + + /// 「自动识别」不落盘。存一个 `"auto"` 进去也能工作,但那样配置文件里 + /// 就分不出"用户挑了自动"和"用户根本没挑过"——而默认值将来可能变。 + #[test] + fn only_explicitly_picked_icons_reach_the_file() { + let mut profiles = SshProfiles::default(); + for (destination, icon) in + [("root@10.0.0.1", Some("debian".to_owned())), ("root@10.0.0.2", None)] + { + profiles.upsert(SshProfileAuth { + destination: destination.to_owned(), + auth: SshAuthMode::Auto, + private_keys: Vec::new(), + label: None, + icon, + }); + } + let json = serde_json::to_string(&profiles).expect("serialize"); + assert!(json.contains(r#""icon":"debian""#)); + assert_eq!(json.matches("\"icon\"").count(), 1, "没挑过的那台不该写出空字段"); + + let icons = profiles.icons(); + assert_eq!(icons.get("root@10.0.0.1").map(String::as_str), Some("debian")); + assert!(!icons.contains_key("root@10.0.0.2")); + } + + #[test] + fn default_labels_continue_past_the_highest_number_not_the_count() { + // 关键是"删掉中间几台之后":按条目总数发号会直接撞上还在列表里的 + // 「主机 3」,用户看到两个同名条目。 + let mut profiles = SshProfiles::default(); + for n in 1..=3 { + let label = profiles.next_default_label("主机"); + assert_eq!(label, format!("主机 {n}")); + profiles.upsert(SshProfileAuth { + destination: format!("root@10.0.0.{n}"), + auth: SshAuthMode::Auto, + private_keys: Vec::new(), + label: Some(label), + icon: None, + }); + } + profiles.remove("root@10.0.0.1"); + profiles.remove("root@10.0.0.2"); + + assert_eq!(profiles.next_default_label("主机"), "主机 4"); + } + + #[test] + fn user_named_hosts_do_not_feed_the_default_counter() { + // 自己起的名字不参与计数,否则「生产数据库 7」这种名字会把编号顶高。 + let mut profiles = SshProfiles::default(); + profiles.upsert(SshProfileAuth { + destination: "root@example.com".to_owned(), + auth: SshAuthMode::Auto, + private_keys: Vec::new(), + label: Some("生产数据库".to_owned()), + icon: None, + }); + + assert_eq!(profiles.next_default_label("主机"), "主机 1"); + } + + #[test] + fn profile_round_trip_preserves_mode_and_key_order() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("ssh_profiles.json"); + let mut profiles = SshProfiles::default(); + profiles.upsert(SshProfileAuth { + destination: "dev@example.com".to_owned(), + auth: SshAuthMode::PublicKey, + private_keys: vec![PathBuf::from(r"C:\Keys\first"), PathBuf::from(r"C:\Keys\second")], + label: None, + icon: None, + }); + profiles.save(&path).unwrap(); + + let loaded = SshProfiles::load(&path).unwrap(); + let profile = loaded.for_destination("dev@example.com"); + assert_eq!(profile.auth, SshAuthMode::PublicKey); + assert_eq!( + profile.private_keys, + vec![PathBuf::from(r"C:\Keys\first"), PathBuf::from(r"C:\Keys\second")] + ); + } + + #[test] + fn duplicate_windows_key_paths_are_removed_without_reordering() { + let mut profiles = SshProfiles::default(); + profiles.upsert(SshProfileAuth { + destination: "dev@example.com".to_owned(), + auth: SshAuthMode::Auto, + private_keys: vec![ + PathBuf::from(r"C:\Keys\id_ed25519"), + PathBuf::from(r"c:\keys\ID_ED25519"), + PathBuf::from(r"C:\Keys\id_rsa"), + ], + label: None, + icon: None, + }); + + assert_eq!( + profiles.for_destination("dev@example.com").private_keys, + vec![PathBuf::from(r"C:\Keys\id_ed25519"), PathBuf::from(r"C:\Keys\id_rsa")] + ); + } + + #[test] + fn renaming_profile_moves_auth_metadata() { + let mut profiles = SshProfiles::default(); + profiles.upsert(SshProfileAuth { + destination: "old@example.com".to_owned(), + auth: SshAuthMode::PublicKey, + private_keys: vec![PathBuf::from(r"C:\Keys\id_ed25519")], + label: None, + icon: None, + }); + + profiles.rename("old@example.com", "new@example.com"); + + assert_eq!(profiles.for_destination("old@example.com").auth, SshAuthMode::Auto); + assert_eq!(profiles.for_destination("new@example.com").auth, SshAuthMode::PublicKey); + } + + #[test] + fn legacy_agent_mode_migrates_to_auto() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("ssh_profiles.json"); + std::fs::write( + &path, + r#"{"version":1,"profiles":[{"destination":"dev@example.com","auth":"agent","private_keys":[]}]}"#, + ) + .unwrap(); + + let loaded = SshProfiles::load(&path).unwrap(); + assert_eq!(loaded.for_destination("dev@example.com").auth, SshAuthMode::Auto); + } + + #[test] + fn unknown_auth_mode_falls_back_to_auto() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("ssh_profiles.json"); + std::fs::write( + &path, + r#"{"version":1,"profiles":[{"destination":"dev@example.com","auth":"future-mode","private_keys":[]}]}"#, + ) + .unwrap(); + + let loaded = SshProfiles::load(&path).unwrap(); + assert_eq!(loaded.for_destination("dev@example.com").auth, SshAuthMode::Auto); + } +} diff --git a/nebula_app/src/ssh_session.rs b/nebula_app/src/ssh_session.rs index 052d84b..44cba31 100644 --- a/nebula_app/src/ssh_session.rs +++ b/nebula_app/src/ssh_session.rs @@ -693,7 +693,9 @@ pub fn spawn_test( move || SshDestination::resolve(&raw) }) .await - .map_err(|err| -> SessionError { format!("SSH 地址解析任务失败: {err}").into() })??; + .map_err(|err| -> SessionError { + format!("SSH 地址解析任务失败: {err}").into() + })??; test_connect(&resolved, &request).await }) .await; @@ -746,7 +748,8 @@ async fn test_authenticate( if session.authenticate_none(&destination.user).await?.success() { return Ok(()); } - let has_draft_password = request.password.as_deref().is_some_and(|password| !password.is_empty()); + let has_draft_password = + request.password.as_deref().is_some_and(|password| !password.is_empty()); if let Some(password) = request.password.as_deref().filter(|p| !p.is_empty()) { if authenticate_password(session, &destination.user, password.as_bytes()).await? { return Ok(()); diff --git a/nebula_app/src/sync.rs b/nebula_app/src/sync.rs index 3de484d..c95b9b5 100644 --- a/nebula_app/src/sync.rs +++ b/nebula_app/src/sync.rs @@ -45,6 +45,11 @@ const SYNC_KEYS: &[&str] = &[ "fetch", "powerline", "keep_session", + "restore_session", + "panel_resize", + "sidebar_w", + "drawer_w", + "hosts_band", "opacity", "background", "background_image_opacity", @@ -63,12 +68,52 @@ const LIST_KEYS: &[&str] = &["pinned_hosts", "saved_hosts", "hidden_hosts"]; /// 客户端弱口令黑名单(硬拒绝,用户裁定 2026-07-28)。E2E 的强度短板 /// 在口令;123456 这类口令让加密形同虚设,宁可拒绝同步也不给虚假安全感。 const WEAK_PASSPHRASES: &[&str] = &[ - "123456", "1234567", "12345678", "123456789", "1234567890", "password", "password1", - "qwerty", "qwertyuiop", "abc123", "111111", "000000", "654321", "666666", "888888", - "letmein", "iloveyou", "admin", "admin123", "root", "passw0rd", "p@ssw0rd", "dragon", - "monkey", "sunshine", "princess", "welcome", "shadow", "master", "qazwsx", "asdfgh", - "zxcvbn", "asdfghjkl", "1q2w3e4r", "1qaz2wsx", "qwe123", "a123456", "123123", "121212", - "112233", "159357", "147258", "789456", "woaini", "5201314", "nebula", + "123456", + "1234567", + "12345678", + "123456789", + "1234567890", + "password", + "password1", + "qwerty", + "qwertyuiop", + "abc123", + "111111", + "000000", + "654321", + "666666", + "888888", + "letmein", + "iloveyou", + "admin", + "admin123", + "root", + "passw0rd", + "p@ssw0rd", + "dragon", + "monkey", + "sunshine", + "princess", + "welcome", + "shadow", + "master", + "qazwsx", + "asdfgh", + "zxcvbn", + "asdfghjkl", + "1q2w3e4r", + "1qaz2wsx", + "qwe123", + "a123456", + "123123", + "121212", + "112233", + "159357", + "147258", + "789456", + "woaini", + "5201314", + "nebula", ]; /// Windows 凭据管理器里的条目名(`ssh_credentials` 的通用 DPAPI 存取)。 @@ -198,7 +243,8 @@ fn sync_passphrase() -> Option { } } #[cfg(windows)] - if let Ok(Some(secret)) = crate::ssh_credentials::windows_store::load_secret(PASSPHRASE_TARGET) { + if let Ok(Some(secret)) = crate::ssh_credentials::windows_store::load_secret(PASSPHRASE_TARGET) + { return String::from_utf8(secret).ok(); } None @@ -365,9 +411,7 @@ fn payload_from_json(bytes: &[u8]) -> Result { } let history = value["history"] .as_array() - .map(|list| { - list.iter().filter_map(|v| v.as_str()).map(str::to_owned).collect::>() - }) + .map(|list| list.iter().filter_map(|v| v.as_str()).map(str::to_owned).collect::>()) .unwrap_or_default(); Ok(SyncPayload { modified, device, settings, keybinds, history }) } @@ -522,10 +566,8 @@ fn fetch_remote(cfg: &SyncConfig, password: &str, passphrase: &str) -> Result Result { } let packet = seal(&payload_to_json(&local), &passphrase)?; if put_remote(&cfg, &password, &packet, fresh.etag.as_deref())? { - Ok(SyncOutcome { message: "同步已推送(已并入远端并发修改)".to_owned(), history_changed }) + Ok(SyncOutcome { + message: "同步已推送(已并入远端并发修改)".to_owned(), history_changed + }) } else { Err("远端持续变化,稍后再试".to_owned()) } @@ -657,7 +701,9 @@ pub fn pull() -> Result { let settings_before = apply_to_settings_text(&merged, &text) != text; let history_changed = apply_local(&merged, &text)?; if !settings_before && !history_changed { - return Ok(SyncOutcome { message: "已是最新(与远端一致)".to_owned(), history_changed }); + return Ok(SyncOutcome { + message: "已是最新(与远端一致)".to_owned(), history_changed + }); } Ok(SyncOutcome { message: format!( diff --git a/nebula_app/src/update_check.rs b/nebula_app/src/update_check.rs index 031a977..fa01c98 100644 --- a/nebula_app/src/update_check.rs +++ b/nebula_app/src/update_check.rs @@ -80,8 +80,7 @@ fn is_newer(latest: &str, current: &str) -> bool { version .split('.') .map(|segment| { - let digits: String = - segment.chars().take_while(char::is_ascii_digit).collect(); + let digits: String = segment.chars().take_while(char::is_ascii_digit).collect(); digits.parse().unwrap_or(0) }) .collect() diff --git a/nebula_app/src/window_context.rs b/nebula_app/src/window_context.rs index 4c172aa..0d6be6a 100644 --- a/nebula_app/src/window_context.rs +++ b/nebula_app/src/window_context.rs @@ -26,7 +26,7 @@ use winit::window::WindowId; use nebula_terminal::event::{Event as TerminalEvent, Notify}; use nebula_terminal::event_loop::{EventLoop as PtyEventLoop, Msg, Notifier}; use nebula_terminal::grid::{Dimensions, Scroll}; -use nebula_terminal::index::Direction; +use nebula_terminal::index::{Column, Direction, Line, Point}; use nebula_terminal::sync::FairMutex; use nebula_terminal::term::test::TermSize; use nebula_terminal::term::{Term, TermMode}; @@ -237,13 +237,7 @@ impl WindowContext { renderer::platform::create_gl_context(&gl_display, &gl_config, raw_window_handle)?; crate::boot_trace("gl context created"); - let display = Display::new( - window, - gl_context, - &config, - event_loop.system_theme(), - false, - )?; + let display = Display::new(window, gl_context, &config, event_loop.system_theme(), false)?; crate::boot_trace("display ready (fonts rasterized)"); Self::new(display, config, options, proxy, boot) @@ -285,13 +279,7 @@ impl WindowContext { let gl_context = renderer::platform::create_gl_context(&gl_display, gl_config, Some(raw_window_handle))?; - let display = Display::new( - window, - gl_context, - &config, - event_loop.system_theme(), - tabbed, - )?; + let display = Display::new(window, gl_context, &config, event_loop.system_theme(), tabbed)?; let mut window_context = Self::new(display, config, options, proxy, boot)?; @@ -323,8 +311,7 @@ impl WindowContext { // Startup no longer replays the saved window size (the session file's // window record is write-only), so the live inner size IS the last // normal size at this point. - let windowed_size = - display.window.inner_size().to_logical(display.window.scale_factor); + let windowed_size = display.window.inner_size().to_logical(display.window.scale_factor); // Bootstrap the tab set: fresh/restored windows spawn their first // pane here; an attach adopts the detached panes wholesale. @@ -1207,9 +1194,13 @@ impl WindowContext { fn export_workspace(&mut self, tab_index: Option) { let exportable = |tab: &&TabEntry| tab.doc.is_none() && !tab.settings; let tabs: Vec<_> = match tab_index { - Some(index) => { - self.tabs.get(index).filter(exportable).map(|tab| self.tab_session(tab)).into_iter().collect() - }, + Some(index) => self + .tabs + .get(index) + .filter(exportable) + .map(|tab| self.tab_session(tab)) + .into_iter() + .collect(), None => self.tabs.iter().filter(exportable).map(|tab| self.tab_session(tab)).collect(), }; let Some(first) = tabs.first() else { return }; @@ -1229,7 +1220,13 @@ impl WindowContext { }; let stem: String = stem .chars() - .map(|c| if matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') { '-' } else { c }) + .map(|c| { + if matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') { + '-' + } else { + c + } + }) .collect(); let Some(path) = @@ -1437,8 +1434,7 @@ impl WindowContext { if let Some(id) = seed.take() { return Some(Layout::Leaf(id)); } - let cwd = - session::valid_dir(cwd).or_else(|| self.display.startup_directory()); + let cwd = session::valid_dir(cwd).or_else(|| self.display.startup_directory()); self.spawn_pane_detached(cwd, self.display.size_info).map(Layout::Leaf) }, session::LayoutSession::Split { axis, ratio_permille, first, second } => { @@ -1476,7 +1472,7 @@ impl WindowContext { /// session snapshot is written here and the Drop one is suppressed — /// after the take below, Drop would see zero tabs and wipe the file. pub fn detach_panes(&mut self) -> DetachedWindow { - session::save(&self.session_snapshot()); + session::save_final(&mut self.session_snapshot()); self.session_exempt = true; DetachedWindow { panes: mem::take(&mut self.panes), @@ -1926,10 +1922,31 @@ impl WindowContext { state.command_started.get_or_insert_with(std::time::Instant::now); }, crate::ai_hook::AiHookKind::TurnDone | crate::ai_hook::AiHookKind::NeedsAttention => { + // codex 的 notify 只有"回合完成"一种事件:弹出交互式提问时 + // 它发的也是 turn-complete,事件流分不出"说完了"和"在等你 + // 回答"。回合结束的瞬间看一眼屏幕尾部——还挂着选择框或确认 + // 提示,就按「等你批准」处理(蓝点升级成手掌)。 + let screen_asks = ev.kind == crate::ai_hook::AiHookKind::TurnDone && { + let term = self.panes[idx].terminal.lock(); + let lines = term.screen_lines(); + let take = lines.min(15); + let start = Point::new(Line((lines - take) as i32), Column(0)); + let end = Point::new( + Line(lines as i32 - 1), + Column(term.columns().saturating_sub(1)), + ); + crate::ai_hook::tail_looks_like_question(&term.bounds_to_string(start, end)) + }; { let state = &mut self.panes[idx].nebula_state; state.awaiting_input = true; state.finished_unseen = true; + // 「等你批准」是比「回合完成」更强的状态:它不是通知你 + // 结果,是挡在半路要你点头。徽章上分成手掌与圆点两种 + // 墨迹,此前两者共用一个点,界面上根本分不出来。 + if ev.kind == crate::ai_hook::AiHookKind::NeedsAttention || screen_asks { + state.needs_attention = true; + } } // Tab dot when the pane sits in a background tab (same rule // as mark_pane_bell; the visible tab shows the pane itself). @@ -1982,9 +1999,8 @@ impl WindowContext { let Some(idx) = self.pane_index(pane_id) else { return false }; let state = &mut self.panes[idx].nebula_state; if state.ai_fix.as_ref().is_some_and(|current| current.seq() == seq) { - state.ai_fix = fix - .clone() - .map(|fix| crate::ai_assistant::AiFixState::Ready { seq, fix }); + state.ai_fix = + fix.clone().map(|fix| crate::ai_assistant::AiFixState::Ready { seq, fix }); self.dirty = true; self.display.window.request_redraw(); } @@ -2241,6 +2257,10 @@ impl WindowContext { self.pane(self.focused_pane_id()).and_then(|pane| pane.ssh_destination.clone()); self.display.route_side_panel(focused_ssh.as_deref()); let panel_cwd = self.focused_cwd().or_else(|| self.focused_wsl_cwd()); + // 命令面板的「工作目录」组也认这个值(WSL 路径已映射成 `\\wsl$\…`, + // 复制出去和丢给资源管理器都能用)。抽屉是节流的,这里不能顺手复用 + // 它的内部状态——面板要的是**当前**目录,不是抽屉上次同步到的那个。 + self.display.nebula_focused_cwd = panel_cwd.clone(); self.display.side_panel_sync(panel_cwd); // Chrome clock: 1 Hz idle, 8 fps for finite UI transitions, and @@ -2248,7 +2268,9 @@ impl WindowContext { // the cadence class changes. let clock_timer = TimerId::new(Topic::NebulaClock, self.display.window.id()); let interval = chrome_clock_interval( - self.display.any_tab_running() || self.display.ssh_test_running(), + self.display.any_tab_running() + || self.display.ssh_test_running() + || self.display.any_tab_flashing(), self.display.chrome_editor_active(), self.display.chrome_animating(), ); @@ -2439,6 +2461,8 @@ impl WindowContext { if let Some(id) = self.tabs.get(self.active_tab).map(|t| t.active_pane) { if let Some(i) = self.pane_index(id) { self.panes[i].nebula_state.finished_unseen = false; + self.panes[i].nebula_state.needs_attention = false; + self.panes[i].nebula_state.failed_unseen = false; } } @@ -2446,7 +2470,13 @@ impl WindowContext { let mut colors = Vec::with_capacity(self.tabs.len()); let mut dots = Vec::with_capacity(self.tabs.len()); let mut running = Vec::with_capacity(self.tabs.len()); + let mut attention = Vec::with_capacity(self.tabs.len()); + let mut failed = Vec::with_capacity(self.tabs.len()); + let mut flashing = Vec::with_capacity(self.tabs.len()); let mut logos = Vec::with_capacity(self.tabs.len()); + let mut shells = Vec::with_capacity(self.tabs.len()); + // 静默行右侧的 shell 短标;Default 启动的 tab 用当前默认 shell 的。 + let default_tag = self.display.default_shell_tag(); let ui_language = self.display.ui_language(); for tab in &self.tabs { let pane = self.pane(tab.active_pane); @@ -2473,6 +2503,15 @@ impl WindowContext { logos.push(logo); labels.push(label); colors.push(tab.custom_color); + shells.push(match &tab.launch { + TabLaunch::Default => default_tag.clone(), + TabLaunch::Shell { name, .. } => crate::shell_detect::shell_short_tag(name), + // SSH 行的身份是目标主机(标签本身就写着),短标只说环境。 + TabLaunch::Ssh(_) => "ssh".to_owned(), + TabLaunch::Profile(_) | TabLaunch::Document(_) | TabLaunch::Settings => { + String::new() + }, + }); // Unseen-result dot: bell in a background tab, a tracked command // that finished unseen, or a tracked program parked at "waiting // for input" (claude between turns). The ring collapsing into a @@ -2487,10 +2526,18 @@ impl WindowContext { // Spinner only while the command actually works; once it rang BEL // and waits for input the dot above takes over. running.push(state.is_some_and(|s| s.command_started.is_some() && !s.awaiting_input)); + attention.push(state.is_some_and(|s| s.needs_attention)); + failed.push(state.is_some_and(|s| s.failed_unseen)); + // 对勾只在成功收尾后的一小段里亮着,随后落回圆点。 + flashing.push(state.is_some_and(|s| { + s.finished_at.is_some_and(|at| at.elapsed() < crate::display::BADGE_FLASH) + })); } let active = self.active_tab.min(labels.len().saturating_sub(1)); // displayed == storage index always holds now, so the bar is reorderable. - self.display.set_chrome_tabs(labels, colors, dots, running, logos, active, true); + self.display.set_chrome_tabs( + labels, colors, dots, running, attention, failed, flashing, logos, shells, active, true, + ); } fn chrome_tab_label(pane: &Pane) -> String { @@ -2580,6 +2627,8 @@ impl WindowContext { let focused = self.focused_pane_id(); if let Some(i) = self.pane_index(focused) { self.panes[i].nebula_state.awaiting_input = false; + // 打字即表态:人已经在这个 pane 上动手了,徽章再催就是噪声。 + self.panes[i].nebula_state.needs_attention = false; } } @@ -2918,9 +2967,11 @@ impl Drop for WindowContext { // empty list is exactly what makes the next launch start clean. // Closing the whole window (X / Alt+F4 / shortcut) keeps the tabs, so // they restore. Crash/kill paths never get here and are covered by - // the 1 Hz autosave instead. + // the 1 Hz autosave instead — which is also why this one (and only + // this one) stamps `clean_exit`: reaching Drop IS the definition of + // a clean exit. if !self.session_exempt { - session::save(&self.session_snapshot()); + session::save_final(&mut self.session_snapshot()); } // Shutdown every pane's PTY. diff --git a/nebula_config/Cargo.toml b/nebula_config/Cargo.toml index ad4954f..8e978ab 100644 --- a/nebula_config/Cargo.toml +++ b/nebula_config/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nebula_config" -version = "0.8.0" +version = "0.9.0" license = "GPL-3.0-or-later" description = "Nebula configuration abstractions" homepage = "https://github.com/Kuddev/nebula" @@ -14,5 +14,5 @@ serde = "1.0.163" toml.workspace = true [dev-dependencies] -nebula_config_derive = { version = "0.8.0", path = "../nebula_config_derive" } +nebula_config_derive = { version = "0.9.0", path = "../nebula_config_derive" } serde = { version = "1.0.163", features = ["derive"] } diff --git a/nebula_config_derive/Cargo.toml b/nebula_config_derive/Cargo.toml index 8e403e0..3b6176b 100644 --- a/nebula_config_derive/Cargo.toml +++ b/nebula_config_derive/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nebula_config_derive" -version = "0.8.0" +version = "0.9.0" license = "GPL-3.0-or-later" description = "Failure resistant deserialization derive" homepage = "https://github.com/Kuddev/nebula" @@ -18,7 +18,7 @@ syn = { version = "2.0.16", features = ["derive", "parsing", "proc-macro", "prin [dev-dependencies.nebula_config] path = "../nebula_config" -version = "0.8.0" +version = "0.9.0" [dev-dependencies] log = "0.4.11" diff --git a/nebula_terminal/Cargo.toml b/nebula_terminal/Cargo.toml index 9f12951..e685120 100644 --- a/nebula_terminal/Cargo.toml +++ b/nebula_terminal/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nebula_terminal" -version = "0.8.0" +version = "0.9.0" license = "GPL-3.0-or-later" description = "Nebula terminal emulation core" readme = "../README.md"