diff --git a/README.md b/README.md index a5e87638..25a2a287 100644 --- a/README.md +++ b/README.md @@ -157,7 +157,7 @@ CrossDesk 让你从电脑、浏览器、手机或终端连接远端电脑,也 | ANSI 字符画面 | Kitty 协议像素画面 | | --- | --- | | CrossDesk CLI 的 ANSI 四象限全景:顶部显示只读模式和连接状态,底部显示操作快捷键 | 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+]` 也可返回,若返回控制模式则向远端发送一次该组合键。 diff --git a/README_EN.md b/README_EN.md index 1875f118..c44ffbe1 100644 --- a/README_EN.md +++ b/README_EN.md @@ -157,7 +157,7 @@ Remote sessions support monitor switching, video quality and frame-rate controls | ANSI character rendering | Kitty pixel graphics | | --- | --- | | CrossDesk CLI ANSI quadrant overview, with read-only mode and connection status at the top and shortcuts at the bottom | 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. diff --git a/apps/cli/README.md b/apps/cli/README.md index 522c22b7..72a0e365 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -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+]` 也可离开本地命令模式,若返回控制模式则同时向远端发送该组合键。 ## 更多文档与功能范围 diff --git a/apps/cli/docs/bridge-protocol.md b/apps/cli/docs/bridge-protocol.md index 1ed4a355..3b173fcf 100644 --- a/apps/cli/docs/bridge-protocol.md +++ b/apps/cli/docs/bridge-protocol.md @@ -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 | 无 | 释放输入、结束当前媒体会话 | diff --git a/apps/cli/docs/terminal-client.md b/apps/cli/docs/terminal-client.md index 03314f25..2425f53c 100644 --- a/apps/cli/docs/terminal-client.md +++ b/apps/cli/docs/terminal-client.md @@ -57,11 +57,11 @@ Windows 的可见传统控制台优先读取当前字体尺寸;Windows Termina 缩放在完整输出边界清理旧画面。上限 240 列、100 画面行;默认顶部预留一行状态栏、底部预留两行快捷键,另预留一列避免自动换行。顶部状态栏用反色区分,左侧为控制模式和连接进度,右侧为画面参数及网络信息;底部两行分别为会话和视图快捷键。视频和等待提示从顶部状态栏下方开始,鼠标坐标按视频区域映射,点击状态栏不会传给远端。按键分组使用统一列宽和左右留白,窄窗口自动减少列数、合并按键组并省略次要参数,累计帧数和输出字节数保留在日志中。控制模式下仅提示本地命令入口及后续操作,进入本地命令模式后才显示平移和缩放按键;终端高度不足时优先收起底部提示行。 -快捷键行使用 `KEYS |`、`VIEW |` 等固定宽度标题,标题和按键组之间用竖线分隔;键名用 ``、`<+/->` 等标签标识。宽窗口中的分组分隔线按列对齐,窄窗口保留标题和完整按键标签,按可用宽度合并或减少分组。 +快捷键行使用 `KEYS |`、`VIEW |` 等固定宽度标题,标题和按键组之间用竖线分隔;键名用 ``、`<+/->` 等标签标识。`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 停止时移除查询定时器和输入监听。 diff --git a/apps/cli/docs/validation.md b/apps/cli/docs/validation.md index 0253cd6b..2427e3ca 100644 --- a/apps/cli/docs/validation.md +++ b/apps/cli/docs/validation.md @@ -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 启动失败后的关闭操作。 diff --git a/apps/cli/main.cpp b/apps/cli/main.cpp index 7a7d66a3..f0690f07 100644 --- a/apps/cli/main.cpp +++ b/apps/cli/main.cpp @@ -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. diff --git a/apps/cli/plugins/pi/index.ts b/apps/cli/plugins/pi/index.ts index b1ea699c..dca9f1c5 100644 --- a/apps/cli/plugins/pi/index.ts +++ b/apps/cli/plugins/pi/index.ts @@ -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)]; }, diff --git a/libs/terminal/include/terminal_renderer.h b/libs/terminal/include/terminal_renderer.h index 0e9b8437..01340516 100644 --- a/libs/terminal/include/terminal_renderer.h +++ b/libs/terminal/include/terminal_renderer.h @@ -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> 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 *); }; diff --git a/libs/terminal/src/input.cpp b/libs/terminal/src/input.cpp index cd21621f..335127ae 100644 --- a/libs/terminal/src/input.cpp +++ b/libs/terminal/src/input.cpp @@ -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; diff --git a/libs/terminal/src/terminal_renderer.cpp b/libs/terminal/src/terminal_renderer.cpp index e7e2e4c7..150b5d1e 100644 --- a/libs/terminal/src/terminal_renderer.cpp +++ b/libs/terminal/src/terminal_renderer.cpp @@ -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, diff --git a/libs/terminal/src/terminal_status.cpp b/libs/terminal/src/terminal_status.cpp index 988b9bd1..175a2e5f 100644 --- a/libs/terminal/src/terminal_status.cpp +++ b/libs/terminal/src/terminal_status.cpp @@ -79,41 +79,52 @@ std::vector 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{" Return", - " Toggle control", - " Quit"} - : std::vector{" Return", - " Toggle; Quit"}, - inner, count); + keys = Columns( + "LOCAL", + count == 4 + ? std::vector{" Return", + " Toggle control", " Quit", + " Set anchor"} + : count == 3 + ? std::vector{" Return", + " Toggle control", " Quit"} + : std::vector{" Return", + " Toggle; Quit"}, + inner, count); } else if (bar.control) { keys = Columns("KEYS", {" Commands", "Input -> remote"}, inner, count); } else { - keys = Columns("KEYS", - count >= 3 - ? std::vector{" Control", " Quit", - " Commands"} - : std::vector{" Control; Quit", - " Commands"}, - inner, count); + keys = Columns( + "KEYS", + count == 4 + ? std::vector{" Control", " Quit", + " Commands", " Detail"} + : count == 3 + ? std::vector{" Control; Quit", + " Commands", " Detail"} + : std::vector{" Control; Quit", + " Commands"}, + inner, count); } if (bar.control && !bar.command_mode) { view = Columns("THEN", {" Toggle control", " Quit"}, inner, count); } else { + const auto zoom = bar.command_mode ? "<+/-> Zoom at mouse" + : "<+/wheel/-> Zoom at mouse"; view = Columns( "VIEW", count == 4 - ? std::vector{" Pan", "<+/-> Zoom", - " Fit", " Detail"} + ? std::vector{"<+/-> Zoom at mouse", + bar.command_mode ? "<=> Zoom in" + : " Zoom", + " Pan", " Full view"} : count == 3 - ? std::vector{" Pan", "<+/-> Zoom", - " Fit; Detail"} - : std::vector{" Pan; <+/-> Zoom", - " Fit; Detail"}, + ? std::vector{zoom, " Pan", + " Full view"} + : std::vector{zoom, " Pan; Full"}, inner, count); } } else { @@ -139,11 +150,18 @@ std::vector StatusLines(const StatusBar &bar, int columns) { ? std::vector{" Toggle control | Quit", " Toggle; Quit", " / ", "", "q"} - : std::vector{" Pan | <+/-> Zoom | Fit | " - " Detail", - "<+/-> Zoom | Fit | Detail", - " Fit | Detail", " Fit", "", - "f"}, + : bar.command_mode + ? std::vector< + std::string>{"<+/-> Zoom at mouse | Pan | Full", + "<+/-> Zoom at mouse | Full", + "<+/-> Zoom | Full", "<+/-> | ", "<+/->", + "+"} + : std::vector{"<+/wheel/-> Zoom at mouse | " + "Pan | Full", + "<+/wheel/-> Zoom at mouse | Full", + "<+/wheel/-> Zoom | Full", + "<+/-> Zoom | Full", "<+/-> | ", + "<+/->", "+"}, inner); } std::vector lines{status, keys, view};