checkpoint: complete SSH and input UX contracts
This commit is contained in:
1 parent
784817795e
commit
5513afd573
12 files changed
+2304
-205
No files matched your search
@@ -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 版本)。
|
||||
- 上轮遗留:设置面板标题层级(真粗体+字号梯度已上车,看效果再调)。
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
+720
-58
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user