[feat] anchor CLI zoom to mouse and simplify view shortcuts

This commit is contained in:
dijunkun
2026-11-02 15:32:16 +08:00
parent 6de7165fb0
commit 30d409ff49
12 changed files with 177 additions and 80 deletions
+7 -4
View File
@@ -157,7 +157,7 @@ CrossDesk 让你从电脑、浏览器、手机或终端连接远端电脑,也
| ANSI 字符画面 | Kitty 协议像素画面 |
| --- | --- |
| <img src="docs/images/cli-ansi-overview.png" width="480" alt="CrossDesk CLI 的 ANSI 四象限全景:顶部显示只读模式和连接状态,底部显示操作快捷键" /> | <img src="docs/images/cli-kitty-overview.png" width="480" alt="CrossDesk CLI 的 Kitty 协议全景:同一远程桌面以像素画面显示,保留状态栏和操作快捷键" /> |
| 用彩色字符显示桌面轮廓,适合普通终端;查看局部可按 `d` 放大。 | 直接显示像素画面,适合支持 Kitty 图形协议的终端,如 Ghostty。 |
| 用彩色字符显示桌面轮廓,适合普通终端;查看局部可用 `+/-` 或滚轮以鼠标位置缩放。 | 直接显示像素画面,适合支持 Kitty 图形协议的终端,如 Ghostty。 |
两图采集于 **2026-10-31**,来自同一授权设备的实际只读连接,以 128 × 40 字符视口还原 CLI 的 ANSI / Kitty 协议输出。图中 FPS 是采集时的实际输出计数,静止画面下 ANSI 无变化可显示 `FPS 0`。图示为输出渲染,终端实窗效果受字体、窗口大小及协议支持影响;采集方法见 [图示来源](docs/images/README.md#cli-captures)。
@@ -227,7 +227,7 @@ crossdesk-cli 123456789 --quality balanced --fps 8
| `--control` | 连接后直接启用键鼠控制;省略时默认只读 |
| `--server your-server.example --port 9099` | 使用自建信令服务,两端需接入同一服务 |
ANSI 全景受字符网格限制,提高画质不能让整屏小字变得清晰;可扩大终端、缩小字体,再按 `d` 查看局部,按 `f` 回到全景。顶部状态栏显示模式、连接进度、渲染器、缩放、实时输出 FPS 和网络信息,窄窗口会省略部分字段。通过 SSH 使用时分配 PTY(`ssh -t`);tmux / screen 使用 ANSI。更多显示、鼠标报告和兼容性说明见 [终端使用指南](apps/cli/docs/terminal-client.md)。
ANSI 全景受字符网格限制,提高画质不能让整屏小字变得清晰;可扩大终端、缩小字体,再用 `+/-` 或滚轮查看局部,按 `f` 回到全景并重置平移。顶部状态栏显示模式、连接进度、渲染器、缩放、实时输出 FPS 和网络信息,窄窗口会省略部分字段。通过 SSH 使用时分配 PTY(`ssh -t`);tmux / screen 使用 ANSI。更多显示、鼠标报告和兼容性说明见 [终端使用指南](apps/cli/docs/terminal-client.md)。
### 控制、查看与退出
@@ -237,10 +237,13 @@ ANSI 全景受字符网格限制,提高画质不能让整屏小字变得清晰
| 控制模式 | 键鼠输入传给远端;`Ctrl+]` 进入本地命令模式并暂停远端输入 |
| 本地命令模式 | 执行命令后持续保持;`c` 选择手动返回后是否启用控制,`q` 退出 CLI |
| `Esc` / `Ctrl+C` | 在本地命令模式中手动返回所选的控制或只读模式 |
| `+` / `=` / `-` | 在只读或本地命令模式中放大 / 缩小 |
| `d` / `f` | 在只读或本地命令模式中切换原始细节 / 完整桌面;只读时也可点击目标进入细节 |
| `+` / `=` / `-` | 在只读或本地命令模式中以鼠标位置放大 / 缩小 |
| 滚轮 / 点击 | 只读模式中,滚轮以鼠标位置缩放;点击目标进入原始细节 |
| `f` | 在只读或本地命令模式中恢复完整桌面,同时重置倍率、平移和细节模式 |
| 方向键 / `i j k l` | 在只读或本地命令模式中平移;`i` 上、`j` 左、`k` 下、`l` 右 |
鼠标下的画面内容在缩放时尽量保持原位,到达桌面边缘时限制裁剪范围;没有有效鼠标位置时沿用当前中心。本地命令模式中鼠标仅更新缩放锚点,点击和滚轮不执行视图操作。
**控制模式下退出:先按 `Ctrl+]`,再按 `q`。** 普通 `c`、`q` 和 `Ctrl+C` 在控制模式中会发送到远端。
`Ctrl+]` 模式不会因为缩放、平移或按 `c` 自动结束。状态栏中的 `CONTROL PAUSED` / `READ ONLY` 表示返回后是否启用控制;按 `Esc` / `Ctrl+C` 手动返回。再次按 `Ctrl+]` 也可返回,若返回控制模式则向远端发送一次该组合键。
+7 -4
View File
@@ -157,7 +157,7 @@ Remote sessions support monitor switching, video quality and frame-rate controls
| ANSI character rendering | Kitty pixel graphics |
| --- | --- |
| <img src="docs/images/cli-ansi-overview.png" width="480" alt="CrossDesk CLI ANSI quadrant overview, with read-only mode and connection status at the top and shortcuts at the bottom" /> | <img src="docs/images/cli-kitty-overview.png" width="480" alt="CrossDesk CLI Kitty protocol overview of the same remote desktop, rendered as pixels with the status bar and shortcuts" /> |
| Colored characters show the desktop in standard terminals; press `d` to inspect a smaller region. | Pixel graphics display the desktop in terminals supporting the Kitty graphics protocol, such as Ghostty. |
| Colored characters show the desktop in standard terminals; use `+/-` or the wheel to zoom at the mouse position. | Pixel graphics display the desktop in terminals supporting the Kitty graphics protocol, such as Ghostty. |
Both images were captured on **2026-10-31** from real read-only connections to the same authorized host. They render the CLI's ANSI / Kitty protocol output in a 128 × 40 character viewport. FPS counts actual output during capture; an unchanged ANSI view can show `FPS 0`. Actual terminal appearance depends on the font, window size, and protocol support. See [capture notes](docs/images/README.md#cli-captures) for the method.
@@ -227,7 +227,7 @@ crossdesk-cli 123456789 --quality balanced --fps 8
| `--control` | Enable keyboard/mouse control immediately after connecting; omitted by default for read-only viewing |
| `--server your-server.example --port 9099` | Use a self-hosted signaling service; both ends must use the same service |
ANSI overviews are limited by the character grid: higher video quality alone cannot make full-desktop text legible. Enlarge the terminal or reduce its font size, press `d` for detail, and `f` to return to the full desktop. The top bar shows input mode, connection progress, renderer, zoom, live output FPS, and network information; narrow windows omit some fields. Allocate a PTY over SSH (`ssh -t`), and use ANSI inside tmux / screen. See the [terminal guide](apps/cli/docs/terminal-client.md) (Chinese) for rendering, mouse reporting, and compatibility details.
ANSI overviews are limited by the character grid: higher video quality alone cannot make full-desktop text legible. Enlarge the terminal or reduce its font size, use `+/-` or the wheel for detail, and `f` to restore the full desktop and reset panning. The top bar shows input mode, connection progress, renderer, zoom, live output FPS, and network information; narrow windows omit some fields. Allocate a PTY over SSH (`ssh -t`), and use ANSI inside tmux / screen. See the [terminal guide](apps/cli/docs/terminal-client.md) (Chinese) for rendering, mouse reporting, and compatibility details.
### Control, navigation, and exit
@@ -237,10 +237,13 @@ ANSI overviews are limited by the character grid: higher video quality alone can
| Control mode | Keyboard/mouse input goes to the host; `Ctrl+]` enters local command mode and pauses remote input |
| Local command mode | Stays active after commands; `c` selects whether control resumes when you leave; `q` exits the CLI |
| `Esc` / `Ctrl+C` | Manually leave local command mode and return to the selected control or read-only mode |
| `+` / `=` / `-` | Zoom in / out in read-only or local command mode |
| `d` / `f` | Switch to native detail / full-desktop view in read-only or local command mode; clicking in read-only mode also opens detail at that point |
| `+` / `=` / `-` | Zoom in / out at the mouse position in read-only or local command mode |
| Wheel / click | In read-only mode, zoom at the mouse position with the wheel; click a point to open native detail |
| `f` | Restore the full desktop and reset zoom, pan, and native detail mode in read-only or local command mode |
| Arrow keys / `i j k l` | Pan in read-only or local command mode: `i` up, `j` left, `k` down, `l` right |
Zoom keeps the content under the pointer in place where possible, clamping the crop at desktop edges. Without a valid mouse position, zoom uses the current center. In local command mode, mouse reports only update the zoom anchor; clicks and the wheel do not change the view.
**To quit while controlling the host, press `Ctrl+]`, then `q`.** Ordinary `c`, `q`, and `Ctrl+C` go to the host in control mode.
Zooming, panning, or pressing `c` does not end `Ctrl+]` mode. The status bar shows `CONTROL PAUSED` / `READ ONLY` to indicate whether control will resume; press `Esc` / `Ctrl+C` to return manually. Pressing `Ctrl+]` again also leaves local mode and forwards that shortcut if control resumes.
+7 -3
View File
@@ -66,13 +66,17 @@ crossdesk-cli 123456789 --display 1
| 操作 | 按键 |
| --- | --- |
| 开启远端控制 | 只读模式下按 `c` |
| 放大局部 / 回到全景 | 只读或本地命令模式下按 `d` / `f`;只读时也可点击目标进入细节 |
| 缩放 / 平移 | 只读或本地命令模式下用 `+/-` 或滚轮缩放;方向键或 `i` 上、`j` 左、`k` 下、`l` 右平移 |
| 缩放 | 只读或本地命令模式下用 `+` / `=` / `-` 以鼠标位置缩放;只读时也可用滚轮 |
| 回到全景 | 只读或本地命令模式下按 `f`,同时重置倍率、平移和细节模式 |
| 平移 | 只读或本地命令模式下用方向键或 `i` 上、`j` 左、`k` 下、`l` 右 |
| 原始细节 | 只读时点击目标位置,或以 `--zoom native` 启动 |
| 暂停远端输入并进入本地命令模式 | `Ctrl+]` |
| 选择返回后的控制状态 | 本地命令模式下按 `c`,再按 `Esc` / `Ctrl+C` 返回 |
| 退出连接 | 只读模式下按 `q` / `Ctrl+C`;控制模式下先按 `Ctrl+]`,再按 `q` |
本地命令模式在缩放、平移和按 `c` 后持续保持,直到手动返回。控制模式中的 `Ctrl+T`、`Ctrl+C` 和普通按键均传给远端。再次按 `Ctrl+]` 也可离开本地命令模式,若返回控制模式则同时向远端发送该组合键。
缩放尽量保持鼠标下的画面内容原位;没有有效鼠标位置时沿用当前中心。底部提示随模式和窗口宽度调整,`Zoom at mouse` 表示鼠标锚点缩放,`Full view` / `Full` 表示恢复全景。
本地命令模式在缩放、平移和按 `c` 后持续保持,直到手动返回。此模式中鼠标仅更新缩放锚点,点击和滚轮不执行视图操作。控制模式中的 `Ctrl+T`、`Ctrl+C` 和普通按键均传给远端。再次按 `Ctrl+]` 也可离开本地命令模式,若返回控制模式则同时向远端发送该组合键。
## 更多文档与功能范围
+1 -1
View File
@@ -12,7 +12,7 @@
| connect | device:9位数字字符串, display:0..63(默认0) | 从专用 password fd 读取至换行/EOF(最多256字节、等待5秒),开始异步注册/连接;ok不是远端已连接 |
| status | 无 | 返回当前状态 |
| control | enabled:boolean | 显式启停控制;关闭时释放输入 |
| input | data:string,最多16KiB | 终端原始按键/UTF-8/bracketed paste/SGR鼠标;只读时 c 开启控制、q/Ctrl+C 退出,d/鼠标点击进入细节,+/-/滚轮缩放,方向键或 i 上、j 左、k 下、l 右平移,f 全景;Ctrl+] 打开本地命令,随后 c 选择返回后的控制状态、q 退出或视图键连续调整画面,Esc/Ctrl+C 手动返回;控制模式中 Ctrl+T/Ctrl+C 传给远端,本地模式中再按 Ctrl+] 手动返回,并在返回控制模式时传递远端 Ctrl+] |
| input | data:string,最多16KiB | 终端原始按键/UTF-8/bracketed paste/SGR鼠标;只读时 c 开启控制、q/Ctrl+C 退出,鼠标点击进入细节,+/-/滚轮以鼠标位置缩放,方向键或 i 上、j 左、k 下、l 右平移,f 恢复全景并重置倍率与平移;Ctrl+] 打开本地命令,随后 c 选择返回后的控制状态、q 退出或视图键连续调整画面,Esc/Ctrl+C 手动返回;控制模式中 Ctrl+T/Ctrl+C 传给远端,本地模式中再按 Ctrl+] 手动返回,并在返回控制模式时传递远端 Ctrl+] |
| display | index:0..63 | 选择已广告的零起始显示器,提升 generation |
| ack | frame_id、generation:非负整数 | 确认宿主消费帧;匹配当前等待帧才开放下一帧 |
| disconnect | 无 | 释放输入、结束当前媒体会话 |
+7 -6
View File
@@ -57,11 +57,11 @@ Windows 的可见传统控制台优先读取当前字体尺寸;Windows Termina
缩放在完整输出边界清理旧画面。上限 240 列、100 画面行;默认顶部预留一行状态栏、底部预留两行快捷键,另预留一列避免自动换行。顶部状态栏用反色区分,左侧为控制模式和连接进度,右侧为画面参数及网络信息;底部两行分别为会话和视图快捷键。视频和等待提示从顶部状态栏下方开始,鼠标坐标按视频区域映射,点击状态栏不会传给远端。按键分组使用统一列宽和左右留白,窄窗口自动减少列数、合并按键组并省略次要参数,累计帧数和输出字节数保留在日志中。控制模式下仅提示本地命令入口及后续操作,进入本地命令模式后才显示平移和缩放按键;终端高度不足时优先收起底部提示行。
快捷键行使用 `KEYS |`、`VIEW |` 等固定宽度标题,标题和按键组之间用竖线分隔;键名用 `<c>`、`<+/->` 等标签标识。宽窗口中的分组分隔线按列对齐,窄窗口保留标题和完整按键标签,按可用宽度合并或减少分组。
快捷键行使用 `KEYS |`、`VIEW |` 等固定宽度标题,标题和按键组之间用竖线分隔;键名用 `<c>`、`<+/->` 等标签标识。`Zoom at mouse` 表示鼠标锚点缩放,`Full view` / `Full` 表示恢复全景,`Pan` 表示平移。只读模式提示滚轮缩放,宽窗口还显示点击进入细节;本地命令模式提示返回、控制切换及键盘视图操作,宽窗口显示鼠标选择锚点和 `=` 放大。宽窗口中的分组分隔线按列对齐,窄窗口保留完整按键标签,优先保留缩放和全景,按可用宽度合并或减少分组。
顶部状态栏以整数显示实时帧率(例如 `FPS 30`),约每秒按最近统计区间内完整输出到终端的帧数计算;没有新画面输出时更新为 `FPS 0`。该数值是实际提交速率,不是 `--fps` 目标值或终端 GPU 呈现频率;窄窗口优先省略画面参数和网络信息,保留帧率。
ANSI 在完整桌面缩略图中仍无法保留普通字号的文字。先增加终端列数(缩小终端字体或扩大窗口),阅读局部时用 `--zoom native`。只读全景中点击目标位置可直接进入该区域的细节模式;按 `d` 则以当前中心进入。细节模式依据视口和字符宽高比选择源区域,避免为了完整装入桌面而把文字缩小;ANSI 视野较小,借助方向键平移阅读。`--zoom 1..32` 是相对整桌面的倍率;在细节模式中 `+/-` 是相对原始细节尺寸的倍率。画面缩放完全在本地进行,控制鼠标坐标映射回原始桌面的正确区域。
ANSI 在完整桌面缩略图中仍无法保留普通字号的文字。先增加终端列数(缩小终端字体或扩大窗口),阅读局部时用 `--zoom native`。只读时可用 `+/-` 或滚轮以鼠标位置缩放,也可点击目标位置直接进入该区域的细节模式。细节模式依据视口和字符宽高比选择源区域,避免为了完整装入桌面而把文字缩小;ANSI 视野较小,借助方向键平移阅读。`--zoom 1..32` 是相对整桌面的倍率;在细节模式中 `+/-` 是相对原始细节尺寸的倍率。画面缩放完全在本地进行,控制鼠标坐标映射回原始桌面的正确区域。
auto 模式发送 Kitty 查询与主设备属性查询(DA1),等待最多 250ms;优先选择 Kitty,否则在 DA1 的能力参数中找到 `4` 时选择 Sixel,再回退为 ANSI。显式 `kitty` 或 `sixel` 模式探测失败时报错,不强制向未知终端输出图像。tmux/screen 中 auto 使用 ANSI,显式像素模式报不支持;不修改用户终端配置。
@@ -127,12 +127,13 @@ macOS 自带 Terminal 还需要在当前窗口勾选“显示 → 允许鼠标
| 退出 CLI | `q` 或 `Ctrl+C` | `Ctrl+]`,再按 `q` |
| 进入本地命令模式 | `?` 或 `Ctrl+]` | `Ctrl+]` |
| 放大/缩小 | `+` / `=` / `-` | `Ctrl+]`,再按对应键 |
| 原始细节/完整画面 | `d`(detail)/ `f`(fit),相邻字母键 | `Ctrl+]`,再按对应键 |
| 恢复完整画面 | `f`,重置倍率、平移和细节模式 | `Ctrl+]`,再按 `f` |
| 鼠标查看 | 滚轮以鼠标位置缩放;点击进入原始细节 | 进入本地命令模式后仅更新缩放锚点,使用缩放键调整 |
| 平移 | 方向键或 `i` 上、`j` 左、`k` 下、`l` 右 | `Ctrl+]`,再按对应键 |
进入本地命令模式时暂停远端输入,并释放已记录按键/鼠标。执行缩放、平移、`c` 或未知命令后均保持此模式;`c` 切换手动返回后是否启用远端控制,状态栏显示 `CONTROL PAUSED` 或 `READ ONLY`。`Esc` 或 `Ctrl+C` 手动返回所选模式;`q` 退出 CLI。再次按 `Ctrl+]` 也会返回,并在所选模式为控制时向远端发送一次 `Ctrl+]`。命令模式中的鼠标事件被忽略,bracketed paste 内容被丢弃,均不会退出模式或触发快捷键。窗口失焦或输入缓冲区溢出时继续保持本地命令模式,但将返回模式设为只读。控制模式下普通 `c/q/?`、`Ctrl+T`、`Ctrl+C` 和单独 `Esc` 均传给远端。目前不支持重绑定。
进入本地命令模式时暂停远端输入,并释放已记录按键/鼠标。执行缩放、平移、`c` 或未知命令后均保持此模式;`c` 切换手动返回后是否启用远端控制,状态栏显示 `CONTROL PAUSED` 或 `READ ONLY`。`Esc` 或 `Ctrl+C` 手动返回所选模式;`q` 退出 CLI。再次按 `Ctrl+]` 也会返回,并在所选模式为控制时向远端发送一次 `Ctrl+]`。命令模式中的鼠标事件仅更新本地缩放锚点,不执行点击、拖动或滚轮操作;bracketed paste 内容被丢弃,均不会退出模式或触发快捷键。窗口失焦或输入缓冲区溢出时继续保持本地命令模式,但将返回模式设为只读。控制模式下普通 `c/q/?`、`Ctrl+T`、`Ctrl+C` 和单独 `Esc` 均传给远端。目前不支持重绑定。
只读时,`d` 或在画面中单击进入该位置的原始细节模式;`+` / `=` 放大、`-` 缩小(1~32倍),滚轮以鼠标指向为中心调整倍率。方向键或 `i/j/k/l` 分别向上/左/下/右平移,`f` 恢复完整画面。黑边点击被忽略。这些按键和鼠标在控制模式下仍传给远端;进入本地命令模式后可连续调整视图,直至手动返回。放大、平移和只读鼠标定位不会向远端发送操作,也适用于静止画面。
只读时,在画面中单击进入该位置的原始细节模式;`+` / `=` 放大、`-` 缩小(1~32倍)。缩放键与滚轮均以鼠标位置为锚点,尽量保持鼠标下的画面内容在原位置;到达桌面边缘时限制裁剪范围。没有收到鼠标位置,或鼠标位于黑边、状态栏时,缩放键沿用当前中心;窗口失焦会清除鼠标位置。方向键或 `i/j/k/l` 分别向上/左/下/右平移,`f` 恢复完整画面并重置倍率、平移和细节模式。黑边点击被忽略。这些按键和鼠标在控制模式下仍传给远端;进入本地命令模式后可移动鼠标选择锚点并连续按缩放键调整视图,直至手动返回。放大、平移和只读鼠标定位不会向远端发送操作,也适用于静止画面。
支持 SGR 和传统 `CSI M` 鼠标报告,包括移动、左右中键、拖动和滚轮;完整缓冲跨读取分片的传统报告,坐标字节不会作为按键执行。启动时依次启用普通、拖动和全移动报告,兼容只支持部分跟踪模式的终端;退出时关闭这些模式。传统报告的坐标上限为 223,较大窗口应使用支持 SGR 的终端。协议格式依据 [XTerm 鼠标协议](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-Mouse-Tracking)。
@@ -155,7 +156,7 @@ export CROSSDESK_CLI="$PWD/build/macosx/arm64/release/crossdesk-cli"
pi -e ./apps/cli/plugins/pi/index.ts
```
在 Pi 输入 `/crossdesk` 或 `/crossdesk 123456789`。先输入 ID,再在自定义遮蔽组件输入密码。扩展使用独立 fd 3 写密码,随后关闭;不写配置、聊天、工具结果或模型上下文。查看组件与独立 CLI 使用相同的 `Ctrl+]` 命令前缀和上述模式按键,由 bridge 统一解析。插件默认请求 high 预览并显示 ANSI 象限字符行,支持同样的只读细节/放大/平移键,颜色深度依据终端环境回退,由 Pi 自身布局和刷新;不直接占用 TTY,不发送 Kitty 图形或自行打开鼠标报告。
在 Pi 输入 `/crossdesk` 或 `/crossdesk 123456789`。先输入 ID,再在自定义遮蔽组件输入密码。扩展使用独立 fd 3 写密码,随后关闭;不写配置、聊天、工具结果或模型上下文。查看组件与独立 CLI 使用相同的 `Ctrl+]` 命令前缀和上述模式按键,由 bridge 统一解析。插件默认请求 high 预览并显示 ANSI 象限字符行,支持同样的缩放、全景和平移键,颜色深度依据终端环境回退,由 Pi 自身布局和刷新;不直接占用 TTY,不发送 Kitty 图形或自行打开鼠标报告。
Pi 扩展读取宿主的字符尺寸,并通过宿主终端写入/输入监听接口查询字符尺寸或完整文本区尺寸,每两秒及窗口变化时刷新;尺寸回复不会传给远端。得到的宽高比通过 bridge 的 `viewport.cell_aspect` 传入,比例变化时重绘静止画面,并丢弃仍在途的旧比例帧。自动检测不可用或不准确时,可在启动 Pi 前设置环境变量 `CROSSDESK_CELL_ASPECT=0.45`(宽/高,允许 0.2~2;`auto` 恢复自动检测)。视图关闭或 bridge 停止时移除查询定时器和输入监听。
+9 -2
View File
@@ -22,12 +22,19 @@
新增回归代码、基准、假传输和验证产物均位于工程外临时目录,未向工程或子模块添加测试。此次完整构建与运行验证覆盖 macOS arm64,未重新进行 Linux/Windows 实机验证。
## 控制按键调整(2026-10-30)
## 鼠标锚点缩放与快捷键提示(2026-11-02)
- 移除 `d` 本地快捷键,保留鼠标点击进入细节及 `--zoom native`。`f` 恢复完整桌面并重置倍率、平移和细节模式;`+/-`、`=` 和只读滚轮使用鼠标锚点缩放。
- 工程外 ASan/UBSan 检查通过 900 次缩放几何检查,覆盖 ASCII、ANSI half/quadrant、Kitty/Sixel 视口、全景/细节、连续输入、边缘限制、SGR/传统鼠标报告、失焦与模式切换;确认 `d` 不再改变本地视图,控制模式仍转发普通字母。
- 四种控制/本地模式组合、1~240 列共 960 组状态栏布局检查通过,覆盖完整按键标签、缩放/全景提示及模式可用操作。Pi 提示通过 Node.js 语法检查;没有进行真实 Pi TUI 或远端设备连接验证。
- 新增验证代码及产物均保存在工程外临时目录。
## 控制按键调整(2026-10-30,历史记录)
- 当前桌面与 CLI 同时启用的 debug 配置下,`xmake build crossdesk-cli`、`xmake run crossdesk-cli doctor --json` 和命令帮助检查通过。
- 工程外 ASan/UBSan 检查覆盖本地命令前缀、只读快捷键、远端 Ctrl+T/Ctrl+C/Ctrl+]、鼠标释放、取消命令、粘贴隔离、UTF-8/CSI 分片、失焦与退出;原有会话/渲染检查通过。
- 平移字母键采用 `i` 上、`j` 左、`k` 下、`l` 右;工程外 ASan/UBSan 检查确认四个方向与普通/应用模式方向键一致,控制模式中普通字母仍传给远端。
- 细节/全景采用相邻的 `d`/`f`;工程外 ASan/UBSan 检查确认细节保留中心、全景复位缩放与中心、前缀命令和鼠标点击行为正确,控制模式中普通字母仍传给远端。
- 当时细节/全景采用相邻的 `d`/`f`(`d` 已于 2026-11-02 移除);工程外 ASan/UBSan 检查确认细节保留中心、全景复位缩放与中心、前缀命令和鼠标点击行为正确,控制模式中普通字母仍传给远端。
- 实际二进制 bridge 验证命令状态、错误恢复和退出响应;生产 view 配合假传输的 PTY 检查验证快捷键、模式提示、隐藏密码和终端恢复。实际二进制的 bridge 与 PTY 回归同样通过。
- Pi 1.4.0 SDK 严格类型检查和模拟组件运行通过,覆盖控制按键转发、命令提示、取消、缩放、退出与 bridge 启动失败后的关闭操作。
+5 -2
View File
@@ -608,7 +608,7 @@ int View(SessionOptions options, const std::string &device,
: sixel ? "SIXEL"
: detail == AnsiDetail::Quadrant ? "QUAD"
: "HALF") +
(input.native ? " | Detail x" : " | Fit x") +
(input.native ? " | Detail x" : " | Zoom x") +
std::to_string(int(input.zoom)) +
(last_frame ? " | " + std::to_string(last_frame->width) + "x" +
std::to_string(last_frame->height)
@@ -1038,7 +1038,10 @@ crossdesk-cli bridge --stdio [--password-fd 3] [--server host] [--port 9099]
Connect by device ID: read-only and silent by default; hidden password prompt on the local terminal.
Read-only: c enables control; q or Ctrl+C quits; ? shows local commands.
Ctrl+] enters persistent local mode: c selects control on return; q quits; Esc/Ctrl+C returns.
View keys: click/d native detail, wheel/+/- zoom, arrows/ijkl pan (i up, j left, k down, l right), f fit.
View keys (read-only/local): +/- zoom at mouse (= also zooms in); f restores the full desktop and resets pan.
Pan: arrows or i up, j left, k down, l right. Read-only mouse: wheel zooms at pointer; click opens native detail.
Local mode: move the mouse to choose the zoom anchor; clicks and wheel do not change the view.
Zoom uses the current center when no mouse position is available or the pointer is outside the picture.
Local mode stays active after commands, paste or focus loss; remote input remains paused.
Ctrl+T and Ctrl+C pass through in control mode; another Ctrl+] leaves local mode and forwards Ctrl+] if control is enabled.
Default ANSI detail: quadrant. Default quality: high (1920x1080); ultra requests up to 3840x2160.
+3 -3
View File
@@ -227,10 +227,10 @@ export default function crossdesk(pi: ExtensionAPI) {
const ack = frame; frame = undefined;
if (ack) queueMicrotask(() => send("ack", ack)); // Host consumed the frame; not a physical display timestamp.
const hint = bridgeStopped ? "q / Esc / Ctrl+C close"
: commandMode ? "Esc/Ctrl+C return | c toggle q quit | arrows/ijkl pan +/- zoom f fit d detail"
: commandMode ? "Esc/Ctrl+C return | c toggle | q quit | +/- zoom | f full | arrows/ijkl pan"
: control ? "Keys -> remote | Ctrl+] then c release, q quit"
: "c control q quit ? help | i up j left k down l right | +/- zoom f fit d detail";
return [truncateToWidth(`CrossDesk ${device} | ${status} ${native ? "detail" : "fit"} x${zoom} | Ctrl+] commands`, width),
: "c control | q quit | +/- zoom | f full | arrows/ijkl pan | ? commands";
return [truncateToWidth(`CrossDesk ${device} | ${status} ${native ? "detail" : "zoom"} x${zoom} | Ctrl+] commands`, width),
...lines.slice(0, h).map(line => truncateToWidth(line, width)),
truncateToWidth(`${hint}${error ? " | " + error : lines.length ? "" : " | Waiting for remote video…"}`, width)];
},
@@ -58,6 +58,8 @@ struct Grid {
Rect Fit(int width, int height, int columns, int pixel_rows,
double pixel_aspect = 1);
Grid Layout(const DecodedFrame &, Viewport);
// Calculate crop and pointer geometry without decoded pixel storage.
Grid Layout(unsigned source_width, unsigned source_height, Viewport);
Grid Convert(const DecodedFrame &, Viewport);
std::optional<std::pair<float, float>> MapPointer(const Grid &, int column,
int row);
@@ -119,9 +121,11 @@ private:
bool pasting_ = false;
unsigned mouse_buttons_ = 0;
float pointer_x_ = .5f, pointer_y_ = .5f;
int mouse_column_ = -1, mouse_row_ = -1;
void Tap(ControllerSession &, int key, int modifier = 0);
bool LocalKey(int key, ControllerSession &, const Grid *);
void Navigate(char key, const Grid *);
void Zoom(double next_zoom, const Grid *);
void Mouse(int button, int x, int y, bool release, ControllerSession &,
const Grid *);
};
+58 -14
View File
@@ -45,8 +45,7 @@ bool Input::LocalKey(int key, ControllerSession &s, const Grid *grid) {
} else if (key == 'c') {
control = !control;
} else if (key == '+' || key == '=' || key == '-' || key == 'f' ||
key == 'd' || key == 'i' || key == 'j' || key == 'k' ||
key == 'l') {
key == 'i' || key == 'j' || key == 'k' || key == 'l') {
Navigate(char(key), grid);
} else if (!command_mode) {
return false;
@@ -58,20 +57,52 @@ bool Input::LocalKey(int key, ControllerSession &s, const Grid *grid) {
mouse_buttons_ = 0;
return true;
}
void Input::Zoom(double next_zoom, const Grid *grid) {
if (next_zoom == zoom)
return;
if (grid && mouse_column_ >= 0 && mouse_row_ >= 0) {
auto v = grid->viewport;
v.zoom = zoom;
v.native = native;
v.center_x = center_x;
v.center_y = center_y;
// Rebuild from the requested view so queued key/wheel repeats compose
// even when the terminal is still displaying an earlier frame.
const auto before = Layout(grid->source_width, grid->source_height, v);
if (const auto anchor = MapPointer(before, mouse_column_, mouse_row_)) {
v.zoom = next_zoom;
const auto after = Layout(grid->source_width, grid->source_height, v);
if (const auto moved = MapPointer(after, mouse_column_, mouse_row_)) {
const auto center = [](int origin, int size, unsigned extent,
double shift) {
const double half = size * .5 / extent;
return std::clamp((origin + size * .5 + shift * (extent - 1)) /
extent,
half, 1 - half);
};
// Use the actual, clamped crop center and the same source-pixel
// mapping as remote input. Letterboxing and status rows stay aligned.
center_x = center(after.source.x, after.source.width,
after.source_width,
double(anchor->first) - moved->first);
center_y = center(after.source.y, after.source.height,
after.source_height,
double(anchor->second) - moved->second);
}
}
}
zoom = next_zoom;
}
void Input::Navigate(char key, const Grid *grid) {
if (key == '+' || key == '=')
zoom = std::min(32.0, zoom * 2);
Zoom(std::min(32.0, zoom * 2), grid);
if (key == '-')
zoom = std::max(1.0, zoom / 2);
Zoom(std::max(1.0, zoom / 2), grid);
if (key == 'f') {
native = false;
zoom = 1;
center_x = center_y = .5;
}
if (key == 'd') {
native = true;
zoom = 1;
}
// Use the displayed crop as the pan step, including native pixel mode.
const double dx = grid && grid->source_width
? double(grid->source.width) / grid->source_width
@@ -99,14 +130,26 @@ void Input::Mouse(int b, int x, int y, bool release, ControllerSession &s,
const Grid *grid) {
++mouse_reports;
// Extra mouse buttons must not alias the three supported remote buttons.
if (!grid || command_mode || b < 0 || b >= 128)
if (b < 0 || b >= 128 || x <= 0 || y <= 0)
return;
auto pos = MapPointer(*grid, x - 1, y - 1);
// Track terminal coordinates even in local command mode. Re-map them for
// every zoom; a stationary mouse must follow subsequent view changes.
mouse_column_ = x - 1;
mouse_row_ = y - 1;
if (!grid || command_mode)
return;
auto pos = MapPointer(*grid, mouse_column_, mouse_row_);
if (!control) {
if (pos && !release && ((b & 64) || b == 0)) {
center_x = pos->first;
center_y = pos->second;
Navigate((b & 64) ? ((b & 1) ? '-' : '+') : 'd', grid);
if (pos && !release) {
if (b & 64) {
Navigate((b & 1) ? '-' : '+', grid);
} else if (b == 0) {
center_x = pos->first;
center_y = pos->second;
native = true;
zoom = 1;
++view_revision;
}
}
return;
}
@@ -248,6 +291,7 @@ void Input::Feed(const std::string &bytes, ControllerSession &s,
control = false;
s.SetControl(false);
mouse_buttons_ = 0;
mouse_column_ = mouse_row_ = -1;
continue;
}
int key = 0;
+24 -14
View File
@@ -25,21 +25,25 @@ static uint32_t Pixel(const DecodedFrame &f, int x, int y) {
(clamp((298 * yy - 100 * u - 208 * v + 128) >> 8) << 8) |
clamp((298 * yy + 516 * u + 128) >> 8);
}
static void Validate(const DecodedFrame &f, const Viewport &v) {
static void Validate(unsigned width, unsigned height, const Viewport &v) {
if (v.columns < 1 || v.rows < 1 || v.columns > 240 || v.rows > 100 ||
v.row_offset < 0 || v.row_offset > 100 ||
!std::isfinite(v.cell_aspect) || v.cell_aspect < .2 ||
v.cell_aspect > 2 || !std::isfinite(v.zoom) || v.zoom < 1 ||
v.zoom > 32 || !std::isfinite(v.center_x) || !std::isfinite(v.center_y) ||
v.center_x < 0 || v.center_x > 1 || v.center_y < 0 || v.center_y > 1 ||
!f.width || !f.height || f.width > 8192 || f.height > 8192 ||
(f.width & 1) || (f.height & 1) ||
size_t(f.width) * f.height > 16777216 ||
f.nv12.size() < size_t(f.width) * f.height * 3 / 2)
!width || !height || width > 8192 || height > 8192 || (width & 1) ||
(height & 1) || size_t(width) * height > 16777216)
throw std::invalid_argument("invalid frame or viewport");
}
static Rect Crop(const DecodedFrame &f, const Viewport &v) {
double width = f.width, height = f.height;
static void Validate(const DecodedFrame &f, const Viewport &v) {
Validate(f.width, f.height, v);
if (f.nv12.size() < size_t(f.width) * f.height * 3 / 2)
throw std::invalid_argument("invalid frame or viewport");
}
static Rect Crop(unsigned source_width, unsigned source_height,
const Viewport &v) {
double width = source_width, height = source_height;
if (v.native) {
const int sx = v.utf8 && v.detail == AnsiDetail::Quadrant ? 2 : 1;
const int sy = v.utf8 ? 2 : 1;
@@ -60,8 +64,10 @@ static Rect Crop(const DecodedFrame &f, const Viewport &v) {
}
int w = std::max(1, int(width / v.zoom)),
h = std::max(1, int(height / v.zoom));
int x = std::clamp(int(v.center_x * f.width - w * .5), 0, int(f.width) - w);
int y = std::clamp(int(v.center_y * f.height - h * .5), 0, int(f.height) - h);
int x = std::clamp(int(v.center_x * source_width - w * .5), 0,
int(source_width) - w);
int y = std::clamp(int(v.center_y * source_height - h * .5), 0,
int(source_height) - h);
return {x, y, w, h};
}
// Area resampling integrates the entire source footprint. Thin lines cannot
@@ -249,16 +255,20 @@ static void AppendNumber(std::string &out, uint32_t value) {
}
Grid Layout(const DecodedFrame &f, Viewport v) {
Validate(f, v);
return Layout(f.width, f.height, v);
}
Grid Layout(unsigned source_width, unsigned source_height, Viewport v) {
Validate(source_width, source_height, v);
const int sx = v.utf8 && v.detail == AnsiDetail::Quadrant ? 2 : 1,
sy = v.utf8 ? 2 : 1;
const int width = v.columns * sx, height = v.rows * sy;
Rect crop = Crop(f, v);
Rect crop = Crop(source_width, source_height, v);
Grid g{v,
Fit(crop.width, crop.height, width, height, v.cell_aspect * sy / sx),
{}};
g.source = crop;
g.source_width = f.width;
g.source_height = f.height;
g.source_width = source_width;
g.source_height = source_height;
g.samples_x = sx;
g.samples_y = sy;
return g;
@@ -553,7 +563,7 @@ PixelFrame ImagePixels(const DecodedFrame &f, const Viewport &requested) {
pixels.height =
std::clamp(v.pixel_height > 0 ? v.pixel_height : v.rows * 20, 2, 1080);
pixels.rgb.resize(size_t(pixels.width) * pixels.height * 3, 0);
const auto crop = Crop(f, v);
const auto crop = Crop(f.width, f.height, v);
const auto r = Fit(crop.width, crop.height, pixels.width, pixels.height,
double(v.columns) * v.cell_aspect * pixels.height /
(v.rows * pixels.width));
@@ -678,7 +688,7 @@ std::string Kitty(const DecodedFrame &f, const Viewport &requested,
std::clamp(v.pixel_width > 0 ? v.pixel_width : v.columns * 10, 2, 1920);
int h =
std::clamp(v.pixel_height > 0 ? v.pixel_height : v.rows * 20, 2, 1080);
const auto crop = Crop(f, v);
const auto crop = Crop(f.width, f.height, v);
std::string b64;
for (;;) {
auto r = Fit(crop.width, crop.height, w, h,
+45 -27
View File
@@ -79,41 +79,52 @@ std::vector<std::string> StatusLines(const StatusBar &bar, int columns) {
std::string keys, view;
if (inner >= 63) {
const size_t count = inner >= 99 ? 4 : inner >= 77 ? 3 : 2;
const size_t count = inner >= 99 ? 4 : inner >= 89 ? 3 : 2;
if (bar.command_mode) {
keys =
Columns("LOCAL",
count >= 3 ? std::vector<std::string>{"<Esc/Ctrl+C> Return",
"<c> Toggle control",
"<q> Quit"}
: std::vector<std::string>{"<Esc/Ctrl+C> Return",
"<c> Toggle; <q> Quit"},
inner, count);
keys = Columns(
"LOCAL",
count == 4
? std::vector<std::string>{"<Esc/Ctrl+C> Return",
"<c> Toggle control", "<q> Quit",
"<mouse> Set anchor"}
: count == 3
? std::vector<std::string>{"<Esc/Ctrl+C> Return",
"<c> Toggle control", "<q> Quit"}
: std::vector<std::string>{"<Esc/Ctrl+C> Return",
"<c> Toggle; <q> Quit"},
inner, count);
} else if (bar.control) {
keys = Columns("KEYS", {"<Ctrl+]> Commands", "Input -> remote"}, inner,
count);
} else {
keys = Columns("KEYS",
count >= 3
? std::vector<std::string>{"<c> Control", "<q> Quit",
"<Ctrl+]> Commands"}
: std::vector<std::string>{"<c> Control; <q> Quit",
"<Ctrl+]> Commands"},
inner, count);
keys = Columns(
"KEYS",
count == 4
? std::vector<std::string>{"<c> Control", "<q> Quit",
"<Ctrl+]> Commands", "<click> Detail"}
: count == 3
? std::vector<std::string>{"<c> Control; <q> Quit",
"<Ctrl+]> Commands", "<click> Detail"}
: std::vector<std::string>{"<c> Control; <q> Quit",
"<Ctrl+]> Commands"},
inner, count);
}
if (bar.control && !bar.command_mode) {
view = Columns("THEN", {"<c> Toggle control", "<q> Quit"}, inner, count);
} else {
const auto zoom = bar.command_mode ? "<+/-> Zoom at mouse"
: "<+/wheel/-> Zoom at mouse";
view = Columns(
"VIEW",
count == 4
? std::vector<std::string>{"<arrows/ijkl> Pan", "<+/-> Zoom",
"<f> Fit", "<d> Detail"}
? std::vector<std::string>{"<+/-> Zoom at mouse",
bar.command_mode ? "<=> Zoom in"
: "<wheel> Zoom",
"<arrows/ijkl> Pan", "<f> Full view"}
: count == 3
? std::vector<std::string>{"<arrows/ijkl> Pan", "<+/-> Zoom",
"<f> Fit; <d> Detail"}
: std::vector<std::string>{"<arrows> Pan; <+/-> Zoom",
"<f> Fit; <d> Detail"},
? std::vector<std::string>{zoom, "<arrows/ijkl> Pan",
"<f> Full view"}
: std::vector<std::string>{zoom, "<arrows> Pan; <f> Full"},
inner, count);
}
} else {
@@ -139,11 +150,18 @@ std::vector<std::string> StatusLines(const StatusBar &bar, int columns) {
? std::vector<std::string>{"<c> Toggle control | <q> Quit",
"<c> Toggle; <q> Quit", "<c> / <q>",
"<q>", "q"}
: std::vector<std::string>{"<arrows> Pan | <+/-> Zoom | <f> Fit | "
"<d> Detail",
"<+/-> Zoom | <f> Fit | <d> Detail",
"<f> Fit | <d> Detail", "<f> Fit", "<f>",
"f"},
: bar.command_mode
? std::vector<
std::string>{"<+/-> Zoom at mouse | <arrows> Pan | <f> Full",
"<+/-> Zoom at mouse | <f> Full",
"<+/-> Zoom | <f> Full", "<+/-> | <f>", "<+/->",
"+"}
: std::vector<std::string>{"<+/wheel/-> Zoom at mouse | <arrows> "
"Pan | <f> Full",
"<+/wheel/-> Zoom at mouse | <f> Full",
"<+/wheel/-> Zoom | <f> Full",
"<+/-> Zoom | <f> Full", "<+/-> | <f>",
"<+/->", "+"},
inner);
}
std::vector<std::string> lines{status, keys, view};