checkpoint: complete SSH and input UX contracts

This commit is contained in:
kuddev committed 2026-07-14 23:47:51 +08:00
1 parent 784817795e
commit 5513afd573
12 files changed
+2304 -205

No files matched your search

+116
View File
@@ -0,0 +1,116 @@
# 当前任务(2026-07-14)
- [ ] 使用真实 SSH 服务器验证直连认证顺序、首次连接确认、输入输出、Resize 和实际连接耗时。
- [ ] 验证远端 `nebula-hook` 的部署/发现方式,确认 Claude、Codex、OpenCode 在 SSH Pane 内能触发本地通知。
- [x] 为 SSH 条目与 Tab 实现统一的原生右键菜单组件和短促开合动画;外部点击、Esc、普通按键和窗口失焦可自然释放轻量菜单。
- [x] SSH 菜单:连接、复制地址、编辑、删除;不再提供右键置顶。删除已补来源说明、二次确认、8 秒 Undo / Ctrl+Z、凭据延迟清除及设置页长期恢复入口。
- [x] Tab 菜单:复制标签页、左右分屏、上下分屏、重命名、关闭和颜色选择。
- [x] Tab 左侧窄光条只在用户明确设置自定义颜色后显示;默认不显示侧边条(按用户 2026-07-14 最新要求覆盖旧设计)。
- [x] 保存并恢复 Tab 自定义颜色及必要的会话元数据。
- [x] 自绘输入框统一支持 Ctrl+A / Ctrl+C / Ctrl+V;全选后输入/粘贴替换,选择有可见背景;隐藏密码仅允许粘贴,显示后才允许复制。
- [x] 侧栏按 `D:/下载/gemini-code-1784017517204.html` 收敛:TABS / SSH HOSTS 使用真实小字号层级,两个加号仅标题 hover 时显现,Tabs 下拉改为竖向三点。
- [x] `cargo check -p nebula` 与 `cargo test -p nebula` 通过(173 passed,0 failed;2026-07-14)。
- [ ] 完成 Debug 运行检查和真实窗口交互验收(hover-only 控件、菜单脱焦、删除/撤销/恢复、输入框快捷键和 DPI 字体观感)。
- [ ] 验收完成后重打 0.3.0 Release,并替换现有发布压缩包。
---
# Nebula 任务交接(2026-07-12 17:10 写于会话结束前)
> 交接给新对话。持久记忆里有同步副本(`nebula-task-handoff` 记忆),本文件更详尽。
> 已交付包:`target/release/nebula.exe`(16:59+,含下述两个已完成修复 + 诊断日志)。
> 不要为了检查代码反复关闭用户正在查看的 Nebula。只有在用户同意进行 Release 重打、且运行实例确实占用目标 exe 时,才单独说明后关闭对应实例。
## ✅ 本轮已完成(已在包里)
### 1. 标题/缩放文字"拥挤粘连+虚胖"— 已修
- 根因:crossfont::Size 单位是 **pt**,`Size::new(base_size.as_px() * scale)` 把 px 当 pt,字号多乘 96/72=1.333 → 字形比步进宽 33% → 标题粘成墨块。
- 修复:`glyph_cache.font_size = base_size.scale(scale)`(renderer/mod.rs `draw_doc_text` 内,~467 行)。
- 用户验证方法:md 标题/设置面板标题应字距正常且整体小一号(之前虚胖);侧栏 SSH 提示真正变小(0.85× 之前实际渲染 1.13×)。
### 2. 鼠标选择"只能选整行/拖拽粒度错"— 已修(代码完成,待用户手感验证)
- 根因:`on_mouse_press` 中 click-state 被**双重推进**——chrome 块(原 ~1238)推一次更新时间戳,`ChromeHit::None` 落下后终端块(原 ~1401)再推一次,elapsed≈0 → 每次终端单击必然升级 DoubleClick/TripleClick → 词/行粒度选择与拖拽。
- 修复(input/mod.rs):
- 新增 `advance_click_state()`:press 入口调用**恰好一次**(WT `_numberOfClicks` 模型)。
- 补 WT 位置约束:两次点击须落在系统双击矩形内(`GetSystemMetrics(SM_CXDOUBLECLK)/2`),时间用系统 `GetDoubleClickTime()`(Cargo.toml 已加 windows-sys feature `Win32_UI_Input_KeyboardAndMouse`)。
- `Mouse` 新增 `last_click_pos` 字段(event.rs)。
- Shift+点击 = 扩展现有选区(WT 行为),不再清空。
- 测试 `input_delay` 引用改为 `multi_click_time()`;7 个 click 测试失败为**遗留**(mock display() unimplemented,input/mod.rs:2286 panic),非回归。
## 🧊 已沉淀暂缓:#6 抽屉/目录树 → TUI 历史刷新风暴(用户 07-12 指示"别死磕,先做别的")
已埋诊断日志(`nebula_link_log` → `%APPDATA%\Nebula\nebula_debug.log`):
- display/mod.rs handle_update HUD 处:`grid_resize <cols>x<lines> px=.. pad_x/pad_r/pad_y cell=.. drawer=.. sidebar=.. reserved=..`
- window_context/split.rs `apply_view_pty`:`pty_resize cols=.. lines=.. view_px=..`
复现脚本:`scripts/drawer_repro.ps1`(启动+自动点抽屉按钮各一次,间隔 2s;FindWindowW 按 title "nebula" 找不到窗口那次是 race,重跑即可)。
**⚠️ 上轮误判已推翻(07-12 二次侦察):**
> 上轮写"异常 B:抽屉状态自发振荡、无人点击也振荡"是**错的**。真相:日志里每对 84↔120 的 grid_resize 前后都紧跟一条 `link_release`——那是 `drawer_repro.ps1` 脚本的模拟点击,不是自发的。**根本没有自发振荡**,别再往"每帧派发 toggle / session 恢复反复 toggle"方向查了。
**真正的实锤(重新读日志得出):单击一次 = 开+关两次 toggle。**
```
[..419.485] grid_resize 84x20 ... drawer=462 ← 开
[..419.485] pty_resize cols=84
[..419.616] link_release ← 脚本第 1 次点击的 release
[..419.812] grid_resize 120x20 ... drawer=0 ← 立刻又关(约 300ms 内)
[..419.813] pty_resize cols=120
```
每次点击都产生"开(drawer=462)→约300ms→关(drawer=0)"成对 resize,即**一次物理点击触发了两次 toggle_side_panel**。press 和 release 各调一次是头号嫌疑:
- `on_mouse_press` 顶栏按钮路径 input/mod.rs:1368-1381(`ChromeHit::PanelFiles/PanelGit → toggle_side_panel`)
- `on_mouse_release` 里是否有对称的第二次分发(tab-drag `end_tab_drag`→Click、或 chrome 命中在 release 再跑一遍)→ **下一步只需读 on_mouse_release 全程 + arm/end_tab_drag,确认 press 已 return 后 release 不该再命中同一按钮**。
- 另一嫌疑:`ChromeHit::PanelFiles` 命中矩形与抽屉打开后某元素重叠,开抽屉后同一坐标又命中"关"。
**异常 A(独立问题,仍未解):`cell=13x40` — 行高 40px 偏大**(宽 13 正常约配 26 高)。
- 计算链已定位:display/mod.rs:4936 `compute_cell_size` → `(metrics.line_height + offset_y).floor()`;metrics 来自 glyph_cache.rs:112 `rasterizer.metrics(key, font.size())`,font.size() 已 `.scale(scale_factor)`(display/mod.rs:1444)。
- 存疑:cell_width=13 说明 average_advance 未被 DPI 双乘,但 line_height=40 偏大 → line_height 单独异常。**下一步**:加日志打印 `metrics.average_advance / metrics.line_height / offset` 原始值,看是 crossfont 返回的 line_height 本身比例失调,还是 config.font.offset.y 被设过大。注意用户设置 `powerline=1`,排查是否 powerline/字体 fallback 拉高行盒。
- 用户运行时设置(`%APPDATA%\Nebula\nebula_settings.txt`):theme=SilverLight, shell=powershell, powerline=1, keep_session=0, background=#ffffff。
**修复原则(留给复工时):** press 已处理的点击,release 必须 early-return,不得二次 toggle;toggle 目标状态幂等(相同 open 值不 set_dimensions)。PTY resize settle 机制已就绪(window_context.rs:1931 leading-edge 300ms + 150ms trailing)——源头去重后风暴应自愈。
## 📋 待办(按用户优先级:先 bug 后新功能)
### #7 SSH 弹外部 conhost 黑窗(bug,未开工)
- 现象:点 SSH host,`C:\Windows\System32\OpenSSH\ssh.exe` 独立黑窗弹出,密码在外部输入。
- 根因判断:`spawn_tab_ssh`(window_context.rs:816)经 `nebula.exe ssh <host>`(main.rs:108 → `ssh::run(options.args)`,ssh.rs)。nebula.exe 是 GUI 子系统进程、无控制台可继承 → 子进程 ssh.exe 被分配新可见 conhost。
- 下一步:读 `ssh::run`;候选修法:包装进程入口 AttachConsole/AllocConsole(隐藏窗口);或 PTY 直接跑 ssh(需另路注入远端集成);或 CreateProcess 时正确传递 pseudoconsole。
### #2-#5 执行器下拉 + WT 同款默认 Shell 设置(✅ 07-12 完成,含 #5 默认 shell picker)
- 执行器下拉已交付:`shell_detect.rs`(Tabby 探测逻辑移植,缓存于 `Display::nebula_detected_shells`)、chevron 按钮(`ChromeHit::NewTabMenu`)、palette profiles-only 菜单、`TabRequest::NewShell`。
- #5 默认 Shell(WT parity)已交付,全链路:
- 存储:`shell=<id>` 支持富 id(cmd/pwsh/nu/wsl:X)。`NebulaRuntimeSettings.shell_id: Option<String>` 原样读写(settings.rs),2 值枚举 `NebulaShell` 继续跟踪 PTY 集成执行器族(powershell/bash 的提示符注入基座)。
- 生效:`spawn_tab` → `default_shell_override()`(window_context.rs:800)经 `shell_detect::resolve_id` 解析;powershell/bash 族返回 None 走 PTY 自有 bootstrap。
- UI:设置页"默认 Shell"行点击 → `open_default_shell_picker()`(palette 置 `picking_default`,confirm 返回 `SetDefaultShell` 而非 launch)→ `set_default_shell()` 持久化。行值显示 icon+名称(`display_name_for_id`,无 IO,每帧安全)。旧 `cycle_shell` 2 值轮换已删除。
- palette 行现携带 `(icon, label, hint)` 三元组,shell 行画 Nerd Font 图标(icon_for_id)。
- 顺手修复:keyboard.rs 重复 `LaunchShell` arm、input/mod.rs 重复 `NewTabMenu` arm(均为前次会话残留的 unreachable pattern)。
- 验证:cargo build 0 error;测试 137 passed / 8 failed(8 个失败与干净基线完全一致 = 遗留 mock 问题,palette 测试 9/9 过)。
### #2-#5 原侦察记录(已实现,留档)
- 需求:tabs 加号旁下箭头 → 菜单列执行器(PowerShell/pwsh/CMD/Git Bash/WSL 发行版)→ 点击用该 shell 开新 tab。参考 Tabby(源码 D:\temp_build\tabby)。**不造轮子**。
- 现成管线:`Profile{name,command,args,cwd}`(config/ui_config.rs:684)、`TabRequest::NewProfile(usize)`→`spawn_tab_profile`(window_context.rs:787)→`spawn_pane_detached_with(shell override)`(1078);右键加号已弹菜单=命令面板 profiles-only(`open_profile_menu` display/mod.rs:2162;palette `LaunchProfile(i)`→keyboard.rs:539)。
- 检测逻辑(照搬 Tabby tabby-electron/src/shells/*.ts,已读):
- PowerShell:知名路径顺序探测 `%LOCALAPPDATA%\Microsoft\WindowsApps\pwsh.exe` → `%ProgramFiles%\PowerShell\7\pwsh.exe` → `%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe`,args `-nologo`
- pwsh:注册表 HKLM `SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\pwsh.exe` 默认值
- CMD:`cmd.exe`
- Git Bash:HKLM→HKCU `Software\GitForWindows` `InstallPath` → `{path}\bin\bash.exe --login -i`
- WSL:HKCU `Software\Microsoft\Windows\CurrentVersion\Lxss` 枚举子键读 `DistributionName` → `%windir%\system32\wsl.exe -d <name>`;无 Lxss 但 system32\bash.exe 存在 → "WSL/Bash on Windows"
- 注册表:windows-sys feature `Win32_System_Registry` 已在依赖(或加 winreg,Cargo.lock 已有)
- UI:chrome.rs `ChromeTabLayout` 加 chevron rect(plus 是 TABS header 右端 s(20) 方块,chrome.rs:299;chevron 放其左),`ChromeHit::NewTabMenu`,hit chrome.rs:429 旁,hover chrome.rs:860 旁,图标 chrome.rs:1010(`draw_centered_icon` + codicon chevron-down)。
- 路由:菜单名单 = 检测项+config.profiles 合并(一个函数三处共用);新 `TabRequest::NewShell{name,shell}`,keyboard.rs:539 做 index→请求映射;Ctrl+Shift+数字(keyboard.rs:273)保持 config.profiles 语义。
### #9 设置改为内容区 tab(新功能,用户已细化)
- 设置作为 tabs 列表一项,点设置按钮 → 设置页在中间内容区打开(VS Code 风格,废除悬浮模态)。
- 设置 tab 激活时:右上角文件/git 抽屉按钮**隐藏**、抽屉功能禁用。
- 参考先例:OpenDoc 查看器 tab(TabEntry.doc 字段、markdown_view)是现成的"非终端 tab"。
### #10 快捷键扩充 + 一键跳转(新功能)
- 对齐 WT;核对 Alt+1..9 / Ctrl+1..9 切 tab 是否真实生效(设置页写着有);[[keyboard.bindings]] 自定义已有配置系统,扩默认表+文档化;"点击一下自动连接/启动"与执行器下拉、SSH hosts 一键行合并交付。
### #11 大文件拆分(重构,用户点名"顺手做")
- display/mod.rs ~4900 行、input/mod.rs ~2500、event.rs ~2900、window_context.rs ~2000。沿 chrome.rs/settings.rs/split.rs 子模块先例按职责拆。
- 顺带修 8 个遗留测试失败(input 点击测试的 mock display() unimplemented → 解耦 chrome 命中)。
## 环境与流程
- 效果验证:用户装包截图反馈;渲染问题用"量截图"法(列投影+自相关量步进/墨宽,见 nebula-render-crispness 记忆第 6 条)。
- 诊断日志常开:`%APPDATA%\Nebula\nebula_debug.log`(grid_resize/pty_resize/link_press 等)。修完 #6 后考虑移除或门控这两条 resize 日志。
- dist 打包需带 conpty.dll + OpenConsole.exe(target/release 已有,7/2 版本)。
- 上轮遗留:设置面板标题层级(真粗体+字号梯度已上车,看效果再调)。
+158 -86
View File
@@ -29,7 +29,6 @@ pub enum ChromeHit {
/// A row in the sidebar's "SSH HOSTS" section (index into the host list).
Host(usize),
AddSshHost,
HostDelete(usize),
/// The "TABS" section header — click toggles the accordion fold.
TabsSection,
/// The "SSH HOSTS" section header — click toggles the accordion fold.
@@ -101,7 +100,6 @@ pub(super) struct ChromeTabLayout {
/// SSH host rows, same indexing contract as `tabs`: entry `i` is host `i`,
/// scrolled-out rows carry a zero rect (skipped by drawing and hit-tests).
pub(super) hosts: Vec<(f32, f32, f32, f32)>,
pub(super) host_closes: Vec<(f32, f32, f32, f32)>,
pub(super) hosts_add: (f32, f32, f32, f32),
/// Accordion headers ("TABS" / "SSH HOSTS" caption bands). Zero when the
/// panel is folded; `hosts_header` is zero when there are no hosts at all.
@@ -262,7 +260,6 @@ pub(super) fn chrome_tab_layout(
toggle,
panel: (0.0, 0.0, 0.0, 0.0),
hosts: Vec::new(),
host_closes: Vec::new(),
hosts_add: (0.0, 0.0, 0.0, 0.0),
tabs_header: (0.0, 0.0, 0.0, 0.0),
hosts_header: (0.0, 0.0, 0.0, 0.0),
@@ -377,22 +374,13 @@ pub(super) fn chrome_tab_layout(
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);
let mut host_closes = Vec::with_capacity(host_count);
for i in 0..host_count {
if !model.hosts_open || i < hosts_scroll || i >= hosts_scroll + hosts_show {
hosts.push(zero);
host_closes.push(zero);
continue;
}
let y = hosts_top + (i - hosts_scroll) as f32 * pitch;
hosts.push((tab_x, y, tab_w, row_h));
let close_size = (row_h * 0.58).max(s(16.0));
host_closes.push((
tab_x + tab_w - close_size - s(10.0),
y + (row_h - close_size) * 0.5,
close_size,
close_size,
));
}
let hosts_band_h = if hosts_show > 0 { hosts_show as f32 * pitch - gap } else { 0.0 };
let hosts_band = (hosts_top, hosts_top + hosts_band_h);
@@ -419,7 +407,6 @@ pub(super) fn chrome_tab_layout(
toggle,
panel,
hosts,
host_closes,
hosts_add: (
hosts_header.0 + hosts_header.2 - s(42.0),
hosts_header.1 + (hosts_header.3 - s(20.0)) * 0.5,
@@ -437,6 +424,73 @@ pub(super) fn chrome_tab_layout(
}
}
/// Draw the new-tab mark from the same physical rectangle used for its hit and
/// hover area. Maple's Codicon `add` glyph has asymmetric outline bounds, so
/// centering it by terminal-cell metrics makes the ink look offset even though
/// Maple itself is a monospaced text font. A two-stroke primitive keeps this
/// one control optically and geometrically centered at every DPI.
fn push_centered_add_icon(
quads: &mut Vec<UiQuad>,
rect: (f32, f32, f32, f32),
scale: f32,
color: Rgb,
) {
let (x, y, width, height) = rect;
let side = width.min(height);
if side <= 0.0 {
return;
}
let center_x = x + width * 0.5;
let center_y = y + height * 0.5;
let stroke = (side * 0.105).clamp((scale * 1.25).max(1.0), scale * 2.0);
let arm = side * 0.285;
let ink = Rgba::new(color.r, color.g, color.b, 235);
quads.push(UiQuad::solid(
center_x - arm,
center_y - stroke * 0.5,
arm * 2.0,
stroke,
stroke * 0.5,
ink,
));
quads.push(UiQuad::solid(
center_x - stroke * 0.5,
center_y - arm,
stroke,
arm * 2.0,
stroke * 0.5,
ink,
));
}
/// Native three-dot menu mark. Its geometry comes from the same control rect
/// as hover/hit-testing, so Maple private-use glyph metrics cannot skew it.
fn push_centered_more_icon(
quads: &mut Vec<UiQuad>,
rect: (f32, f32, f32, f32),
scale: f32,
color: Rgb,
) {
let (x, y, width, height) = rect;
let diameter = (2.2 * scale).max(2.0);
let gap = (3.2 * scale).max(diameter + scale);
let center_x = x + width * 0.5;
let center_y = y + height * 0.5;
let ink = Rgba::new(color.r, color.g, color.b, 235);
for offset in [-gap, 0.0, gap] {
quads.push(UiQuad::solid(
center_x - diameter * 0.5,
center_y + offset - diameter * 0.5,
diameter,
diameter,
diameter * 0.5,
ink,
));
}
}
pub(super) fn chrome_hit_with_tabs(
size_info: &SizeInfo,
scale_factor: f32,
@@ -478,11 +532,6 @@ pub(super) fn chrome_hit_with_tabs(
return ChromeHit::Tab(index);
}
}
for (index, rect) in layout.host_closes.iter().copied().enumerate() {
if contains_rect(rect, x, y) {
return ChromeHit::HostDelete(index);
}
}
for (index, rect) in layout.hosts.iter().copied().enumerate() {
if contains_rect(rect, x, y) {
return ChromeHit::Host(index);
@@ -780,9 +829,9 @@ 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 (design language): a soft accent
// wash over the pill plus a hairline accent border — no
// accent bar, state is carried by brightness alone.
// 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),
@@ -822,6 +871,39 @@ pub(super) fn draw_chrome(d: &mut Display) {
if tab_hovered {
quads.push(UiQuad::solid(tab_draw_x, tab_draw_y, tab_w, tab_h, pill_r, HOVER_FILL));
}
// 侧边光条仅表示用户明确设置的标签色;默认 Tab 不额外占用视觉层级。
if let Some(strip_color) = d.nebula_tab_colors.get(index).copied().flatten() {
let strip_x = tab_draw_x + s(4.0);
let strip_y = tab_draw_y + s(7.0);
let strip_w = s(2.5).max(2.0);
let strip_h = (tab_h - s(14.0)).max(s(10.0));
let glow = s(12.0);
quads.push(UiQuad::glow(
strip_x + strip_w * 0.5 - glow * 0.5,
strip_y + strip_h * 0.5 - glow * 0.5,
glow,
glow,
Rgba::new(
strip_color.r,
strip_color.g,
strip_color.b,
if index == d.nebula_active_tab { 72 } else { 38 },
),
));
quads.push(UiQuad::solid(
strip_x,
strip_y,
strip_w,
strip_h,
strip_w * 0.5,
Rgba::new(
strip_color.r,
strip_color.g,
strip_color.b,
if index == d.nebula_active_tab { 245 } else { 176 },
),
));
}
let (close_x, _, close_w, close_h) = tab_layout.closes[index];
let close_y = tab_draw_y + (tab_h - close_h) / 2.0;
if close_hovered {
@@ -903,11 +985,37 @@ pub(super) fn draw_chrome(d: &mut Display) {
if d.nebula_chrome_hover == ChromeHit::NewTab {
quads.push(UiQuad::solid(plus_x, plus_y, plus_w, plus_h, pill_r, HOVER_FILL_STRONG));
}
let expanded_sidebar = d.left_sidebar_visible() && tab_layout.panel.2 > 0.0;
let tabs_plus_visible = !expanded_sidebar
|| matches!(
d.nebula_chrome_hover,
ChromeHit::TabsSection | ChromeHit::NewTab | ChromeHit::NewTabMenu
);
if !d.nebula_settings_open && tabs_plus_visible {
let plus_ink = if d.nebula_chrome_hover == ChromeHit::NewTab {
sk.icon_hover
} else {
sk.icon
};
push_centered_add_icon(&mut quads, tab_layout.plus, scale, plus_ink);
}
// The dropdown chevron beside "+": same hover-only lift.
let (menu_x, menu_y, menu_w, menu_h) = tab_layout.menu;
if d.nebula_chrome_hover == ChromeHit::NewTabMenu {
quads.push(UiQuad::solid(menu_x, menu_y, menu_w, menu_h, pill_r, HOVER_FILL_STRONG));
}
if !d.nebula_settings_open {
push_centered_more_icon(
&mut quads,
tab_layout.menu,
scale,
if d.nebula_chrome_hover == ChromeHit::NewTabMenu {
sk.icon_hover
} else {
sk.icon
},
);
}
// SSH HOSTS section (quad layer): hover fill per row + the per-section
// overlay scrollbar thumbs. Row icons/labels render in the text pass.
@@ -916,20 +1024,29 @@ pub(super) fn draw_chrome(d: &mut Display) {
if hw <= 0.0 {
continue;
}
if matches!(d.nebula_chrome_hover, ChromeHit::Host(i) | ChromeHit::HostDelete(i) if i == index)
{
if matches!(d.nebula_chrome_hover, ChromeHit::Host(i) if i == index) {
quads.push(UiQuad::solid(hx, hy, hw, hh, pill_r, HOVER_FILL));
}
if matches!(d.nebula_chrome_hover, ChromeHit::HostDelete(i) if i == index) {
if let Some((cx, cy, cw, ch)) = tab_layout.host_closes.get(index).copied() {
quads.push(UiQuad::solid(cx, cy, cw, ch, cw * 0.35, HOVER_FILL_STRONG));
}
}
}
if d.nebula_chrome_hover == ChromeHit::AddSshHost {
let (ax, ay, aw, ah) = tab_layout.hosts_add;
quads.push(UiQuad::solid(ax, ay, aw, ah, s(6.0), HOVER_FILL_STRONG));
}
if matches!(
d.nebula_chrome_hover,
ChromeHit::HostsSection | ChromeHit::AddSshHost
) {
push_centered_add_icon(
&mut quads,
tab_layout.hosts_add,
scale,
if d.nebula_chrome_hover == ChromeHit::AddSshHost {
sk.icon_hover
} else {
sk.icon
},
);
}
let thumb_c = sk.scrollbar_thumb;
for bar in [tab_layout.tabs_scrollbar, tab_layout.hosts_scrollbar].into_iter().flatten() {
let (bx, by, bw, bh) = bar;
@@ -997,6 +1114,7 @@ pub(super) fn draw_chrome(d: &mut Display) {
&d.nebula_theme,
&mut quads,
scale,
size.cell_width(),
);
}
@@ -1025,8 +1143,6 @@ pub(super) fn draw_chrome(d: &mut Display) {
let (TXT, TXT_ON_ACCENT, TXT_DIM, ICON, ICON_HOVER) =
(sk.ink, sk.ink_strong, sk.ink_dim, sk.icon, sk.icon_hover);
const ICON_SETTINGS: &str = "\u{eb51}";
const ICON_ADD: &str = "\u{ea60}";
const ICON_CHEVRON_DOWN: &str = "\u{eab4}";
const ICON_CLOSE: &str = "\u{ea76}";
const ICON_CHROME_MINIMIZE: &str = "\u{eaba}";
const ICON_CHROME_MAXIMIZE: &str = "\u{eab9}";
@@ -1063,31 +1179,6 @@ pub(super) fn draw_chrome(d: &mut Display) {
if settings_hovered { ICON_HOVER } else { ICON },
ICON_SETTINGS,
);
// The settings modal veils the sidebar, so its "+" must not float on
// top of the glass (the veil lives in the quad layer below text).
if !d.nebula_settings_open {
draw_centered_icon(
&mut d.renderer,
&mut d.glyph_cache,
&size,
cell_w,
cell_h,
tab_layout.plus,
if d.nebula_chrome_hover == ChromeHit::NewTab { ICON_HOVER } else { ICON },
ICON_ADD,
);
// The dropdown chevron (codicon chevron-down) beside "+".
draw_centered_icon(
&mut d.renderer,
&mut d.glyph_cache,
&size,
cell_w,
cell_h,
tab_layout.menu,
if d.nebula_chrome_hover == ChromeHit::NewTabMenu { ICON_HOVER } else { ICON },
ICON_CHEVRON_DOWN,
);
}
for (hit, cx, cy) in chrome_control_centers(w, top_y, bar_h, scale) {
let hovered = d.nebula_chrome_hover == hit;
let icon = match hit {
@@ -1138,11 +1229,15 @@ pub(super) fn draw_chrome(d: &mut Display) {
// inside the header band to keep clearance from the join.
let (pnl_x, pnl_y, _, _) = tab_layout.panel;
let tabs_chevron = if d.nebula_tabs_section_open { "\u{eab4}" } else { "\u{eab6}" };
d.renderer.draw_chrome_text(
const SECTION_TITLE_SCALE: f32 = 0.75;
let section_title_flags = nebula_terminal::term::cell::Flags::BOLD;
d.renderer.draw_doc_text(
&size,
pnl_x + s(16.0),
pnl_y + s(18.0),
pnl_y + s(22.0),
SECTION_TITLE_SCALE,
TXT_DIM,
section_title_flags,
&format!("TABS {tabs_chevron}"),
&mut d.glyph_cache,
);
@@ -1314,15 +1409,6 @@ pub(super) fn draw_chrome(d: &mut Display) {
&label,
&mut d.glyph_cache,
);
let (ax, ay, aw, ah) = tab_layout.hosts_add;
d.renderer.draw_chrome_text(
&size,
ax + (aw - cell_w) / 2.0,
ay + (ah - cell_h) / 2.0,
if d.nebula_chrome_hover == ChromeHit::AddSshHost { sk.accent } else { TXT_DIM },
"+",
&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;
@@ -1354,11 +1440,13 @@ pub(super) fn draw_chrome(d: &mut Display) {
if tab_layout.hosts_header.2 > 0.0 {
let (hh_x, hh_y, _, hh_h) = tab_layout.hosts_header;
let hosts_chevron = if d.nebula_hosts_section_open { "\u{eab4}" } else { "\u{eab6}" };
d.renderer.draw_chrome_text(
d.renderer.draw_doc_text(
&size,
hh_x + s(16.0),
hh_y + (hh_h - cell_h) / 2.0,
hh_y + (hh_h - cell_h * SECTION_TITLE_SCALE) / 2.0,
SECTION_TITLE_SCALE,
TXT_DIM,
section_title_flags,
&format!("SSH HOSTS {hosts_chevron}"),
&mut d.glyph_cache,
);
@@ -1373,7 +1461,7 @@ pub(super) fn draw_chrome(d: &mut Display) {
// would bleed across the seam onto the terminal grid.
if d.nebula_ssh_hosts.is_empty() && d.nebula_hosts_section_open {
use unicode_width::UnicodeWidthChar;
const HINT_SCALE: f32 = 0.85;
const HINT_SCALE: f32 = 0.80;
let hint_flags = nebula_terminal::term::cell::Flags::empty();
let (pnl_x, _, pnl_w, _) = tab_layout.panel;
let text_x = hh_x + s(28.0);
@@ -1423,7 +1511,7 @@ pub(super) fn draw_chrome(d: &mut Display) {
if hw <= 0.0 {
continue;
}
let hovered = matches!(d.nebula_chrome_hover, ChromeHit::Host(i) | ChromeHit::HostDelete(i) if i == index);
let hovered = matches!(d.nebula_chrome_hover, ChromeHit::Host(i) if i == index);
let color = if hovered { TXT_ON_ACCENT } else { TXT };
let cy = row_text_cy(hy, hh);
let name = d.nebula_ssh_hosts.get(index).map(String::as_str).unwrap_or("?");
@@ -1458,22 +1546,6 @@ pub(super) fn draw_chrome(d: &mut Display) {
&mut d.glyph_cache,
);
}
if hovered {
if let Some((cx, cy, cw, ch)) = tab_layout.host_closes.get(index).copied() {
d.renderer.draw_chrome_text(
&size,
cx + (cw - cell_w) / 2.0,
cy + (ch - cell_h) / 2.0,
if matches!(d.nebula_chrome_hover, ChromeHit::HostDelete(i) if i == index) {
Rgb::new(sk.danger.r, sk.danger.g, sk.danger.b)
} else {
TXT_DIM
},
"×",
&mut d.glyph_cache,
);
}
}
}
}
} else {
+95 -9
View File
@@ -11,6 +11,7 @@
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.
@@ -261,6 +262,7 @@ const RECENT_MAX: usize = 6;
pub struct CommandPalette {
open: bool,
query: String,
query_selection: super::text_input::SelectAllState,
/// Indices into the combined item space — `0..ITEMS.len()` are the static
/// actions, `ITEMS.len()..` map onto `profiles`. Best match first.
filtered: Vec<usize>,
@@ -294,6 +296,7 @@ impl CommandPalette {
let mut palette = Self {
open: false,
query: String::new(),
query_selection: Default::default(),
filtered: Vec::new(),
selected: None, // No selection until user navigates
recent: Vec::new(),
@@ -318,6 +321,12 @@ impl CommandPalette {
self.picking_default
}
/// Lightweight shell/profile selector mode. Unlike the full command
/// palette it has no search field and should release focus naturally.
pub fn is_picker(&self) -> bool {
self.open && self.profiles_only
}
/// 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]) {
@@ -377,6 +386,7 @@ impl CommandPalette {
self.profiles_only = false;
self.picking_default = false;
self.query.clear();
self.query_selection.clear();
self.refilter();
}
@@ -386,6 +396,7 @@ impl CommandPalette {
self.profiles_only = true;
self.picking_default = false;
self.query.clear();
self.query_selection.clear();
self.refilter();
}
@@ -396,6 +407,7 @@ impl CommandPalette {
self.profiles_only = true;
self.picking_default = true;
self.query.clear();
self.query_selection.clear();
self.refilter();
}
@@ -404,6 +416,7 @@ impl CommandPalette {
self.profiles_only = false;
self.picking_default = false;
self.hover = None;
self.query_selection.clear();
}
/// Update hover based on mouse position. `row` is the index within the
@@ -443,15 +456,33 @@ impl CommandPalette {
if c.is_control() {
return;
}
self.query.push(c);
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.pop();
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<String> {
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() {
@@ -466,7 +497,10 @@ impl CommandPalette {
/// 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<PaletteAction> {
let selected = self.selected?;
// The first row is visibly selected on a freshly opened palette. Make
// Enter execute that same row even before arrow navigation, otherwise
// the UI advertises an action that the keyboard cannot confirm.
let selected = self.selected.unwrap_or(0);
let idx = *self.filtered.get(selected)?;
self.close();
if let Some(profile) = idx.checked_sub(ITEMS.len()) {
@@ -556,11 +590,13 @@ impl CommandPalette {
if self.filtered.is_empty() || max_rows == 0 {
return (Vec::new(), None);
}
// No selection yet (initial state) — show first page, no highlight
// 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(|&idx| self.row_for_idx(idx)).collect();
return (rows, None);
return (rows, Some(0));
};
// Keep the selection visible: once it passes the last row, scroll so it
// sits on the bottom line of the window.
@@ -699,7 +735,7 @@ pub(super) fn push_quads(
let s = |v: f32| v * scale;
let palette = theme.palette();
let sk = theme.skin();
let with_input = !model.is_picking_default();
let with_input = !model.is_picker();
let layout = palette_layout(w, h, scale, with_input);
let (px, py, pw, ph) = layout.panel;
let (ix, iy, iw, ih) = layout.input;
@@ -736,6 +772,20 @@ pub(super) fn push_quads(
// Query input box: only in full palette mode, not the WT-style picker.
if with_input {
quads.push(UiQuad::solid(ix, iy, iw, ih, s(10.0), sk.input));
if model.query_all_selected() && !model.query.is_empty() {
let cell_w = size.cell_width();
let columns: usize = model.query.chars().map(|c| c.width().unwrap_or(0)).sum();
let selection_x = ix + s(14.0 + 28.0);
let selection_w = (columns as f32 * cell_w).min(iw - s(56.0));
quads.push(UiQuad::solid(
selection_x - s(2.0),
iy + s(7.0),
selection_w + s(4.0),
ih - s(14.0),
s(4.0),
sk.accent_soft,
));
}
}
// Hover background: subtle highlight when the mouse is over a row.
@@ -795,7 +845,7 @@ pub(super) fn draw_text(
let h = size.height();
let cell_w = size.cell_width();
let cell_h = size.cell_height();
let with_input = !model.is_picking_default();
let with_input = !model.is_picker();
let layout = palette_layout(w, h, scale, with_input);
let (ix, iy, iw, ih) = layout.input;
@@ -821,7 +871,11 @@ pub(super) fn draw_text(
r.draw_chrome_text(size, query_x, text_y, sk.ink_strong, cursor, gc);
} else {
// Show query + cursor at the end
let shown = format!("{query}{cursor}");
let shown = if model.query_all_selected() {
query.to_owned()
} else {
format!("{query}{cursor}")
};
r.draw_chrome_text(size, query_x, text_y, sk.ink_strong, &shown, gc);
}
}
@@ -909,7 +963,9 @@ mod tests {
args: vec![],
},
}];
palette.refilter();
// 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");
@@ -1010,4 +1066,34 @@ mod tests {
// The bottom visible row is the actually-selected item (field .label).
assert_eq!(rows[max - 1].label, ITEMS[palette.filtered[7]].label);
}
#[test]
fn only_profile_modes_are_lightweight_pickers() {
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 menu releases focus naturally");
palette.close();
assert!(!palette.is_picker());
palette.open_default_picker();
assert!(palette.is_picker(), "default-shell selector shares picker semantics");
}
#[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());
}
}
+504
View File
@@ -0,0 +1,504 @@
//! Unified native context menu for sidebar tabs and SSH destinations.
//!
//! The menu is rendered by Nebula's existing OpenGL UI pipeline. Geometry,
//! hit-testing and animation live together here so pointer targets cannot drift
//! away from the pixels the user sees at non-integer DPI scales.
use std::time::{Duration, Instant};
use super::*;
const OPEN_DURATION: Duration = Duration::from_millis(120);
const CLOSE_DURATION: Duration = Duration::from_millis(90);
// Maple Mono Normal NF CN ships the stable Codicon block below. Keep menu
// symbols on that block: newer Codicon additions such as U+EC86 are absent
// from the installed Maple version and rasterize as a missing-glyph box.
const ICON_COPY: &str = "\u{ebcc}";
const ICON_SPLIT_RIGHT: &str = "\u{eb56}";
const ICON_SPLIT_DOWN: &str = "\u{eb57}";
const ICON_EDIT: &str = "\u{ea73}";
const ICON_CLOSE: &str = "\u{ea76}";
/// Curated tab colors. `None` is the first swatch and means no light strip.
pub const TAB_COLORS: [Rgb; 7] = [
Rgb::new(224, 108, 117),
Rgb::new(209, 154, 102),
Rgb::new(229, 192, 123),
Rgb::new(152, 195, 121),
Rgb::new(86, 182, 194),
Rgb::new(97, 175, 239),
Rgb::new(198, 120, 221),
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ContextMenuTarget {
Tab(usize),
Ssh(usize),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ContextMenuAction {
DuplicateTab(usize),
SplitTabRight(usize),
SplitTabDown(usize),
RenameTab(usize),
CloseTab(usize),
SetTabColor { index: usize, color: Option<Rgb> },
ConnectSsh(usize),
CopySshAddress(usize),
EditSsh(usize),
DeleteSsh(usize),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ContextMenuHit {
Outside,
Panel,
Action(ContextMenuAction),
}
#[derive(Debug, Clone)]
pub(super) struct ContextMenu {
target: ContextMenuTarget,
anchor: (f32, f32),
opened_at: Instant,
closing_at: Option<Instant>,
hover: Option<ContextMenuAction>,
current_color: Option<Rgb>,
}
impl ContextMenu {
pub(super) fn new(
target: ContextMenuTarget,
anchor: (f32, f32),
current_color: Option<Rgb>,
) -> Self {
Self {
target,
anchor,
opened_at: Instant::now(),
closing_at: None,
hover: None,
current_color,
}
}
pub(super) fn begin_close(&mut self) {
self.closing_at.get_or_insert_with(Instant::now);
self.hover = None;
}
pub(super) fn set_hover(&mut self, action: Option<ContextMenuAction>) -> bool {
if self.hover == action {
return false;
}
self.hover = action;
true
}
fn progress(&self) -> f32 {
if let Some(start) = self.closing_at {
return (1.0 - start.elapsed().as_secs_f32() / CLOSE_DURATION.as_secs_f32())
.clamp(0.0, 1.0);
}
(self.opened_at.elapsed().as_secs_f32() / OPEN_DURATION.as_secs_f32()).clamp(0.0, 1.0)
}
pub(super) fn finished(&self) -> bool {
self.closing_at.is_some_and(|start| start.elapsed() >= CLOSE_DURATION)
}
pub(super) fn animating(&self) -> bool {
self.closing_at.is_some() || self.opened_at.elapsed() < OPEN_DURATION
}
pub(super) fn interactive(&self) -> bool {
self.closing_at.is_none()
}
}
#[derive(Debug, Clone, Copy)]
struct Row {
action: ContextMenuAction,
icon: &'static str,
label: &'static str,
hint: &'static str,
rect: (f32, f32, f32, f32),
danger: bool,
}
#[derive(Debug, Clone)]
struct MenuLayout {
panel: (f32, f32, f32, f32),
rows: Vec<Row>,
separators: Vec<(f32, f32, f32, f32)>,
color_label: Option<(f32, f32)>,
colors: Vec<(Option<Rgb>, ContextMenuAction, (f32, f32, f32, f32))>,
}
fn contains((x, y, w, h): (f32, f32, f32, f32), px: f32, py: f32) -> bool {
px >= x && px <= x + w && py >= y && py <= y + h
}
fn layout(menu: &ContextMenu, size: SizeInfo, scale: f32, animated_y_offset: f32) -> MenuLayout {
let s = |v: f32| v * scale;
let width = s(252.0);
let row_h = s(38.0);
let pad = s(7.0);
let sep_h = s(9.0);
let color_h = s(62.0);
let (row_count, separators, extra) = match menu.target {
ContextMenuTarget::Tab(_) => (5usize, 2usize, color_h),
ContextMenuTarget::Ssh(_) => (4usize, 1usize, 0.0),
};
let height = pad * 2.0 + row_count as f32 * row_h + separators as f32 * sep_h + extra;
let margin = s(8.0);
let mut x = menu.anchor.0;
let mut y = menu.anchor.1 + animated_y_offset;
if x + width > size.width() - margin {
x = (menu.anchor.0 - width).max(margin);
}
if y + height > size.height() - margin {
y = (size.height() - margin - height).max(margin);
}
// The anchor may itself sit inside the safety margin (a right-click on a
// close glyph at the edge), so clamp after choosing the preferred side as
// well. Without this final cap the menu could still overhang by the
// anchor-to-margin distance.
let max_x = (size.width() - margin - width).max(margin);
let max_y = (size.height() - margin - height).max(margin);
x = x.clamp(margin, max_x);
y = y.clamp(margin, max_y);
let panel = (x, y, width, height);
let mut rows = Vec::with_capacity(row_count);
let mut separator_rects = Vec::with_capacity(separators);
let mut cursor_y = y + pad;
let row = |action, icon, label, hint, y, danger| Row {
action,
icon,
label,
hint,
rect: (x + pad, y, width - 2.0 * pad, row_h),
danger,
};
let mut separator = |cursor_y: &mut f32| {
separator_rects.push((x + s(12.0), *cursor_y + sep_h * 0.5, width - s(24.0), s(1.0)));
*cursor_y += sep_h;
};
let mut color_label = None;
let mut colors = Vec::new();
match menu.target {
ContextMenuTarget::Tab(index) => {
rows.push(row(
ContextMenuAction::DuplicateTab(index),
ICON_COPY,
"复制标签页",
"",
cursor_y,
false,
));
cursor_y += row_h;
separator(&mut cursor_y);
rows.push(row(
ContextMenuAction::SplitTabRight(index),
ICON_SPLIT_RIGHT,
"左右分屏",
"Ctrl+Shift+D",
cursor_y,
false,
));
cursor_y += row_h;
rows.push(row(
ContextMenuAction::SplitTabDown(index),
ICON_SPLIT_DOWN,
"上下分屏",
"Ctrl+Shift+S",
cursor_y,
false,
));
cursor_y += row_h;
separator(&mut cursor_y);
rows.push(row(
ContextMenuAction::RenameTab(index),
ICON_EDIT,
"重命名",
"F2",
cursor_y,
false,
));
cursor_y += row_h;
rows.push(row(
ContextMenuAction::CloseTab(index),
ICON_CLOSE,
"关闭",
"Ctrl+Shift+W",
cursor_y,
true,
));
cursor_y += row_h;
color_label = Some((x + s(14.0), cursor_y + s(8.0)));
let swatch = s(20.0);
let gap = s(8.0);
let start_x = x + s(14.0);
let swatch_y = cursor_y + s(32.0);
let all = std::iter::once(None).chain(TAB_COLORS.into_iter().map(Some));
for (slot, color) in all.enumerate() {
let rect = (start_x + slot as f32 * (swatch + gap), swatch_y, swatch, swatch);
colors.push((color, ContextMenuAction::SetTabColor { index, color }, rect));
}
},
ContextMenuTarget::Ssh(index) => {
rows.push(row(
ContextMenuAction::ConnectSsh(index),
"\u{f489}",
"连接",
"Enter",
cursor_y,
false,
));
cursor_y += row_h;
rows.push(row(
ContextMenuAction::CopySshAddress(index),
ICON_COPY,
"复制地址",
"",
cursor_y,
false,
));
cursor_y += row_h;
rows.push(row(
ContextMenuAction::EditSsh(index),
ICON_EDIT,
"编辑",
"",
cursor_y,
false,
));
cursor_y += row_h;
separator(&mut cursor_y);
rows.push(row(
ContextMenuAction::DeleteSsh(index),
"\u{ea81}",
"删除",
"",
cursor_y,
true,
));
},
}
MenuLayout { panel, rows, separators: separator_rects, color_label, colors }
}
pub(super) fn hit_test(
menu: &ContextMenu,
size: SizeInfo,
scale: f32,
x: f32,
y: f32,
) -> ContextMenuHit {
let layout = layout(menu, size, scale, 0.0);
for row in &layout.rows {
if contains(row.rect, x, y) {
return ContextMenuHit::Action(row.action);
}
}
for (_, action, rect) in &layout.colors {
if contains(*rect, x, y) {
return ContextMenuHit::Action(*action);
}
}
if contains(layout.panel, x, y) { ContextMenuHit::Panel } else { ContextMenuHit::Outside }
}
fn alpha(color: Rgba, opacity: f32) -> Rgba {
Rgba::new(color.r, color.g, color.b, (color.a as f32 * opacity).round() as u8)
}
fn fade_ink(base: Rgba, ink: Rgb, opacity: f32) -> Rgb {
let mix = |a: u8, b: u8| (a as f32 + (b as f32 - a as f32) * opacity).round() as u8;
Rgb::new(mix(base.r, ink.r), mix(base.g, ink.g), mix(base.b, ink.b))
}
/// Paint the menu after the chrome/logo pass and before true modal dialogs.
pub(super) fn draw(display: &mut Display) {
let Some(menu) = display.nebula_context_menu.clone() else { return };
if menu.finished() {
display.nebula_context_menu = None;
return;
}
let progress = menu.progress();
let eased = 1.0 - (1.0 - progress) * (1.0 - progress) * (1.0 - progress);
let size = display.size_info;
let scale = display.window.scale_factor as f32;
let s = |v: f32| v * scale;
let layout = layout(&menu, size, scale, -s(5.0) * (1.0 - eased));
let sk = display.nebula_theme.skin();
let radius = s(9.0);
let (px, py, pw, ph) = layout.panel;
let mut quads = vec![
UiQuad::glow(
px - s(16.0),
py - s(12.0),
pw + s(32.0),
ph + s(30.0),
alpha(Rgba::new(0, 0, 0, 72), progress),
),
UiQuad::solid(px, py, pw, ph, radius, alpha(sk.panel, progress)),
UiQuad::solid(px, py, pw, s(1.0).max(1.0), radius, alpha(sk.hairline, progress)),
];
for separator in &layout.separators {
quads.push(UiQuad::solid(
separator.0,
separator.1,
separator.2,
separator.3,
0.0,
alpha(sk.hairline, progress),
));
}
for row in &layout.rows {
if menu.hover == Some(row.action) {
quads.push(UiQuad::solid(
row.rect.0,
row.rect.1,
row.rect.2,
row.rect.3,
s(6.0),
alpha(sk.hover, progress),
));
}
}
for (color, action, rect) in &layout.colors {
let selected = *color == menu.current_color;
if menu.hover == Some(*action) || selected {
let ring = if selected { sk.accent } else { sk.ink_dim };
quads.push(UiQuad::solid(
rect.0 - s(3.0),
rect.1 - s(3.0),
rect.2 + s(6.0),
rect.3 + s(6.0),
s(7.0),
alpha(Rgba::new(ring.r, ring.g, ring.b, 210), progress),
));
}
let swatch = color.unwrap_or(sk.accent);
quads.push(UiQuad::solid(
rect.0,
rect.1,
rect.2,
rect.3,
s(5.0),
alpha(Rgba::new(swatch.r, swatch.g, swatch.b, 255), progress),
));
}
display.renderer.draw_ui(&size, &quads);
// Text has no alpha channel in the glyph pipeline, so fade it toward the
// panel color. This avoids a one-frame label pop during both directions.
let ink = fade_ink(sk.panel, sk.ink, progress);
let dim = fade_ink(sk.panel, sk.ink_dim, progress);
let danger = fade_ink(sk.panel, Rgb::new(sk.danger.r, sk.danger.g, sk.danger.b), progress);
let cell_h = size.cell_height();
for row in &layout.rows {
let y = row.rect.1 + (row.rect.3 - cell_h) * 0.5;
display.renderer.draw_chrome_text(
&size,
row.rect.0 + s(10.0),
y,
if row.danger { danger } else { dim },
row.icon,
&mut display.glyph_cache,
);
display.renderer.draw_chrome_text(
&size,
row.rect.0 + s(38.0),
y,
if row.danger { danger } else { ink },
row.label,
&mut display.glyph_cache,
);
if !row.hint.is_empty() {
let hint_w = row.hint.chars().count() as f32 * size.cell_width();
display.renderer.draw_chrome_text(
&size,
row.rect.0 + row.rect.2 - hint_w - s(10.0),
y,
dim,
row.hint,
&mut display.glyph_cache,
);
}
}
if let Some((x, y)) = layout.color_label {
display.renderer.draw_chrome_text(&size, x, y, dim, "标签颜色", &mut display.glyph_cache);
}
if let Some((None, _, rect)) = layout.colors.first() {
display.renderer.draw_chrome_text(
&size,
rect.0 + (rect.2 - size.cell_width()) * 0.5,
rect.1 + (rect.3 - cell_h) * 0.5,
fade_ink(
Rgba::new(sk.accent.r, sk.accent.g, sk.accent.b, 255),
sk.ink_on_accent,
progress,
),
"A",
&mut display.glyph_cache,
);
}
if menu.animating() {
display.window.request_redraw();
display.pending_update.dirty = true;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn size() -> SizeInfo {
SizeInfo::new(800.0, 600.0, 9.0, 18.0, 8.0, 8.0, false)
}
#[test]
fn tab_menu_stays_inside_bottom_right_window_edge() {
let menu = ContextMenu::new(ContextMenuTarget::Tab(2), (796.0, 596.0), None);
let layout = layout(&menu, size(), 1.0, 0.0);
assert!(layout.panel.0 >= 8.0);
assert!(layout.panel.1 >= 8.0);
assert!(layout.panel.0 + layout.panel.2 <= 792.0);
assert!(layout.panel.1 + layout.panel.3 <= 592.0);
}
#[test]
fn first_tab_row_and_auto_color_have_actions() {
let menu = ContextMenu::new(ContextMenuTarget::Tab(3), (100.0, 100.0), None);
let layout = layout(&menu, size(), 1.0, 0.0);
let first = layout.rows[0];
assert_eq!(
hit_test(
&menu,
size(),
1.0,
first.rect.0 + 2.0,
first.rect.1 + 2.0,
),
ContextMenuHit::Action(ContextMenuAction::DuplicateTab(3))
);
assert_eq!(layout.colors.len(), 8);
assert_eq!(layout.colors[0].0, None);
assert_eq!(
layout.colors[0].1,
ContextMenuAction::SetTabColor { index: 3, color: None }
);
}
}
File diff suppressed because it is too large. Load diff
+107 -10
View File
@@ -93,6 +93,8 @@ pub enum SettingsHit {
ShellCycle,
/// One of the expanded shell picker rows (index into detected_shells).
ShellPickerRow(usize),
/// Restore one address from the persistent hidden-host list.
RestoreHiddenSsh(usize),
FetchToggle,
PowerlineToggle,
OpacityDown,
@@ -135,6 +137,10 @@ pub(super) struct NebulaRuntimeSettings {
/// SSH destinations auto-saved after a successful typed `ssh` connection,
/// most recent first (see `Display::nebula_save_ssh_host`).
pub(super) saved_hosts: Vec<String>,
/// SSH aliases explicitly removed from the sidebar. This is separate from
/// `saved_hosts` because entries discovered in `~/.ssh/config` would
/// otherwise reappear on the very next merge.
pub(super) hidden_hosts: Vec<String>,
}
/// Load runtime UI settings from `Nebula/nebula_settings.txt`; defaults when
@@ -163,6 +169,7 @@ pub(super) fn nebula_settings_load(config: &UiConfig) -> NebulaRuntimeSettings {
theme: NebulaTheme::default(),
pinned_hosts: Vec::new(),
saved_hosts: Vec::new(),
hidden_hosts: Vec::new(),
};
if let Ok(data) = std::fs::read_to_string(path) {
for line in data.lines() {
@@ -223,6 +230,14 @@ pub(super) fn nebula_settings_load(config: &UiConfig) -> NebulaRuntimeSettings {
.map(str::to_owned)
.collect();
},
Some(("hidden_hosts", v)) => {
settings.hidden_hosts = v
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned)
.collect();
},
_ => {},
}
}
@@ -276,10 +291,11 @@ pub(super) fn nebula_settings_write(settings: &NebulaRuntimeSettings) {
let path = nebula_data_dir().join("nebula_settings.txt");
let pinned_hosts = settings.pinned_hosts.join(",");
let saved_hosts = settings.saved_hosts.join(",");
let hidden_hosts = settings.hidden_hosts.join(",");
let _ = std::fs::write(
path,
format!(
"theme={theme}\nghost={}\naccept={accept}\nshell={shell}\nfetch={}\npowerline={}\nkeep_session={}\nopacity={:.2}\nbackground={background}\nbackground_image={background_image}\nbackground_image_opacity={:.2}\npinned_hosts={pinned_hosts}\nsaved_hosts={saved_hosts}\n",
"theme={theme}\nghost={}\naccept={accept}\nshell={shell}\nfetch={}\npowerline={}\nkeep_session={}\nopacity={:.2}\nbackground={background}\nbackground_image={background_image}\nbackground_image_opacity={:.2}\npinned_hosts={pinned_hosts}\nsaved_hosts={saved_hosts}\nhidden_hosts={hidden_hosts}\n",
settings.ghost as u8,
settings.fetch as u8,
settings.powerline as u8,
@@ -308,6 +324,8 @@ struct SettingsGeometry {
ghost: (f32, f32, f32, f32),
accept: (f32, f32, f32, f32),
open_config_file: (f32, f32, f32, f32),
hidden_host_row0: (f32, f32, f32, f32),
hidden_host_count: usize,
/// Full-width "窗口透明度" row (the frame); the stepper buttons below sit
/// inside it.
opacity_row: (f32, f32, f32, f32),
@@ -344,9 +362,16 @@ pub(super) fn settings_max_scroll(
section: NebulaSettingsSection,
shell_picker_open: bool,
shell_picker_count: usize,
hidden_host_count: usize,
) -> f32 {
let geometry =
settings_geometry(size_info, scale_factor, 0.0, shell_picker_open, shell_picker_count);
let geometry = settings_geometry(
size_info,
scale_factor,
0.0,
shell_picker_open,
shell_picker_count,
hidden_host_count,
);
let (_, _, _, ph) = geometry.popup;
let content_h = match section {
NebulaSettingsSection::Appearance => geometry.appearance_h,
@@ -363,6 +388,7 @@ fn settings_geometry(
scroll: f32,
shell_picker_open: bool,
shell_picker_count: usize,
hidden_host_count: usize,
) -> SettingsGeometry {
let s = |v: f32| v * scale_factor;
let w = size_info.width();
@@ -445,7 +471,13 @@ fn settings_geometry(
let picker_extra = if shell_picker_open { shell_picker_count as f32 * ROW_H } else { 0.0 };
let ghost_y0 = shell_y0 + 3.0 * ROW_H + picker_extra + GROUP_ADVANCE;
let open_y0 = ghost_y0 + 2.0 * ROW_H + GROUP_ADVANCE;
let profiles_h = s(open_y0 + ROW_H + 32.0 - 72.0);
let hidden_y0 = open_y0 + ROW_H + GROUP_ADVANCE;
let profiles_end = if hidden_host_count == 0 {
open_y0 + ROW_H
} else {
hidden_y0 + hidden_host_count as f32 * ROW_H
};
let profiles_h = s(profiles_end + 32.0 - 72.0);
// Keymap: one contiguous group of read-only shortcut rows.
let keymap_y0 = 146.0;
@@ -488,6 +520,8 @@ fn settings_geometry(
ghost: (row_x, at(ghost_y0), row_w, row_h),
accept: (row_x, at(ghost_y0 + ROW_H), row_w, row_h),
open_config_file: (row_x, at(open_y0), row_w, row_h),
hidden_host_row0: (row_x, at(hidden_y0), row_w, row_h),
hidden_host_count,
reset: (popup_x + popup_w - s(170.0), popup_y + s(24.0), s(150.0), s(42.0)),
content_top,
appearance_h,
@@ -513,9 +547,16 @@ pub fn settings_hit(
scroll: f32,
shell_picker_open: bool,
shell_picker_count: usize,
hidden_host_count: usize,
) -> SettingsHit {
let geometry =
settings_geometry(size_info, scale_factor, scroll, shell_picker_open, shell_picker_count);
let geometry = settings_geometry(
size_info,
scale_factor,
scroll,
shell_picker_open,
shell_picker_count,
hidden_host_count,
);
if contains_rect(geometry.gear, x, y) {
return SettingsHit::Toggle;
@@ -594,6 +635,13 @@ pub fn settings_hit(
if contains_rect(geometry.open_config_file, x, y) {
return SettingsHit::OpenConfigFile;
}
let (row_x, row_y, row_w, row_h) = geometry.hidden_host_row0;
for index in 0..geometry.hidden_host_count {
let rect = (row_x, row_y + index as f32 * row_h, row_w, row_h);
if contains_rect(rect, x, y) {
return SettingsHit::RestoreHiddenSsh(index);
}
}
},
NebulaSettingsSection::Keymap => {},
NebulaSettingsSection::Advanced => {
@@ -626,6 +674,9 @@ pub(super) struct SettingsView {
/// Detected shells for the picker (cached once per process).
pub(super) shells: Vec<(String, String, String)>, // (id, name, program)
pub(super) shell_id: Option<String>,
/// Persistent soft-deleted destinations. Rows provide a discoverable
/// recovery path after the short Undo bar has expired.
pub(super) hidden_hosts: Vec<String>,
pub(super) fetch: bool,
pub(super) powerline: bool,
pub(super) keep_session: bool,
@@ -652,8 +703,14 @@ pub(super) fn push_quads(
let h = size.height();
let sk = view.theme.skin();
let geometry =
settings_geometry(size, scale, view.scroll, view.shell_picker_open, view.shells.len());
let geometry = settings_geometry(
size,
scale,
view.scroll,
view.shell_picker_open,
view.shells.len(),
view.hidden_hosts.len(),
);
let (px, py, pw, ph) = geometry.popup;
// Header band height: the title row sits above the content, and the header
// separator + big title are all measured from here.
@@ -991,6 +1048,18 @@ pub(super) fn push_quads(
}
group_frame(quads, geometry.ghost, 2);
group_frame(quads, geometry.open_config_file, 1);
if geometry.hidden_host_count > 0 {
group_frame(quads, geometry.hidden_host_row0, geometry.hidden_host_count);
for index in 0..geometry.hidden_host_count {
let mut rect = geometry.hidden_host_row0;
rect.1 += index as f32 * rect.3;
row_hover(
quads,
rect,
view.hover == SettingsHit::RestoreHiddenSsh(index),
);
}
}
for (hit, rect) in [
(SettingsHit::ShellCycle, geometry.shell),
(SettingsHit::FetchToggle, geometry.fetch),
@@ -1127,8 +1196,14 @@ pub(super) fn draw_text(
let cell_h = size.cell_height();
let sk = view.theme.skin();
let geometry =
settings_geometry(size, scale, view.scroll, view.shell_picker_open, view.shells.len());
let geometry = settings_geometry(
size,
scale,
view.scroll,
view.shell_picker_open,
view.shells.len(),
view.hidden_hosts.len(),
);
let mut icon_draws = Vec::new();
let (px, py, _pw, ph) = geometry.popup;
let (content_x, content_y, _content_w, _) = geometry.content;
@@ -1428,6 +1503,28 @@ pub(super) fn draw_text(
gc,
);
}
if geometry.hidden_host_count > 0 {
let (hx, hy, hw, hh) = geometry.hidden_host_row0;
if visible(group_y(hy), title_h) {
section_title(
r,
gc,
size,
scale,
&sk,
hx,
group_y(hy),
"已隐藏 SSH 主机 · 密码不会恢复",
);
}
for (index, host) in view.hidden_hosts.iter().enumerate() {
let rect = (hx, hy + index as f32 * hh, hw, hh);
if visible(rect.1, rect.3) {
row_label(r, gc, size, scale, &sk, rect, host, "恢复", sk.accent);
}
}
}
},
NebulaSettingsSection::Keymap => {
let (kx, ky, kw, kh) = geometry.keymap_row0;
+105 -6
View File
@@ -14,6 +14,7 @@
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)]
@@ -117,6 +118,7 @@ pub struct SidePanel {
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
@@ -126,6 +128,7 @@ pub struct SidePanel {
/// 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 row toward the terminal.
@@ -161,9 +164,11 @@ impl SidePanel {
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,
@@ -268,22 +273,33 @@ impl SidePanel {
/// Append typed text to the filter query and re-derive the rows.
pub fn search_input(&mut self, text: &str) {
for c in text.chars().filter(|c| !c.is_control()) {
self.search.push(c);
}
self.search_selection.insert(&mut self.search, text);
self.scroll = 0;
self.rebuild_rows();
}
pub fn search_backspace(&mut self) {
self.search.pop();
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<String> {
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;
@@ -356,9 +372,41 @@ impl SidePanel {
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<String> {
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 <msg>`.
pub fn git_commit_submit(&mut self) {
let msg = self.commit_msg.trim().to_string();
@@ -367,6 +415,7 @@ impl SidePanel {
}
self.commit_focus = false;
self.commit_msg.clear();
self.commit_selection.clear();
self.spawn_git(vec!["commit".into(), "-m".into(), msg]);
}
@@ -752,6 +801,7 @@ pub(super) fn push_quads(
theme: &NebulaTheme,
quads: &mut Vec<UiQuad>,
scale: f32,
cell_w: f32,
) {
let s = |v: f32| v * scale;
let palette = theme.palette();
@@ -845,6 +895,19 @@ pub(super) fn push_quads(
));
}
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
@@ -923,6 +986,19 @@ pub(super) fn push_quads(
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(8.0)) {
quads.push(UiQuad::solid(bx, sy, bw, sh, radius, sk.input));
@@ -1033,7 +1109,10 @@ pub(super) fn draw_text(
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 && super::caret_blink_on() {
let shown = if panel.search_focus
&& !panel.search_all_selected()
&& super::caret_blink_on()
{
format!("{}▏", panel.search)
} else {
panel.search.clone()
@@ -1134,7 +1213,11 @@ pub(super) fn draw_text(
let (sx, sy, sw, sh) = layout.search;
let strip_ty = sy + (sh - cell_h) / 2.0;
if panel.commit_focus {
let caret = if super::caret_blink_on() { "▏" } else { "" };
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 取消"
@@ -1296,4 +1379,20 @@ mod tests {
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 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");
}
}
+71
View File
@@ -0,0 +1,71 @@
//! Shared select-all editing semantics for Nebula's small self-drawn fields.
//!
//! These fields do not yet expose arbitrary drag selections, but they must
//! still agree on the Windows muscle-memory baseline: Ctrl+A selects the whole
//! buffer, Ctrl+C copies that selection, and typing/paste replaces it.
#[derive(Debug, Clone, Default)]
pub(crate) struct SelectAllState {
selected: bool,
}
impl SelectAllState {
pub(crate) fn select(&mut self, text: &str) {
self.selected = !text.is_empty();
}
pub(crate) fn clear(&mut self) {
self.selected = false;
}
pub(crate) fn is_selected(&self) -> bool {
self.selected
}
pub(crate) fn selected_text(&self, text: &str) -> Option<String> {
self.selected.then(|| text.to_owned())
}
pub(crate) fn insert(&mut self, text: &mut String, incoming: &str) {
if self.selected {
text.clear();
self.selected = false;
}
text.extend(incoming.chars().filter(|character| !character.is_control()));
}
pub(crate) fn backspace(&mut self, text: &mut String) {
if self.selected {
text.clear();
self.selected = false;
} else {
text.pop();
}
}
}
#[cfg(test)]
mod tests {
use super::SelectAllState;
#[test]
fn paste_replaces_a_select_all_buffer() {
let mut state = SelectAllState::default();
let mut text = "old".to_owned();
state.select(&text);
assert_eq!(state.selected_text(&text).as_deref(), Some("old"));
state.insert(&mut text, "new\r\n");
assert_eq!(text, "new");
assert!(!state.is_selected());
}
#[test]
fn backspace_clears_a_select_all_buffer() {
let mut state = SelectAllState::default();
let mut text = "selected".to_owned();
state.select(&text);
state.backspace(&mut text);
assert!(text.is_empty());
assert!(!state.is_selected());
}
}
+29 -7
View File
@@ -743,6 +743,13 @@ impl ApplicationHandler<Event> for Processor {
window_context.display.window.request_redraw();
}
},
(EventType::SshDeleteUndoExpired, Some(window_id)) => {
if let Some(window_context) = self.windows.get_mut(window_id) {
window_context.display.expire_ssh_delete_undo();
window_context.dirty = true;
window_context.display.window.request_redraw();
}
},
(EventType::NebulaTab(request), Some(window_id)) => {
if let Some(window_context) = self.windows.get_mut(window_id) {
let close_window = window_context.handle_tab_request(request);
@@ -925,6 +932,8 @@ pub enum EventType {
/// An interactive resize settled (no size change for the debounce window):
/// flush the deferred PTY resizes + welcome-intro reprint in one shot.
NebulaResizeSettled,
/// The SSH deletion grace period ended; commit its delayed credential cleanup.
SshDeleteUndoExpired,
/// Typed AI-CLI lifecycle event from the nebula-hook pipe (see `ai_hook`).
AiHook(crate::ai_hook::AiHookEvent),
/// A toast was clicked: focus the originating window and, when known,
@@ -955,6 +964,8 @@ pub enum TabRequest {
OpenDoc(std::path::PathBuf),
Close,
CloseIndex(usize),
/// Duplicate a tab using the launch identity captured when it was opened.
Duplicate(usize),
CloseWindow,
SelectNext,
SelectPrev,
@@ -968,6 +979,12 @@ pub enum TabRequest {
},
/// Toggle a split (left/right or top/bottom) with an independent shell.
SplitToggle(crate::display::SplitDirection),
/// Select a specific tab and split it. Context menus can target inactive
/// tabs, so this must be atomic rather than relying on two queued events.
SplitIndex {
index: usize,
direction: crate::display::SplitDirection,
},
/// Dock the whole layout of tab `source` into the ACTIVE tab, splitting it
/// on `nav`'s side (drag a sidebar tab into the terminal area to drop).
DockSplit {
@@ -982,6 +999,11 @@ pub enum TabRequest {
BeginRename(usize),
/// Commit the rename with the provided new name.
CommitRename(String),
/// Set a tab's custom light-strip color; `None` follows the theme accent.
SetColor {
index: usize,
color: Option<crate::display::color::Rgb>,
},
/// Cancel the current rename operation.
CancelRename,
}
@@ -2431,7 +2453,7 @@ impl input::Processor<EventProxy, ActionContext<'_, Notifier, EventProxy>> {
// Clock ticks are handled at the window-context level.
EventType::NebulaTick | EventType::NebulaAttach => (),
// Resize settling is handled at the window-context level.
EventType::NebulaResizeSettled => (),
EventType::NebulaResizeSettled | EventType::SshDeleteUndoExpired => (),
// AI hook events are handled at the Processor level (they may
// target any window's pane); FocusWindow likewise.
EventType::AiHook(_) | EventType::FocusWindow { .. } => (),
@@ -2769,7 +2791,7 @@ impl input::Processor<EventProxy, ActionContext<'_, Notifier, EventProxy>> {
}
let panel = &mut self.ctx.display.nebula_side_panel;
panel.search_unfocus(false);
panel.commit_focus = false;
panel.commit_unfocus();
}
// Nebula: always redraw on focus change, and clear the
@@ -2835,13 +2857,13 @@ impl input::Processor<EventProxy, ActionContext<'_, Notifier, EventProxy>> {
// box, not paste into the shell behind it.
self.ctx.display.nebula_side_panel.search_input(&text);
} else if self.ctx.display.nebula_side_panel.commit_focus {
self.ctx
.display
.nebula_side_panel
.commit_msg
.extend(text.chars().filter(|c| !c.is_control()));
self.ctx.display.nebula_side_panel.commit_input(&text);
} else if self.ctx.display.nebula_ssh_editor.is_some() {
self.ctx.display.ssh_editor_insert(&text);
} else if self.ctx.display.command_palette_open()
&& !self.ctx.display.command_palette_picker_open()
{
self.ctx.display.palette_input_text(&text);
} else {
// Don't use bracketed paste for single char input.
self.ctx.paste(&text, text.chars().count() > 1);
+174 -15
View File
@@ -8,7 +8,7 @@ use winit::keyboard::{Key, KeyLocation, ModifiersState, NamedKey};
use winit::platform::macos::OptionAsAlt;
use nebula_terminal::event::EventListener;
use nebula_terminal::term::TermMode;
use nebula_terminal::term::{ClipboardType, TermMode};
use winit::platform::modifier_supplement::KeyEventExtModifierSupplement;
use crate::config::{Action, BindingKey, BindingMode, KeyBinding};
@@ -36,6 +36,29 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
return;
}
// A context menu is a transient focus scope, not a true modal. Esc
// only dismisses it; any other key dismisses first and then continues
// through the normal shortcut/terminal path.
if self.ctx.display().context_menu_interactive() {
let escape = matches!(key.logical_key, Key::Named(NamedKey::Escape));
self.ctx.display().close_context_menu();
self.ctx.mark_dirty();
if escape {
return;
}
}
// The inline default-shell list is a transient picker. Esc only
// dismisses; any other key dismisses and then follows its normal path.
if self.ctx.display().nebula_shell_picker_open {
let escape = matches!(key.logical_key, Key::Named(NamedKey::Escape));
self.ctx.display().close_shell_picker();
self.ctx.mark_dirty();
if escape {
return;
}
}
// Tab rename input: consume keyboard when editing a tab name (before
// command palette, so rename can't be interrupted by palette shortcuts)
if self.ctx.display().nebula_tab_rename.is_some() {
@@ -72,6 +95,26 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
self.ctx.display().tab_rename_caret_edge(true);
self.ctx.mark_dirty();
},
Key::Character(c)
if mods.control_key() && !mods.alt_key() && c.eq_ignore_ascii_case("a") =>
{
self.ctx.display().tab_rename_select_all();
self.ctx.mark_dirty();
},
Key::Character(c)
if mods.control_key() && !mods.alt_key() && c.eq_ignore_ascii_case("c") =>
{
if let Some(text) = self.ctx.display().tab_rename_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().tab_rename_insert(&text);
self.ctx.mark_dirty();
},
Key::Character(s) if mods.is_empty() || mods.shift_key() => {
// Insert at the caret (type-to-overwrite on select-all).
// Note: on Windows/IME, printable text arrives via
@@ -100,6 +143,25 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
Key::Named(NamedKey::Backspace) => {
self.ctx.display().nebula_side_panel.search_backspace();
},
Key::Character(c)
if mods.control_key() && !mods.alt_key() && c.eq_ignore_ascii_case("a") =>
{
self.ctx.display().nebula_side_panel.search_select_all();
},
Key::Character(c)
if mods.control_key() && !mods.alt_key() && c.eq_ignore_ascii_case("c") =>
{
let text = self.ctx.display().nebula_side_panel.search_selected_text();
if let Some(text) = 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().nebula_side_panel.search_input(&text);
},
Key::Character(s) if mods.is_empty() || mods.shift_key() => {
let text = s.clone();
self.ctx.display().nebula_side_panel.search_input(&text);
@@ -114,23 +176,35 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
if self.ctx.display().nebula_side_panel.commit_focus {
match &key.logical_key {
Key::Named(NamedKey::Escape) => {
let panel = &mut self.ctx.display().nebula_side_panel;
panel.commit_focus = false;
panel.commit_msg.clear();
self.ctx.display().nebula_side_panel.commit_cancel();
},
Key::Named(NamedKey::Enter) => {
self.ctx.display().nebula_side_panel.git_commit_submit();
},
Key::Named(NamedKey::Backspace) => {
self.ctx.display().nebula_side_panel.commit_msg.pop();
self.ctx.display().nebula_side_panel.commit_backspace();
},
Key::Character(c)
if mods.control_key() && !mods.alt_key() && c.eq_ignore_ascii_case("a") =>
{
self.ctx.display().nebula_side_panel.commit_select_all();
},
Key::Character(c)
if mods.control_key() && !mods.alt_key() && c.eq_ignore_ascii_case("c") =>
{
let text = self.ctx.display().nebula_side_panel.commit_selected_text();
if let Some(text) = 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().nebula_side_panel.commit_input(&text);
},
Key::Character(s) if mods.is_empty() || mods.shift_key() => {
let text = s.clone();
self.ctx
.display()
.nebula_side_panel
.commit_msg
.extend(text.chars().filter(|c| !c.is_control()));
self.ctx.display().nebula_side_panel.commit_input(s);
},
_ => {},
}
@@ -145,6 +219,32 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
Key::Named(NamedKey::Enter) => self.ctx.display().save_ssh_editor(),
Key::Named(NamedKey::Tab) => self.ctx.display().ssh_editor_next_field(),
Key::Named(NamedKey::Backspace) => self.ctx.display().ssh_editor_backspace(),
Key::Character(c)
if mods.control_key()
&& !mods.alt_key()
&& c.eq_ignore_ascii_case("a") =>
{
self.ctx.display().ssh_editor_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_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") =>
{
// SSH 编辑器是应用自有文本框。直接写入当前焦点字段,避免后续
// 终端粘贴绑定把地址或密码误发送到后台 PTY。
let text = self.ctx.clipboard_mut().load(ClipboardType::Clipboard);
self.ctx.display().ssh_editor_insert(&text);
},
Key::Character(c) if mods.is_empty() || mods.shift_key() => {
self.ctx.display().ssh_editor_insert(c)
},
@@ -155,7 +255,45 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
return;
}
// Nebula command palette owns the keyboard while open: route typing /
// The new-tab shell/profile list is a lightweight menu: navigation and
// confirmation stay inside it, while unrelated keys dismiss it and
// continue through normal shortcuts/terminal input.
if self.ctx.display().command_palette_picker_open() {
let handled = match &key.logical_key {
Key::Named(NamedKey::Escape) => {
self.ctx.display().close_command_palette();
true
},
Key::Named(NamedKey::Enter) => {
if let Some(action) = self.ctx.display().palette_confirm() {
self.run_palette_action(action);
}
true
},
Key::Named(NamedKey::Tab) => {
self.ctx.display().palette_move(if mods.shift_key() { -1 } else { 1 });
true
},
Key::Named(NamedKey::ArrowDown) => {
self.ctx.display().palette_move(1);
true
},
Key::Named(NamedKey::ArrowUp) => {
self.ctx.display().palette_move(-1);
true
},
_ => {
self.ctx.display().close_command_palette();
false
},
};
self.ctx.mark_dirty();
if handled {
return;
}
}
// The full command palette owns the keyboard while open: route typing /
// navigation / confirm into it and swallow everything else, so no
// terminal binding fires behind the modal.
if self.ctx.display().command_palette_open() {
@@ -173,9 +311,17 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
Key::Named(NamedKey::ArrowUp) => self.ctx.display().palette_move(-1),
Key::Named(NamedKey::Backspace) => self.ctx.display().palette_backspace(),
Key::Character(c) if mods.control_key() => {
// Ctrl+Shift+P (the opener) toggles the palette shut; other
// ctrl-combos are swallowed rather than typed as text.
if mods.shift_key() && c.eq_ignore_ascii_case("p") {
if c.eq_ignore_ascii_case("a") {
self.ctx.display().palette_select_all();
} else if c.eq_ignore_ascii_case("c") {
if let Some(text) = self.ctx.display().palette_selected_text() {
self.ctx.clipboard_mut().store(ClipboardType::Clipboard, text);
}
} else if c.eq_ignore_ascii_case("v") {
let text = self.ctx.clipboard_mut().load(ClipboardType::Clipboard);
self.ctx.display().palette_input_text(&text);
} else if mods.shift_key() && c.eq_ignore_ascii_case("p") {
// Ctrl+Shift+P (the opener) toggles the full palette.
self.ctx.display().close_command_palette();
}
},
@@ -206,6 +352,19 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
return;
}
// Ctrl+Z reverses the latest SSH deletion while the action bar lives.
// Text-entry and true-modal scopes above retain their own contracts.
if mods.control_key()
&& !mods.alt_key()
&& !mods.shift_key()
&& matches!(&key.logical_key, Key::Character(c) if c.eq_ignore_ascii_case("z"))
&& self.ctx.display().ssh_delete_undo_available()
{
self.undo_ssh_delete();
self.ctx.mark_dirty();
return;
}
// Document-viewer tab: bare navigation keys scroll the document. Only
// unmodified keys are taken — chords (Ctrl+Tab, Ctrl+Shift+W, …) fall
// through to the normal tab bindings, and any stray text ends in the
+223 -14
View File
@@ -126,6 +126,12 @@ pub trait ActionContext<T: EventListener> {
fn scroll(&mut self, _scroll: Scroll) {}
fn window(&mut self) -> &mut Window;
fn display(&mut self) -> &mut Display;
/// Whether this context owns Nebula's window chrome. Unit tests that only
/// exercise terminal selection can turn it off instead of constructing an
/// OpenGL Display just to get through unrelated hit-testing.
fn nebula_chrome_active(&self) -> bool {
true
}
fn terminal(&self) -> &Term<T>;
fn terminal_mut(&mut self) -> &mut Term<T>;
fn nebula_accept(&self) -> crate::display::AcceptKey {
@@ -569,6 +575,7 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
// open, and the closing animation swallows pointer motion until the
// retained editor snapshot is released.
if self.ctx.display().nebula_ssh_editor.is_some() {
self.ctx.display().set_ssh_delete_undo_hover(false);
let hit = if self.ctx.display().ssh_editor_active() {
self.ctx.display().ssh_editor_hit(x as f32, y as f32)
} else {
@@ -633,6 +640,30 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
let settings_scroll = self.ctx.display().settings_scroll();
let shell_picker_open = self.ctx.display().nebula_shell_picker_open;
let shell_picker_count = self.ctx.display().shell_picker_count();
let hidden_host_count = self.ctx.display().hidden_ssh_host_count();
// A native context menu is a pointer-modal overlay: while it is open,
// underlying links, tabs and drawer rows must not react to hover.
if self.ctx.display().context_menu_interactive() {
self.ctx.display().set_ssh_delete_undo_hover(false);
let hit = self.ctx.display().context_menu_hover(x as f32, y as f32);
self.ctx.window().set_mouse_cursor(if matches!(
hit,
crate::display::ContextMenuHit::Action(_)
) {
CursorIcon::Pointer
} else {
CursorIcon::Default
});
return;
}
let undo_hover = self.ctx.display().nebula_confirm.is_none()
&& !self.ctx.display().command_palette_open()
&& self.ctx.display().ssh_delete_undo_hit(x as f32, y as f32);
self.ctx.display().set_ssh_delete_undo_hover(undo_hover);
if undo_hover {
self.ctx.window().set_mouse_cursor(CursorIcon::Pointer);
return;
}
let settings_hover = crate::display::settings_hit(
&window_size,
scale,
@@ -643,6 +674,7 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
settings_scroll,
shell_picker_open,
shell_picker_count,
hidden_host_count,
);
let chrome_hover = if crate::display::in_chrome_bar(&window_size, scale, x as f32, y as f32)
{
@@ -676,6 +708,7 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
| crate::display::SettingsHit::AcceptCycle
| crate::display::SettingsHit::ShellCycle
| crate::display::SettingsHit::ShellPickerRow(_)
| crate::display::SettingsHit::RestoreHiddenSsh(_)
| crate::display::SettingsHit::FetchToggle
| crate::display::SettingsHit::PowerlineToggle
| crate::display::SettingsHit::KeepSessionToggle
@@ -760,7 +793,7 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
if self.ctx.display().command_palette_open() {
let px = x as f32;
let py = y as f32;
let with_input = !self.ctx.display().command_palette_picking_default();
let with_input = !self.ctx.display().command_palette_picker_open();
let layout = crate::display::command_palette::palette_layout(
window_size.width(),
window_size.height(),
@@ -993,9 +1026,42 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
self.ctx.display().nebula_confirm = None;
self.ctx.paste_now(&text, bracketed);
},
NebulaConfirm::DeleteSsh { host, .. } => {
self.ctx.display().nebula_confirm = None;
if self.ctx.display().confirm_delete_ssh_host(&host) {
self.schedule_ssh_delete_undo_expiry();
}
},
}
}
/// Keep timer creation and cancellation identical across keyboard, mouse,
/// and confirm-dialog paths. The delayed event owns only window routing;
/// sensitive credential state stays inside `Display`.
fn schedule_ssh_delete_undo_expiry(&mut self) {
let window_id = self.ctx.window().id();
let timer_id = TimerId::new(Topic::SshDeleteUndo, window_id);
let event = Event::new(EventType::SshDeleteUndoExpired, window_id);
let scheduler = self.ctx.scheduler_mut();
scheduler.unschedule(timer_id);
scheduler.schedule(
event,
crate::display::SSH_DELETE_UNDO_DURATION,
false,
timer_id,
);
}
/// Cancel expiry first so a late event cannot immediately dispose a host
/// that the user has just restored.
fn undo_ssh_delete(&mut self) -> bool {
let window_id = self.ctx.window().id();
self.ctx
.scheduler_mut()
.unschedule(TimerId::new(Topic::SshDeleteUndo, window_id));
self.ctx.display().undo_delete_ssh_host()
}
/// Advance the multi-click state machine for this press — exactly ONCE
/// per press, at the top of `on_mouse_press` (WT's `_numberOfClicks`
/// model). A press upgrades Click→Double→Triple only when it is the same
@@ -1028,6 +1094,51 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
state
}
fn run_context_menu_action(&mut self, action: crate::display::ContextMenuAction) {
use crate::display::ContextMenuAction::*;
match action {
DuplicateTab(index) => {
self.ctx.nebula_tab(crate::event::TabRequest::Duplicate(index));
},
SplitTabRight(index) => {
self.ctx.nebula_tab(crate::event::TabRequest::SplitIndex {
index,
direction: crate::display::SplitDirection::LeftRight,
});
},
SplitTabDown(index) => {
self.ctx.nebula_tab(crate::event::TabRequest::SplitIndex {
index,
direction: crate::display::SplitDirection::TopBottom,
});
},
RenameTab(index) => {
self.ctx.nebula_tab(crate::event::TabRequest::BeginRename(index));
},
CloseTab(index) => {
self.ctx.nebula_tab(crate::event::TabRequest::CloseIndex(index));
},
SetTabColor { index, color } => {
self.ctx.nebula_tab(crate::event::TabRequest::SetColor { index, color });
},
ConnectSsh(index) => {
let host = self.ctx.display().nebula_ssh_hosts.get(index).cloned();
if let Some(host) = host {
self.ctx.nebula_tab(crate::event::TabRequest::NewSsh(host));
}
},
CopySshAddress(index) => {
let host = self.ctx.display().nebula_ssh_hosts.get(index).cloned();
if let Some(host) = host {
self.ctx.clipboard_mut().store(ClipboardType::Clipboard, host);
}
},
EditSsh(index) => self.ctx.display().edit_ssh_host(index),
DeleteSsh(index) => self.ctx.display().request_delete_ssh_host(index),
}
self.ctx.mark_dirty();
}
fn on_mouse_press(&mut self, button: MouseButton) {
// Multi-click bookkeeping happens here and nowhere else. It used to
// be advanced both by the chrome block and the terminal block below;
@@ -1035,6 +1146,43 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
// to a double/triple — plain drags selected by word or whole line.
self.advance_click_state(button);
// Window chrome is deliberately optional at the input boundary. The
// terminal's click-state machine is useful and testable without a GPU
// window; real application contexts keep the default `true` value.
if self.ctx.nebula_chrome_active() {
// Non-primary presses also release lightweight menus. Right-click on
// a context menu is handled below so it can naturally retarget.
if button != MouseButton::Left {
if button != MouseButton::Right && self.ctx.display().context_menu_interactive() {
self.ctx.display().close_context_menu();
self.ctx.mark_dirty();
return;
}
if self.ctx.display().command_palette_picker_open() {
self.ctx.display().close_command_palette();
self.ctx.mark_dirty();
return;
}
if self.ctx.display().nebula_shell_picker_open {
self.ctx.display().close_shell_picker();
self.ctx.mark_dirty();
return;
}
}
if button == MouseButton::Left && self.ctx.display().context_menu_interactive() {
let x = self.ctx.mouse().x as f32;
let y = self.ctx.mouse().y as f32;
if let crate::display::ContextMenuHit::Action(action) =
self.ctx.display().context_menu_click(x, y)
{
self.run_context_menu_action(action);
}
self.ctx.mark_dirty();
return;
}
// A left press anywhere OUTSIDE the rename box ends the edit
// (canceling, like Esc) — the click itself still lands wherever it
// was aimed. Clicking inside the box is caret placement (below).
@@ -1065,7 +1213,7 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
let y = self.ctx.mouse().y as f32;
let size = self.ctx.display().size_info;
let scale = self.ctx.window().scale_factor as f32;
let with_input = !self.ctx.display().command_palette_picking_default();
let with_input = !self.ctx.display().command_palette_picker_open();
let layout = crate::display::command_palette::palette_layout(
size.width(),
size.height(),
@@ -1087,6 +1235,21 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
return;
}
// The reversible-action bar floats above drawer/chrome content. True
// modals still retain pointer ownership and therefore block this path.
let undo_pointer = (self.ctx.mouse().x as f32, self.ctx.mouse().y as f32);
if button == MouseButton::Left
&& self.ctx.display().nebula_confirm.is_none()
&& self
.ctx
.display()
.ssh_delete_undo_hit(undo_pointer.0, undo_pointer.1)
{
self.undo_ssh_delete();
self.ctx.mark_dirty();
return;
}
// Right-side drawer (directory tree / git): header tabs switch views,
// directory rows expand/collapse. Sits under the modal layers, so
// only when no modal owns the pointer.
@@ -1224,6 +1387,15 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
if button == MouseButton::Right {
let x = self.ctx.mouse().x as f32;
let y = self.ctx.mouse().y as f32;
let menu_was_open = self.ctx.display().context_menu_interactive();
if menu_was_open
&& !matches!(
self.ctx.display().context_menu_hit(x, y),
crate::display::ContextMenuHit::Outside
)
{
return;
}
match self.ctx.display().chrome_hit(x, y) {
crate::display::ChromeHit::NewTab => {
let profiles: Vec<String> =
@@ -1234,14 +1406,20 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
self.ctx.mark_dirty();
return;
},
crate::display::ChromeHit::Tab(index) => {
if index > 0 {
self.ctx.nebula_tab(crate::event::TabRequest::Move { from: index, to: 0 });
}
crate::display::ChromeHit::Tab(index)
| crate::display::ChromeHit::TabClose(index) => {
self.ctx.display().open_tab_context_menu(index, x, y);
self.ctx.mark_dirty();
return;
},
crate::display::ChromeHit::Host(_) => {
crate::display::ChromeHit::Host(index) => {
self.ctx.display().open_ssh_context_menu(index, x, y);
self.ctx.mark_dirty();
return;
},
_ if menu_was_open => {
self.ctx.display().close_context_menu();
self.ctx.mark_dirty();
return;
},
_ => {},
@@ -1284,7 +1462,8 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
let settings_scroll = self.ctx.display().settings_scroll();
let shell_picker_open = self.ctx.display().nebula_shell_picker_open;
let shell_picker_count = self.ctx.display().shell_picker_count();
match crate::display::settings_hit(
let hidden_host_count = self.ctx.display().hidden_ssh_host_count();
let settings_hit = crate::display::settings_hit(
&size,
scale,
x,
@@ -1294,7 +1473,22 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
settings_scroll,
shell_picker_open,
shell_picker_count,
) {
hidden_host_count,
);
if shell_picker_open
&& !matches!(
settings_hit,
crate::display::SettingsHit::ShellCycle
| crate::display::SettingsHit::ShellPickerRow(_)
)
{
// First outside click dismisses the picker without activating
// an unrelated control hidden behind its temporary focus scope.
self.ctx.display().close_shell_picker();
self.ctx.mark_dirty();
return;
}
match settings_hit {
crate::display::SettingsHit::Toggle => {
self.ctx.display().toggle_settings();
self.ctx.mark_dirty();
@@ -1331,6 +1525,11 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
self.ctx.mark_dirty();
return;
},
crate::display::SettingsHit::RestoreHiddenSsh(index) => {
self.ctx.display().restore_hidden_ssh_host(index);
self.ctx.mark_dirty();
return;
},
crate::display::SettingsHit::FetchToggle => {
self.ctx.display().toggle_fetch();
self.ctx.mark_dirty();
@@ -1407,11 +1606,6 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
self.ctx.mark_dirty();
return;
},
crate::display::ChromeHit::HostDelete(index) => {
self.ctx.display().delete_ssh_host(index);
self.ctx.mark_dirty();
return;
},
crate::display::ChromeHit::TabClose(index) => {
self.ctx.nebula_tab(crate::event::TabRequest::CloseIndex(index));
return;
@@ -1517,6 +1711,7 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
return;
}
}
}
// Nebula: right-click copies the selection, or pastes when there is
// none (Windows Terminal-style), unless the app is in mouse mode.
@@ -1924,6 +2119,16 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
}
pub fn on_focus_change(&mut self, is_focused: bool) {
if !is_focused {
// Lightweight pointer menus must never remain stranded over an
// inactive window. True modals intentionally survive focus loss.
self.ctx.display().close_context_menu();
self.ctx.display().close_shell_picker();
if self.ctx.display().command_palette_picker_open() {
self.ctx.display().close_command_palette();
}
self.ctx.mark_dirty();
}
if self.ctx.terminal().mode().contains(TermMode::FOCUS_IN_OUT) {
let chr = if is_focused { "I" } else { "O" };
@@ -2384,6 +2589,10 @@ mod tests {
unimplemented!();
}
fn nebula_chrome_active(&self) -> bool {
false
}
fn pop_message(&mut self) {
self.message_buffer.pop();
}
+2
View File
@@ -34,6 +34,8 @@ pub enum Topic {
/// in-box ConPTY repaints its whole viewport on every resize, so per-tick
/// notifications flood the scrollback with shredded repaints.
NebulaResizeSettle,
/// Grace-period timer for committing an SSH host deletion after Undo expires.
SshDeleteUndo,
}
/// Event scheduled to be emitted at a specific time.