417 lines
8.6 KiB
Markdown
417 lines
8.6 KiB
Markdown
# Desktop Mark:Ctrl+1 后的标注逻辑技术说明
|
||
|
||
本文只总结当前项目中 **按下 `Ctrl+1` 之后** 的窗口选择与标注逻辑,不包含主页面其它功能。
|
||
下面的所有代码:E:\workspace\wechat_rap
|
||
---
|
||
|
||
## 1. 入口:全局快捷键 `Ctrl+1`
|
||
|
||
代码位置:`src-tauri/src/window_capture.rs`、`src-tauri/src/lib.rs`
|
||
|
||
### 1.1 启动时注册快捷键
|
||
应用启动时:
|
||
|
||
- 在 `lib.rs` 中调用 `window_capture::spawn_shortcut_listener(app.handle().clone())`
|
||
- 该函数在后台线程中通过 Win32 `RegisterHotKey` 注册:
|
||
|
||
```rust
|
||
RegisterHotKey(null_mut(), 1, MOD_CONTROL as u32, b'1' as u32)
|
||
```
|
||
|
||
含义:
|
||
|
||
- `MOD_CONTROL`:Ctrl
|
||
- `b'1'`:数字键 1
|
||
- `id = 1`:该快捷键内部 ID
|
||
yu
|
||
### 1.2 消息循环监听
|
||
快捷键不是前端监听,而是 Rust 后台线程通过 Win32 消息循环监听:
|
||
|
||
```rust
|
||
while GetMessageW(&mut message, null_mut(), 0, 0) > 0 {
|
||
if message.message == WM_HOTKEY && message.wParam == 1 {
|
||
...
|
||
}
|
||
}
|
||
```
|
||
|
||
当收到 `WM_HOTKEY` 且 `wParam == 1` 时,说明用户按下了 `Ctrl+1`。
|
||
|
||
---
|
||
|
||
## 2. 快捷键触发后:进入窗口选择模式
|
||
|
||
代码位置:`src-tauri/src/window_capture.rs`
|
||
|
||
按下 `Ctrl+1` 后,Rust 会调用:
|
||
|
||
```rust
|
||
enter_window_select_mode_internal(&app, &state)
|
||
```
|
||
|
||
### 2.1 切换内部状态
|
||
该函数会更新 `OverlayStore`:
|
||
|
||
```rust
|
||
store.target_window_id = None;
|
||
store.mode = OverlayMode::WindowSelect;
|
||
```
|
||
|
||
当前状态含义:
|
||
|
||
- `target_window_id = None`:还没有确认选中哪个真实应用窗口
|
||
- `mode = WindowSelect`:前端 overlay 进入“窗口选择”模式,而不是“标注框绘制”模式
|
||
|
||
### 2.2 打开 Overlay 窗口
|
||
然后调用:
|
||
|
||
```rust
|
||
show_overlay_window(app)
|
||
```
|
||
|
||
作用:
|
||
|
||
- 根据虚拟屏幕范围设置 overlay 位置与尺寸
|
||
- 显示 overlay 窗口
|
||
- 重新聚焦 overlay
|
||
|
||
Overlay 特征(见 `lib.rs`):
|
||
|
||
- `transparent(true)`:透明
|
||
- `decorations(false)`:无边框
|
||
- `always_on_top(true)`:置顶
|
||
- `visible(false)`:默认隐藏,按需显示
|
||
- `skip_taskbar(true)`:不进任务栏
|
||
|
||
因此,`Ctrl+1` 的直接结果是:
|
||
|
||
```text
|
||
全屏透明置顶 Overlay 被显示出来,进入窗口选择模式
|
||
```
|
||
|
||
---
|
||
|
||
## 3. Overlay 前端:窗口选择模式如何工作
|
||
|
||
代码位置:`src/overlay.ts`、`overlay.html`、`src/overlay.css`
|
||
|
||
### 3.1 Overlay 的数据来源
|
||
前端通过轮询:
|
||
|
||
```ts
|
||
invoke<OverlayFrame>('get_overlay_frame')
|
||
```
|
||
|
||
从 Rust 获取当前帧状态。
|
||
|
||
`OverlayFrame` 关键字段:
|
||
|
||
- `mode`: `'annotation' | 'window_select'`
|
||
- `virtual_screen_rect`
|
||
- `target_window`
|
||
- `hover_window`
|
||
- `cursor`
|
||
- `annotations`
|
||
- `node_tree`
|
||
|
||
在 `Ctrl+1` 后:
|
||
|
||
```text
|
||
mode = window_select
|
||
```
|
||
|
||
### 3.2 鼠标移到哪个窗口,哪个窗口高亮
|
||
Rust 侧通过:
|
||
|
||
```rust
|
||
hover_target()
|
||
```
|
||
|
||
获取鼠标所在真实窗口。
|
||
|
||
逻辑要点:
|
||
|
||
1. 先用 `GetCursorPos` 取屏幕坐标
|
||
2. 再通过 `WindowFromPoint` + `GetAncestor(..., GA_ROOT)` 拿到鼠标下的顶层窗口
|
||
3. 再向下过滤,跳过:
|
||
- Desktop Mark 自己
|
||
- 工具窗口
|
||
- 输入法 / 任务栏 / 桌面 / 阴影等系统窗口
|
||
4. 最终得到一个可选的真实桌面应用窗口 `hover_window`
|
||
|
||
### 3.3 前端高亮绘制
|
||
在 `overlay.ts` 里:
|
||
|
||
- 整屏先绘制半透明遮罩
|
||
- 当前 `hover_window` 对应区域被镂空
|
||
- 再绘制高亮边框与提示标签
|
||
|
||
窗口选择模式下,标签文本类似:
|
||
|
||
```text
|
||
点击选择:窗口标题 · x=... , y=... · DPI ...%
|
||
```
|
||
|
||
因此用户看到的体验是:
|
||
|
||
```text
|
||
Ctrl+1 → 整屏暗化 → 鼠标移到哪个应用窗口,哪个窗口被高亮
|
||
```
|
||
|
||
---
|
||
|
||
## 4. 点击窗口:确认目标窗口
|
||
|
||
代码位置:`src/overlay.ts`、`src-tauri/src/window_capture.rs`
|
||
|
||
### 4.1 前端点击逻辑
|
||
在 `overlay.ts` 中,`pointerup` 时如果当前模式是 `window_select`:
|
||
|
||
```ts
|
||
await invoke('select_target_window', { windowId: frame.hover_window.window_id });
|
||
frame = await invoke<OverlayFrame>('get_overlay_frame');
|
||
```
|
||
|
||
也就是说:
|
||
|
||
- 用户点击当前高亮窗口
|
||
- 把这个窗口的 `window_id` 发送给 Rust
|
||
|
||
### 4.2 Rust 侧确认目标窗口
|
||
对应命令:
|
||
|
||
```rust
|
||
select_target_window(window_id, app, state)
|
||
```
|
||
|
||
它会做三件事:
|
||
|
||
#### a. 校验窗口存在
|
||
|
||
```rust
|
||
find_window_by_id(&window_id)
|
||
```
|
||
|
||
#### b. 把目标窗口提到前台
|
||
|
||
```rust
|
||
bring_window_to_front(window.hwnd)
|
||
```
|
||
|
||
内部会:
|
||
|
||
- `ShowWindow(hwnd, SW_RESTORE)` 恢复窗口
|
||
- `SetWindowPos(... HWND_TOPMOST ...)` 暂时提到最上层
|
||
- `SetForegroundWindow(hwnd)` 设为前台
|
||
|
||
#### c. 切换 Overlay 模式
|
||
|
||
```rust
|
||
store.target_window_id = Some(window_id);
|
||
store.mode = OverlayMode::Annotation;
|
||
```
|
||
|
||
此时进入:
|
||
|
||
```text
|
||
标注模式(Annotation)
|
||
```
|
||
|
||
#### d. 再次显示并聚焦 Overlay
|
||
|
||
```rust
|
||
show_overlay_window(&app)
|
||
```
|
||
|
||
这样可以避免用户选完窗口后还要额外点一下别处,才能开始画标注框。
|
||
|
||
---
|
||
|
||
## 5. 标注模式:只显示当前目标窗口的标注框
|
||
|
||
代码位置:`src-tauri/src/window_capture.rs`
|
||
|
||
用户确认窗口后,后续 `get_overlay_frame()` 不会再返回所有标注,而是只返回:
|
||
|
||
```rust
|
||
当前 target_window_id 对应的 annotations
|
||
```
|
||
|
||
实现逻辑:
|
||
|
||
```rust
|
||
let visible_annotations = if store.mode == OverlayMode::Annotation {
|
||
store
|
||
.target_window_id
|
||
.as_ref()
|
||
.map(|window_id| {
|
||
store
|
||
.annotations
|
||
.iter()
|
||
.filter(|annotation| &annotation.window_id == window_id)
|
||
.cloned()
|
||
.collect()
|
||
})
|
||
.unwrap_or_default()
|
||
} else {
|
||
Vec::new()
|
||
};
|
||
```
|
||
|
||
效果:
|
||
|
||
- 切到窗口 A 标注时,只显示窗口 A 的标注框
|
||
- 切到窗口 B 标注时,窗口 A 的标注框会隐藏,但数据不会丢
|
||
|
||
---
|
||
|
||
## 6. 在目标窗口内拖拽创建标注框
|
||
|
||
代码位置:`src/overlay.ts`、`src-tauri/src/window_capture.rs`
|
||
|
||
### 6.1 前端拖拽
|
||
在 `annotation` 模式下:
|
||
|
||
- `pointerdown` 记录起点
|
||
- `pointermove` 更新终点
|
||
- `pointerup` 生成屏幕坐标矩形 `RectInfo`
|
||
|
||
然后调用:
|
||
|
||
```ts
|
||
invoke('add_annotation', {
|
||
input: {
|
||
window_id: frame.target_window.window_id,
|
||
screen_rect: rect,
|
||
label: null,
|
||
action_kind: 'click',
|
||
description: null,
|
||
}
|
||
})
|
||
```
|
||
|
||
### 6.2 Rust 保存标注
|
||
命令:
|
||
|
||
```rust
|
||
add_annotation(input, state)
|
||
```
|
||
|
||
Rust 会做这些校验:
|
||
|
||
1. 目标窗口是否仍存在
|
||
2. 标注框尺寸是否太小
|
||
3. 标注框是否完整位于目标窗口内部
|
||
|
||
通过后,生成:
|
||
|
||
- `annotation.id`
|
||
- `label`
|
||
- `action_kind`
|
||
- `description`
|
||
- `screen_rect`
|
||
- `normalized_rect`
|
||
- `feature_image_path`
|
||
|
||
### 6.3 特征图生成
|
||
保存标注时还会生成一个本地 BMP 特征图:
|
||
|
||
```rust
|
||
capture_feature_image(&format!("ann-{index}"), input.screen_rect)
|
||
```
|
||
|
||
特征图保存目录:
|
||
|
||
```text
|
||
%TEMP%/desktop_mark/feature_images/
|
||
```
|
||
|
||
---
|
||
|
||
## 7. 标注数据如何持久化
|
||
|
||
代码位置:`src-tauri/src/window_capture.rs`
|
||
|
||
当前标注逻辑不是只存在内存里,而是会持久化到本地:
|
||
|
||
### 7.1 状态文件
|
||
|
||
```text
|
||
./data/workflow-state.json
|
||
```
|
||
|
||
### 7.2 持久化内容
|
||
`PersistedWorkflowState` 包含:
|
||
|
||
- `app_name`
|
||
- `app_description`
|
||
- `annotations`
|
||
- `next_annotation_index`
|
||
|
||
### 7.3 触发时机
|
||
以下操作后都会持久化:
|
||
|
||
- `add_annotation`
|
||
- `update_annotation`
|
||
- `update_annotation_geometry`
|
||
- `delete_annotation`
|
||
- `clear_annotations`
|
||
- `update_app_metadata`
|
||
|
||
---
|
||
|
||
## 8. Ctrl+1 后逻辑的完整时序
|
||
|
||
```text
|
||
用户按下 Ctrl+1
|
||
→ Rust Win32 热键线程收到 WM_HOTKEY
|
||
→ 进入 WindowSelect 模式
|
||
→ 显示全屏透明 Overlay
|
||
→ 鼠标移动,Rust 持续计算 hover_window
|
||
→ 前端绘制高亮窗口
|
||
→ 用户点击高亮窗口
|
||
→ Rust 记录 target_window_id,并切到 Annotation 模式
|
||
→ Overlay 再次聚焦
|
||
→ 用户直接在目标窗口内拖拽
|
||
→ 前端把矩形发给 add_annotation
|
||
→ Rust 校验并保存 annotation + feature image
|
||
→ 当前窗口的标注框持续显示,其它窗口标注框隐藏
|
||
```
|
||
|
||
---
|
||
|
||
## 9. 当前 Ctrl+1 方案的核心特点
|
||
|
||
### 优点
|
||
|
||
- 全局快捷键直接进入窗口选择,不依赖前端焦点
|
||
- Overlay 与真实应用窗口分离,不污染目标应用
|
||
- 只显示当前目标窗口的标注框,切窗不混乱
|
||
- 标注数据与特征图会落本地
|
||
- DPI、虚拟屏、多窗口过滤都在 Rust/Win32 层处理
|
||
|
||
### 当前限制
|
||
|
||
- 目标窗口关系、节点关系仍主要依赖本地状态和后续页面逻辑
|
||
- Ctrl+1 只负责“选窗 + 进入标注模式”,不负责主页面流程编排
|
||
- feature image 当前保存为 BMP,不是 PNG
|
||
|
||
---
|
||
|
||
## 10. 涉及文件清单
|
||
|
||
### Rust 后端
|
||
|
||
- `src-tauri/src/lib.rs`
|
||
- `src-tauri/src/window_capture.rs`
|
||
|
||
### Overlay 前端
|
||
|
||
- `overlay.html`
|
||
- `src/overlay.ts`
|
||
- `src/overlay.css`
|
||
|
||
### 类型定义
|
||
|
||
- `src/types.ts`
|