Initial code commit

This commit is contained in:
大森 2026-06-27 17:54:44 +08:00
commit 89d9f1928a
52 changed files with 13821 additions and 0 deletions

43
.gitignore vendored Normal file
View File

@ -0,0 +1,43 @@
# OS / editors
.DS_Store
Thumbs.db
.vscode/
.idea/
# Python
.venv/
venv/
__pycache__/
*.py[cod]
.pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage
htmlcov/
*.egg-info/
# Node / frontend
node_modules/
dist/
build/
.next/
coverage/
*.tsbuildinfo
# Rust / Tauri
/target/
**/target/
# Local tooling caches
.codegraph/
# Nested repositories
test_window/
# Generated / local artifacts
*.log
*.tmp
backup/
**/backup/
PixPin_*.png
*_vis.png

View File

@ -0,0 +1,632 @@
# Rust + Tauri 重构开发文档
## 1. 目标
用 Rust + Tauri 重构当前 Python + PySide6 版本,功能行为保持一致:
- 启动后显示 80×80 置顶悬浮窗。
- `Ctrl+1` 进入窗口识别模式。
- 全屏半透明遮罩覆盖虚拟屏幕。
- 鼠标移动时识别底层应用窗口,蓝色边框高亮;窗口内部无遮罩。
- 鼠标在桌面或无合格窗口时,默认选择整个虚拟屏幕。
- 鼠标点击锁定当前窗口;锁定后鼠标移动不再切换目标。
- `Esc` 或回车退出当前选择/标注流程,不删除已保存标注。
- 锁定窗口后 `Ctrl+R` 进入矩形标注模式。
- 可创建多个标注框;标注框可选中、移动、缩放。
- 左侧显示 300px 标注信息面板。
- 标注框附近显示编辑表单,可编辑控件名、描述、类型、动作、特征图占位。
- 自动保存到用户目录 JSON 文件。
首版仍只支持 Windows 10/11。
## 2. 当前 Python 版本功能清单
### 2.1 应用启动
当前入口为 `python -m desktop_mark`
启动时创建:
- `WindowProvider`Win32 窗口识别。
- `FloatingButton`80×80 悬浮窗。
- `OverlayWindow`:窗口选择与标注 overlay。
- `AnnotationPanel`:左侧标注信息面板。
- `AnnotationFormPopup`:标注编辑表单。
- `HotkeyWindow`:全局快捷键桥接。
启动后只显示悬浮窗overlay、信息面板、表单默认隐藏。
### 2.2 DPI 策略
当前 Python 版启动时调用:
- `SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)`
- Qt 高 DPI rounding policy`PassThrough`
Tauri 版应在 Rust 入口尽早设置 Win32 DPI awareness避免 Win32 坐标和 WebView 坐标偏移。
### 2.3 悬浮窗
当前行为:
- 固定尺寸80×80。
- 置顶、无边框、工具窗口。
- 深色半透明圆角背景。
- 主文本:`RPA`
- 副文本:`Ctrl+1`
- 点击等价于触发窗口选择。
- 支持鼠标拖动移动。
- 自身窗口句柄必须加入窗口识别排除列表,避免选中工具自身。
### 2.4 全局快捷键
当前快捷键:
- `Ctrl+1`:进入窗口选择模式。
- `Ctrl+R`:进入标注模式;如果没有锁定窗口,则先进入窗口选择模式。
当前实现使用 Win32 `RegisterHotKey`,不是键盘 hook。Tauri 版也应使用 Rust 原生层注册系统热键,避免管理员权限和杀软误报。
### 2.5 窗口识别
当前算法:
1. 不直接使用 `WindowFromPoint`,因为全屏 overlay 会遮住底层窗口。
2. 从 `GetTopWindow(None)` 开始,按 Z 序循环 `GetWindow(hwnd, GW_HWNDNEXT)`
3. 跳过:
- 已排除窗口句柄。
- 不可见窗口。
- 最小化窗口。
- `WS_EX_TOOLWINDOW` 工具窗口。
- 宽高小于等于 0 的窗口。
4. 用 `GetWindowRect(hwnd)` 得到窗口矩形。
5. 返回第一个包含鼠标点的合格顶层窗口。
6. 标题来自 `GetWindowText(hwnd)`
7. 进程名来自 `GetWindowThreadProcessId` + `OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION)` + 模块路径文件名。
8. 没有命中任何窗口时,返回虚拟屏幕桌面窗口:
- `hwnd = null`
- `title = "Desktop"`
- `process_name = "explorer.exe"`
- `rect = virtual_screen_rect`
- `is_desktop = true`
虚拟屏幕使用:
- `SM_XVIRTUALSCREEN`
- `SM_YVIRTUALSCREEN`
- `SM_CXVIRTUALSCREEN`
- `SM_CYVIRTUALSCREEN`
### 2.6 Overlay 状态机
状态枚举:
```rust
enum OverlayMode {
Idle,
PickingWindow,
WindowLocked,
DrawingAnnotation,
EditingAnnotation,
MovingAnnotation,
ResizingAnnotation,
}
```
行为:
- `Idle`overlay 隐藏。
- `PickingWindow`overlay 显示,每 50ms 读取鼠标位置并识别窗口。
- `WindowLocked`:点击窗口后进入;蓝框保持锁定,不再跟随鼠标。
- `DrawingAnnotation`:锁定窗口内拖拽新建矩形。
- `EditingAnnotation`:显示表单,选中标注。
- `MovingAnnotation`:拖动已有标注框。
- `ResizingAnnotation`:拖动 8 个 resize handles。
### 2.7 Overlay 绘制规则
当前视觉规则:
- 全屏半透明黑色遮罩,透明度约 120/255。
- 当前目标窗口区域清除遮罩。
- 目标窗口边缘绘制 2px 蓝色边框。
- 右上角显示窗口标题和 bbox。
- 已有标注框为橙色 2px 边框。
- 标注序号显示在框左上角。
- 选中标注显示 8 个白底蓝边 resize handles。
- 新建中的临时框为蓝色 2px 边框。
Tauri 版建议:
- 用独立透明 always-on-top overlay window。
- 前端用 SVG 或 Canvas 绘制遮罩、挖空、边框、标注框。
- 遮罩挖空可用 SVG `mask` / `clipPath`,或 Canvas `globalCompositeOperation = "destination-out"`
### 2.8 标注交互
创建标注:
1. `Ctrl+R` 进入 `DrawingAnnotation`
2. 鼠标左键按下记录起点。
3. 拖动时显示临时矩形。
4. 鼠标释放:
- 对矩形做 `normalized()`
- 裁剪到锁定窗口 `clamped_to(locked_window.rect)`
- 小于 `6×6` 像素视为误触,不创建。
- 合格则添加到 `project.annotations`
- 进入 `EditingAnnotation`
- 在标注框右下附近显示编辑表单。
命中测试顺序:
1. resize handle。
2. 标注框内部移动。
3. 空白区域新建。
多个标注重叠时,最后创建的标注优先命中。
移动规则:
- 移动时保持标注框宽高不变。
- 移动结果必须完整留在锁定窗口内。
- 不允许因简单裁剪导致宽高变小。
缩放规则:
- 8 个 handles`nw``n``ne``e``se``s``sw``w`
- 最小尺寸:`6×6`
- 缩放结果裁剪到锁定窗口内。
取消规则:
- `Esc` 或回车:退出当前 overlay 流程。
- 清空当前 hover/locked/temp 状态。
- 隐藏 overlay、左侧面板、表单。
- 不删除内存中的项目数据和已保存数据。
## 3. 数据模型契约
Tauri 版 Rust 与前端必须使用同一份 JSON schema。建议 Rust `serde` 类型如下:
```rust
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub struct Rect {
pub x: i32,
pub y: i32,
pub width: i32,
pub height: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WindowInfo {
pub hwnd: Option<isize>,
pub title: String,
pub process_name: String,
pub rect: Rect,
pub is_desktop: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AnnotationType {
Button,
Input,
Text,
Image,
Other,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Annotation {
pub id: String,
pub rect: Rect,
pub control_name: String,
pub description: String,
#[serde(rename = "type")]
pub annotation_type: AnnotationType,
pub actions: Vec<String>,
pub feature_preview_path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AnnotationProject {
pub window: WindowInfo,
pub custom_window_name: String,
pub window_description: String,
pub annotations: Vec<Annotation>,
}
```
### 3.1 `Rect` 方法
必须实现并测试:
- `right() -> i32``x + width`
- `bottom() -> i32``y + height`
- `contains(x, y) -> bool`:使用 normalized 后半开区间 `[x, right)``[y, bottom)`
- `normalized() -> Rect`:支持负向拖拽。
- `clamped_to(bounds) -> Rect`:矩形裁剪到边界内。
- `move_clamped(dx, dy, bounds) -> Rect`:保持原宽高移动,并完整限制在边界内。
### 3.2 坐标约定
所有坐标都使用屏幕绝对物理像素:
- 窗口 bbox屏幕绝对物理像素。
- 标注框:屏幕绝对物理像素。
- JSON 保存:屏幕绝对物理像素。
首版不实现窗口移动后的标注自动重定位。
## 4. JSON 保存格式
保存路径必须兼容当前版本:
```text
%USERPROFILE%\.desktop_mark\last_project.json
```
JSON 字段固定:
```json
{
"window": {
"hwnd": 123456,
"title": "Untitled - Notepad",
"process_name": "notepad.exe",
"rect": { "x": 100, "y": 100, "width": 800, "height": 600 },
"is_desktop": false
},
"custom_window_name": "登录页",
"window_description": "用户登录窗口",
"annotations": [
{
"id": "uuid-string",
"rect": { "x": 150, "y": 160, "width": 120, "height": 32 },
"control_name": "用户名输入框",
"description": "输入登录用户名",
"type": "input",
"actions": ["input_text"],
"feature_preview_path": null
}
]
}
```
保存时机:
- 标注表单保存后。
- 窗口名称或窗口简介变化后。
加载规则:
- 启动时如果文件存在则加载到内存。
- 不自动显示 overlay。
- 加载失败时弹出错误,继续空项目。
- 不覆盖坏文件。
## 5. Tauri 推荐架构
### 5.1 窗口划分
建议使用 4 个 Tauri windows
1. `floating`
- 80×80。
- always-on-top。
- decorations false。
- transparent true。
- resizable false。
- skip taskbar true。
- 显示 `RPA` / `Ctrl+1`
2. `overlay`
- 覆盖虚拟屏幕。
- always-on-top。
- decorations false。
- transparent true。
- skip taskbar true。
- 初始隐藏。
- 用于窗口选择、遮罩、标注绘制。
3. `panel`
- 宽 300。
- 高度为虚拟屏幕高度。
- 位于虚拟屏幕左侧。
- always-on-top。
- 初始隐藏。
- 显示窗口信息和标注列表。
4. `annotation_form`
- 约 320×420。
- always-on-top。
- 初始隐藏。
- 位于标注框右下附近,越界则左/上偏移。
也可以把 `panel``annotation_form` 做成 overlay 内部 DOM但独立窗口更接近当前 PySide6 行为。
### 5.2 Rust 模块建议
```text
src-tauri/src/
main.rs
models.rs
rect.rs
win32_window.rs
hotkeys.rs
storage.rs
window_manager.rs
commands.rs
```
职责:
- `models.rs``WindowInfo``Annotation``AnnotationProject``AnnotationType`
- `rect.rs``Rect` 几何方法和单元测试。
- `win32_window.rs`:窗口枚举、进程名、虚拟屏幕。
- `hotkeys.rs``RegisterHotKey` 或 Tauri global shortcut 适配。
- `storage.rs`JSON 保存/加载。
- `window_manager.rs`:创建/定位/显示隐藏 Tauri windows。
- `commands.rs`:暴露给前端的 Tauri commands。
### 5.3 前端模块建议
```text
src/
main.tsx
state/projectStore.ts
types.ts
windows/Floating.tsx
windows/Overlay.tsx
windows/Panel.tsx
windows/AnnotationForm.tsx
geometry/rect.ts
```
职责:
- `types.ts`:与 Rust serde 对齐的类型。
- `projectStore.ts`当前项目、锁定窗口、hover 窗口、选中标注、overlay mode。
- `Overlay.tsx`:遮罩、窗口高亮、标注框、鼠标交互。
- `Panel.tsx`:窗口信息、窗口名称/简介、标注列表。
- `AnnotationForm.tsx`:控件名、描述、类型、动作、预览占位。
- `rect.ts`:前端几何算法,必须与 Rust 测试一致。
## 6. Rust Native API / Tauri Commands
建议命令:
```rust
#[tauri::command]
fn get_virtual_screen_rect() -> Rect;
#[tauri::command]
fn window_at(x: i32, y: i32, excluded_hwnds: Vec<isize>) -> WindowInfo;
#[tauri::command]
fn foreground_window(excluded_hwnds: Vec<isize>) -> Option<WindowInfo>;
#[tauri::command]
fn save_project(project: AnnotationProject) -> Result<(), String>;
#[tauri::command]
fn load_project() -> Result<Option<AnnotationProject>, String>;
#[tauri::command]
fn set_tool_window_exclusions(labels: Vec<String>) -> Result<Vec<isize>, String>;
```
前端事件:
```text
hotkey:start-window-picking
hotkey:start-annotation
project:changed
annotation:edit-requested
annotation:selected
flow:cancelled
```
## 7. Win32 实现要点
Rust 可使用 `windows` crate。
必须调用或等价实现:
- `SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)`
- `GetTopWindow(HWND(0))`
- `GetWindow(hwnd, GW_HWNDNEXT)`
- `IsWindowVisible(hwnd)`
- `IsIconic(hwnd)`
- `GetWindowLongPtrW(hwnd, GWL_EXSTYLE)`,跳过 `WS_EX_TOOLWINDOW`
- `GetWindowRect(hwnd)`
- `GetWindowTextW(hwnd)`
- `GetWindowThreadProcessId(hwnd, &mut pid)`
- `OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid)`
- `QueryFullProcessImageNameW` 或等价 API 获取进程路径
- `GetSystemMetrics(SM_XVIRTUALSCREEN)` 等虚拟屏幕指标
不要把 overlay window 作为候选窗口。所有 Tauri 工具窗口 hwnd 必须加入排除集合。
## 8. UI 字段与枚举
### 8.1 标注类型
固定值:
```text
button
input
text
image
other
```
### 8.2 动作枚举
固定值:
```text
click
double_click
right_click
input_text
hover
scroll
```
### 8.3 左侧面板字段
- 窗口标题。
- 进程名。
- bbox`x, y, width, height`
- 窗口名称:可编辑。
- 窗口简介:可编辑。
- 标注列表:`序号. label (type)`
`label` 优先级:
1. `control_name`
2. `description`
3. `标注 {index}`
### 8.4 标注表单字段
- 控件名:文本输入。
- 描述:多行文本。
- 类型:下拉选择。
- 动作:多选列表。
- 特征图:占位,显示 `预览待生成`
- 保存按钮。
- 取消按钮。
保存行为:
- 更新当前 annotation。
- `feature_preview_path` 保持 `null`
- 刷新左侧列表。
- 自动保存 JSON。
- 隐藏表单。
取消行为:
- 只关闭表单。
- 不删除 annotation。
## 9. 关键行为验收
### 9.1 启动
命令启动后:
- 桌面出现 80×80 `RPA` 悬浮窗。
- overlay、panel、form 不显示。
- 无控制台异常。
### 9.2 窗口选择
准备:打开记事本和浏览器。
操作:按 `Ctrl+1`
预期:
- 全屏半透明遮罩出现。
- 鼠标移动到记事本/浏览器时,对应窗口显示蓝色边框。
- 被高亮窗口内部无遮罩。
- 鼠标移动到桌面空白时,蓝框覆盖虚拟屏幕。
### 9.3 锁定与取消
操作:在浏览器窗口上点击。
预期:
- 蓝框锁定浏览器。
- 鼠标移动到其他窗口不切换蓝框。
- 按 `Esc` 或回车后 overlay 隐藏。
- 悬浮窗仍存在。
### 9.4 标注
操作:重新 `Ctrl+1` 锁定记事本,按 `Ctrl+R`,拖拽两个矩形。
预期:
- 两个橙色标注框出现。
- 标注框不能拖出记事本窗口。
- 选中框显示 8 个调整手柄。
- 左侧出现 300px 信息面板并列出两个标注。
### 9.5 表单与保存
操作:完成标注后,在表单输入:
- 控件名:`用户名输入框`
- 描述:`输入登录用户名`
- 类型:`input`
- 动作:`input_text`
点击保存。
预期:
- 左侧列表同步显示该标注。
- `%USERPROFILE%\.desktop_mark\last_project.json` 存在。
- JSON 包含对应 `control_name``description``type``actions`
### 9.6 边界
操作:在锁定窗口边缘向窗口外拖拽创建标注。
预期:
- 最终矩形被裁剪到锁定窗口内。
- 小于 `6×6` 的点击/拖拽不会新增标注。
## 10. 与当前 Python 版本保持一致的边界决定
- 只支持 Windows 10/11。
- 坐标保存为屏幕绝对物理像素。
- 不实现窗口移动后的标注自动重定位。
- 不实现特征图真实截图,只显示占位。
- 快捷键冲突时显示错误,不自动改用其他快捷键。
- 工具自身窗口必须被窗口识别排除。
- 没有命中应用窗口时选择整个虚拟屏幕。
## 11. 推荐开发顺序
1. 建 Tauri 项目和四个窗口。
2. 实现 Rust `Rect`、模型、JSON 保存/加载和单元测试。
3. 实现 Win32 虚拟屏幕与窗口识别。
4. 实现全局快捷键。
5. 实现 `floating` 窗口。
6. 实现 `overlay` 状态机和窗口高亮。
7. 实现标注框创建、移动、缩放。
8. 实现 `panel``annotation_form`
9. 串联自动保存和启动加载。
10. 按第 9 节逐项手工验收。
## 12. 当前 Python 版本验证基线
当前版本已验证:
```text
uv venv --python 3.11 --seed --clear
.\.venv\Scripts\python -m pip install -e .[dev]
.\.venv\Scripts\python -m pytest
```
结果:
```text
6 passed
```
启动烟测:
```text
.\.venv\Scripts\python -m desktop_mark
```
进程保持运行 3 秒,无 stdout/stderr 异常;测试后主动终止。

View File

@ -0,0 +1,7 @@
# 桌面应用RPA标注工具
### 期望实现目标
打开软件后桌面出现一个悬浮窗。80x80
按下快捷键ctrl+1 开始识别窗口默认全屏半透明遮罩通过识别鼠标在哪个应用或者哪个应用获取到了鼠标焦点那么就获取应用的x,y,w,h信息的bbox边框位置然后该应用窗口用蓝色边框切应用内窗口去掉半透明遮罩同时如果鼠标移动到其他应用窗口则继续切换窗口激活状态。如果鼠标在桌面上没有任何应用窗口上则默认选中全屏。然后点击鼠标后则选中当前窗口此时移动鼠标则不会激活其他窗口可以通过ESC回车撤销选中。选中窗口后可以通过Ctrl+r 开启矩形标注框可以在选中的应用窗口中开始标注同时屏幕左侧会出现一个300x1200的标注信息框标注的信息会在此处展示同时在顶部显示当前选中的窗口信息可以定义窗口名称与简介
标注可以多次标注,当开启标注后,拖动鼠标可以在应用窗口上进行绘制,点击鼠标后结束标注,同时已标注的框可以调整大小与位置,然后在标注框附近显示一个表单弹窗,可以输入控件、描述、类型、可选动作、特征图预览等

View File

@ -0,0 +1,26 @@
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "desktop-mark"
version = "0.1.0"
description = "Windows desktop RPA annotation tool"
readme = "REAMD.md"
requires-python = ">=3.11"
dependencies = [
"PySide6>=6.7,<7",
"pywin32>=306",
]
[project.optional-dependencies]
dev = [
"pytest>=8,<9",
]
[tool.setuptools.packages.find]
where = ["src"]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]

View File

@ -0,0 +1,4 @@
"""Desktop RPA annotation tool."""
__all__ = ["__version__"]
__version__ = "0.1.0"

View File

@ -0,0 +1,3 @@
from .app import main
main()

View File

@ -0,0 +1,204 @@
"""Annotation metadata side panel and edit popup."""
from __future__ import annotations
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QCloseEvent
from PySide6.QtWidgets import (
QComboBox,
QFormLayout,
QHBoxLayout,
QLabel,
QLineEdit,
QListWidget,
QListWidgetItem,
QPushButton,
QPlainTextEdit,
QVBoxLayout,
QWidget,
)
from .models import Annotation, AnnotationProject, AnnotationType, Rect
ACTIONS = ["click", "double_click", "right_click", "input_text", "hover", "scroll"]
class AnnotationPanel(QWidget):
window_metadata_changed = Signal(str, str)
annotation_selected = Signal(str)
def __init__(self) -> None:
super().__init__()
self.project: AnnotationProject | None = None
self._refreshing = False
self.setWindowTitle("标注信息")
self.setFixedWidth(300)
self.setWindowFlags(Qt.WindowType.Tool | Qt.WindowType.WindowStaysOnTopHint)
self.title_label = QLabel("-")
self.process_label = QLabel("-")
self.bbox_label = QLabel("-")
self.name_edit = QLineEdit()
self.description_edit = QPlainTextEdit()
self.description_edit.setFixedHeight(90)
self.annotation_list = QListWidget()
layout = QVBoxLayout(self)
layout.addWidget(QLabel("窗口信息"))
form = QFormLayout()
form.addRow("标题", self.title_label)
form.addRow("进程", self.process_label)
form.addRow("BBox", self.bbox_label)
form.addRow("窗口名称", self.name_edit)
form.addRow("窗口简介", self.description_edit)
layout.addLayout(form)
layout.addWidget(QLabel("标注列表"))
layout.addWidget(self.annotation_list, 1)
self.name_edit.textChanged.connect(self._emit_window_metadata)
self.description_edit.textChanged.connect(self._emit_window_metadata)
self.annotation_list.currentItemChanged.connect(self._on_current_item_changed)
def set_project(self, project: AnnotationProject | None) -> None:
self.project = project
self.refresh()
def refresh(self) -> None:
self._refreshing = True
try:
self.annotation_list.clear()
if self.project is None:
self.title_label.setText("-")
self.process_label.setText("-")
self.bbox_label.setText("-")
self.name_edit.clear()
self.description_edit.clear()
return
window = self.project.window
rect = window.rect
self.title_label.setText(window.title)
self.process_label.setText(window.process_name or "-")
self.bbox_label.setText(f"{rect.x}, {rect.y}, {rect.width}, {rect.height}")
self.name_edit.setText(self.project.custom_window_name)
self.description_edit.setPlainText(self.project.window_description)
for index, annotation in enumerate(self.project.annotations, start=1):
label = annotation.control_name or annotation.description or f"标注 {index}"
item = QListWidgetItem(f"{index}. {label} ({annotation.type.value})")
item.setData(Qt.ItemDataRole.UserRole, annotation.id)
self.annotation_list.addItem(item)
finally:
self._refreshing = False
def _emit_window_metadata(self) -> None:
if self._refreshing or self.project is None:
return
self.project.custom_window_name = self.name_edit.text()
self.project.window_description = self.description_edit.toPlainText()
self.window_metadata_changed.emit(
self.project.custom_window_name,
self.project.window_description,
)
def _on_current_item_changed(self, current: QListWidgetItem | None, previous: QListWidgetItem | None) -> None:
if current is None or self._refreshing:
return
annotation_id = current.data(Qt.ItemDataRole.UserRole)
if annotation_id:
self.annotation_selected.emit(str(annotation_id))
class AnnotationFormPopup(QWidget):
saved = Signal(object)
cancelled = Signal()
def __init__(self) -> None:
super().__init__()
self.annotation: Annotation | None = None
self.setWindowTitle("编辑标注")
self.setWindowFlags(Qt.WindowType.Tool | Qt.WindowType.WindowStaysOnTopHint)
self.control_name_edit = QLineEdit()
self.description_edit = QPlainTextEdit()
self.description_edit.setFixedHeight(100)
self.type_combo = QComboBox()
for annotation_type in AnnotationType:
self.type_combo.addItem(annotation_type.value, annotation_type)
self.actions_list = QListWidget()
self.actions_list.setSelectionMode(QListWidget.SelectionMode.MultiSelection)
for action in ACTIONS:
self.actions_list.addItem(action)
self.preview_label = QLabel("预览待生成")
self.preview_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.preview_label.setMinimumHeight(80)
self.preview_label.setStyleSheet("border: 1px dashed #999; color: #666;")
save_button = QPushButton("保存")
cancel_button = QPushButton("取消")
save_button.clicked.connect(self._save)
cancel_button.clicked.connect(self._cancel)
form = QFormLayout()
form.addRow("控件名", self.control_name_edit)
form.addRow("描述", self.description_edit)
form.addRow("类型", self.type_combo)
form.addRow("动作", self.actions_list)
form.addRow("特征图", self.preview_label)
buttons = QHBoxLayout()
buttons.addStretch(1)
buttons.addWidget(cancel_button)
buttons.addWidget(save_button)
layout = QVBoxLayout(self)
layout.addLayout(form)
layout.addLayout(buttons)
self.resize(320, 420)
def edit_annotation(self, annotation: Annotation, anchor_rect: Rect) -> None:
self.annotation = annotation
self.control_name_edit.setText(annotation.control_name)
self.description_edit.setPlainText(annotation.description)
index = self.type_combo.findData(annotation.type)
self.type_combo.setCurrentIndex(index if index >= 0 else 0)
selected_actions = set(annotation.actions)
for row in range(self.actions_list.count()):
item = self.actions_list.item(row)
item.setSelected(item.text() in selected_actions)
self.preview_label.setText("预览待生成")
self._move_near(anchor_rect)
self.show()
self.raise_()
self.activateWindow()
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802
self.cancelled.emit()
super().closeEvent(event)
def _save(self) -> None:
if self.annotation is None:
return
self.annotation.control_name = self.control_name_edit.text().strip()
self.annotation.description = self.description_edit.toPlainText().strip()
self.annotation.type = self.type_combo.currentData()
self.annotation.actions = [item.text() for item in self.actions_list.selectedItems()]
self.annotation.feature_preview_path = None
self.saved.emit(self.annotation)
self.hide()
def _cancel(self) -> None:
self.hide()
self.cancelled.emit()
def _move_near(self, anchor_rect: Rect) -> None:
screen = self.screen()
available = screen.availableGeometry() if screen is not None else self.geometry()
x = anchor_rect.right() + 12
y = anchor_rect.bottom() + 12
if x + self.width() > available.right():
x = anchor_rect.x - self.width() - 12
if y + self.height() > available.bottom():
y = anchor_rect.y - self.height() - 12
x = max(available.left(), min(x, available.right() - self.width()))
y = max(available.top(), min(y, available.bottom() - self.height()))
self.move(x, y)

View File

@ -0,0 +1,134 @@
"""Application entrypoint and object wiring."""
from __future__ import annotations
import ctypes
import sys
from pathlib import Path
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QApplication, QMessageBox
from .annotation_panel import AnnotationFormPopup, AnnotationPanel
from .floating_button import FloatingButton
from .hotkeys import HotkeyWindow
from .models import AnnotationProject
from .overlay import OverlayWindow
from .storage import DEFAULT_PROJECT_PATH, load_project, save_project
from .win32_window import WindowProvider
DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 = -4
class DesktopMarkApp:
def __init__(self) -> None:
self.qt_app = QApplication.instance() or QApplication(sys.argv)
self.qt_app.setQuitOnLastWindowClosed(False)
self.provider = WindowProvider()
self.floating_button = FloatingButton()
self.overlay = OverlayWindow(self.provider)
self.panel = AnnotationPanel()
self.form = AnnotationFormPopup()
self.hotkeys = HotkeyWindow()
self.project_path = DEFAULT_PROJECT_PATH
self.project: AnnotationProject | None = self._load_last_project(self.project_path)
self._configure_panel_geometry()
self._connect_signals()
self._exclude_tool_windows()
if self.project is not None:
self.panel.set_project(self.project)
self.overlay.set_project(self.project)
self.hotkeys.register()
self.floating_button.show()
def run(self) -> int:
return self.qt_app.exec()
def _connect_signals(self) -> None:
self.floating_button.clicked.connect(self.overlay.start_window_picking)
self.hotkeys.start_window_picking.connect(self.overlay.start_window_picking)
self.hotkeys.start_annotation.connect(self.overlay.start_annotation)
self.overlay.project_changed.connect(self._on_project_changed)
self.overlay.annotation_edit_requested.connect(self.form.edit_annotation)
self.overlay.hidden_by_user.connect(self._hide_annotation_ui)
self.panel.annotation_selected.connect(self.overlay.select_annotation)
self.panel.window_metadata_changed.connect(self._on_window_metadata_changed)
self.form.saved.connect(self._on_annotation_saved)
self.form.cancelled.connect(self.overlay.update)
self.qt_app.aboutToQuit.connect(self.hotkeys.unregister)
def _exclude_tool_windows(self) -> None:
hwnds = {
int(self.floating_button.winId()),
int(self.overlay.winId()),
int(self.panel.winId()),
int(self.form.winId()),
int(self.hotkeys.winId()),
}
self.provider.set_excluded_hwnds(hwnds)
def _configure_panel_geometry(self) -> None:
virtual = self.provider.virtual_screen_rect()
self.panel.setFixedHeight(virtual.height)
self.panel.move(virtual.x, virtual.y)
def _on_project_changed(self, project: AnnotationProject | None) -> None:
self.project = project
self.panel.set_project(project)
if project is not None:
self.panel.show()
def _on_window_metadata_changed(self, name: str, description: str) -> None:
if self.project is None:
return
self.project.custom_window_name = name
self.project.window_description = description
self._save_current_project()
def _on_annotation_saved(self, annotation) -> None: # noqa: ANN001
if self.project is None:
return
self.panel.refresh()
self.overlay.update()
self._save_current_project()
def _hide_annotation_ui(self) -> None:
self.panel.hide()
self.form.hide()
def _save_current_project(self) -> None:
if self.project is None:
return
try:
save_project(self.project, self.project_path)
except Exception as exc:
QMessageBox.warning(None, "保存失败", f"无法保存标注项目:\n{exc}")
def _load_last_project(self, path: Path) -> AnnotationProject | None:
if not path.exists():
return None
try:
return load_project(path)
except Exception as exc:
QMessageBox.warning(None, "加载失败", f"无法加载上次标注项目,将从空项目继续:\n{exc}")
return None
def _enable_dpi_awareness() -> None:
try:
ctypes.windll.user32.SetProcessDpiAwarenessContext(
DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2
)
except Exception:
pass
def main() -> int:
_enable_dpi_awareness()
QApplication.setHighDpiScaleFactorRoundingPolicy(
Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
)
app = DesktopMarkApp()
return app.run()

View File

@ -0,0 +1,79 @@
"""Small always-on-top launcher button."""
from __future__ import annotations
from PySide6.QtCore import QPoint, Qt, Signal
from PySide6.QtGui import QColor, QFont, QMouseEvent, QPainter, QPaintEvent, QPen
from PySide6.QtWidgets import QWidget
class FloatingButton(QWidget):
clicked = Signal()
def __init__(self) -> None:
super().__init__()
self.setFixedSize(80, 80)
self.setWindowTitle("Desktop Mark")
self.setWindowFlags(
Qt.WindowType.FramelessWindowHint
| Qt.WindowType.WindowStaysOnTopHint
| Qt.WindowType.Tool
)
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True)
self.setMouseTracking(True)
self._drag_start_global: QPoint | None = None
self._drag_start_pos: QPoint | None = None
self._dragging = False
self.move(32, 160)
def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
painter.setPen(QPen(QColor(255, 255, 255, 80), 1))
painter.setBrush(QColor(20, 24, 32, 210))
painter.drawRoundedRect(self.rect().adjusted(1, 1, -1, -1), 18, 18)
painter.setPen(QColor(255, 255, 255))
title_font = QFont(self.font())
title_font.setBold(True)
title_font.setPointSize(18)
painter.setFont(title_font)
painter.drawText(self.rect().adjusted(0, 12, 0, -24), Qt.AlignmentFlag.AlignCenter, "RPA")
sub_font = QFont(self.font())
sub_font.setPointSize(8)
painter.setFont(sub_font)
painter.setPen(QColor(190, 205, 255))
painter.drawText(self.rect().adjusted(0, 50, 0, -8), Qt.AlignmentFlag.AlignCenter, "Ctrl+1")
super().paintEvent(event)
def mousePressEvent(self, event: QMouseEvent) -> None: # noqa: N802
if event.button() == Qt.MouseButton.LeftButton:
self._drag_start_global = event.globalPosition().toPoint()
self._drag_start_pos = self.pos()
self._dragging = False
event.accept()
return
super().mousePressEvent(event)
def mouseMoveEvent(self, event: QMouseEvent) -> None: # noqa: N802
if self._drag_start_global is not None and self._drag_start_pos is not None:
delta = event.globalPosition().toPoint() - self._drag_start_global
if delta.manhattanLength() > 4:
self._dragging = True
self.move(self._drag_start_pos + delta)
event.accept()
return
super().mouseMoveEvent(event)
def mouseReleaseEvent(self, event: QMouseEvent) -> None: # noqa: N802
if event.button() == Qt.MouseButton.LeftButton and self._drag_start_global is not None:
was_dragging = self._dragging
self._drag_start_global = None
self._drag_start_pos = None
self._dragging = False
if not was_dragging:
self.clicked.emit()
event.accept()
return
super().mouseReleaseEvent(event)

View File

@ -0,0 +1,72 @@
"""Global hotkey bridge backed by Win32 RegisterHotKey."""
from __future__ import annotations
from ctypes import wintypes
import win32con
import win32gui
from PySide6.QtCore import Signal
from PySide6.QtWidgets import QMessageBox, QWidget
HOTKEY_PICK_WINDOW = 1
HOTKEY_START_ANNOTATION = 2
class HotkeyWindow(QWidget):
start_window_picking = Signal()
start_annotation = Signal()
def __init__(self) -> None:
super().__init__()
self._registered_ids: set[int] = set()
self.setWindowTitle("Desktop Mark Hotkeys")
self.hide()
def register(self) -> None:
hwnd = int(self.winId())
self._register_one(hwnd, HOTKEY_PICK_WINDOW, win32con.MOD_CONTROL, ord("1"), "Ctrl+1")
self._register_one(hwnd, HOTKEY_START_ANNOTATION, win32con.MOD_CONTROL, ord("R"), "Ctrl+R")
def unregister(self) -> None:
hwnd = int(self.winId())
for hotkey_id in tuple(self._registered_ids):
try:
win32gui.UnregisterHotKey(hwnd, hotkey_id)
except Exception:
pass
finally:
self._registered_ids.discard(hotkey_id)
def nativeEvent(self, eventType, message): # noqa: N802, ANN001
msg = self._message_from_native(message)
if eventType == "windows_generic_MSG" and msg.message == win32con.WM_HOTKEY:
hotkey_id = int(msg.wParam)
if hotkey_id == HOTKEY_PICK_WINDOW:
self.start_window_picking.emit()
return True, 0
if hotkey_id == HOTKEY_START_ANNOTATION:
self.start_annotation.emit()
return True, 0
return super().nativeEvent(eventType, message)
def _message_from_native(self, message): # noqa: ANN001
if hasattr(message, "message") and hasattr(message, "wParam"):
return message
return wintypes.MSG.from_address(int(message))
def closeEvent(self, event): # noqa: N802, ANN001
self.unregister()
super().closeEvent(event)
def _register_one(self, hwnd: int, hotkey_id: int, modifiers: int, key: int, label: str) -> None:
try:
win32gui.RegisterHotKey(hwnd, hotkey_id, modifiers, key)
except Exception as exc:
QMessageBox.critical(
self,
"快捷键注册失败",
f"全局快捷键 {label} 注册失败,可能已被其他程序占用。\n\n{exc}",
)
return
self._registered_ids.add(hotkey_id)

View File

@ -0,0 +1,87 @@
"""Data models and coordinate helpers for annotation projects."""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import StrEnum
from uuid import uuid4
from PySide6.QtCore import QRect
class AnnotationType(StrEnum):
BUTTON = "button"
INPUT = "input"
TEXT = "text"
IMAGE = "image"
OTHER = "other"
@dataclass(frozen=True)
class Rect:
x: int
y: int
width: int
height: int
def right(self) -> int:
return self.x + self.width
def bottom(self) -> int:
return self.y + self.height
def contains(self, x: int, y: int) -> bool:
normalized = self.normalized()
return normalized.x <= x < normalized.right() and normalized.y <= y < normalized.bottom()
def normalized(self) -> "Rect":
x1 = min(self.x, self.right())
y1 = min(self.y, self.bottom())
x2 = max(self.x, self.right())
y2 = max(self.y, self.bottom())
return Rect(x1, y1, x2 - x1, y2 - y1)
def clamped_to(self, bounds: "Rect") -> "Rect":
rect = self.normalized()
limit = bounds.normalized()
x1 = max(limit.x, min(rect.x, limit.right()))
y1 = max(limit.y, min(rect.y, limit.bottom()))
x2 = max(limit.x, min(rect.right(), limit.right()))
y2 = max(limit.y, min(rect.bottom(), limit.bottom()))
if x2 < x1:
x2 = x1
if y2 < y1:
y2 = y1
return Rect(x1, y1, x2 - x1, y2 - y1)
def to_qrect(self) -> QRect:
rect = self.normalized()
return QRect(rect.x, rect.y, rect.width, rect.height)
@dataclass(frozen=True)
class WindowInfo:
hwnd: int | None
title: str
process_name: str
rect: Rect
is_desktop: bool = False
@dataclass
class Annotation:
id: str = field(default_factory=lambda: str(uuid4()))
rect: Rect = field(default_factory=lambda: Rect(0, 0, 0, 0))
control_name: str = ""
description: str = ""
type: AnnotationType = AnnotationType.OTHER
actions: list[str] = field(default_factory=list)
feature_preview_path: str | None = None
@dataclass
class AnnotationProject:
window: WindowInfo
custom_window_name: str = ""
window_description: str = ""
annotations: list[Annotation] = field(default_factory=list)

View File

@ -0,0 +1,375 @@
"""Selection and annotation overlay state machine."""
from __future__ import annotations
from enum import StrEnum
from PySide6.QtCore import QPoint, QRect, QTimer, Qt, Signal
from PySide6.QtGui import QColor, QCursor, QFont, QKeyEvent, QMouseEvent, QPainter, QPaintEvent, QPen
from PySide6.QtWidgets import QWidget
from .models import Annotation, AnnotationProject, Rect, WindowInfo
from .win32_window import WindowProvider
MIN_ANNOTATION_SIZE = 6
HANDLE_SIZE = 8
class OverlayMode(StrEnum):
IDLE = "idle"
PICKING_WINDOW = "picking_window"
WINDOW_LOCKED = "window_locked"
DRAWING_ANNOTATION = "drawing_annotation"
EDITING_ANNOTATION = "editing_annotation"
MOVING_ANNOTATION = "moving_annotation"
RESIZING_ANNOTATION = "resizing_annotation"
class OverlayWindow(QWidget):
project_changed = Signal(object)
annotation_edit_requested = Signal(object, object)
hidden_by_user = Signal()
def __init__(self, provider: WindowProvider) -> None:
super().__init__()
self.provider = provider
self.mode = OverlayMode.IDLE
self.hover_window: WindowInfo | None = None
self.locked_window: WindowInfo | None = None
self.project: AnnotationProject | None = None
self.selected_annotation_id: str | None = None
self._virtual_rect = self.provider.virtual_screen_rect()
self._timer = QTimer(self)
self._timer.setInterval(50)
self._timer.timeout.connect(self._poll_hover_window)
self._drag_start: QPoint | None = None
self._temp_rect: Rect | None = None
self._moving_annotation: Annotation | None = None
self._moving_original: Rect | None = None
self._resizing_annotation: Annotation | None = None
self._resizing_original: Rect | None = None
self._resize_handle: str | None = None
self.setWindowFlags(
Qt.WindowType.FramelessWindowHint
| Qt.WindowType.WindowStaysOnTopHint
| Qt.WindowType.Tool
)
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True)
self.setMouseTracking(True)
self.hide()
def start_window_picking(self) -> None:
self._prepare_screen_geometry()
self.mode = OverlayMode.PICKING_WINDOW
self.hover_window = self.provider.window_at(*self._cursor_xy())
self._temp_rect = None
self._drag_start = None
self._timer.start()
self.show()
self.raise_()
self.activateWindow()
self.update()
def start_annotation(self) -> None:
if self.locked_window is None:
self.start_window_picking()
return
self._ensure_project(self.locked_window)
self.mode = OverlayMode.DRAWING_ANNOTATION
self._timer.stop()
self.show()
self.raise_()
self.activateWindow()
self.update()
def select_annotation(self, annotation_id: str) -> None:
self.selected_annotation_id = annotation_id
if self.mode not in {OverlayMode.IDLE, OverlayMode.PICKING_WINDOW}:
self.mode = OverlayMode.EDITING_ANNOTATION
self.update()
def set_project(self, project: AnnotationProject | None) -> None:
self.project = project
self.locked_window = project.window if project is not None else None
self.selected_annotation_id = None
self.update()
def cancel_flow(self) -> None:
self._timer.stop()
self.mode = OverlayMode.IDLE
self.hover_window = None
self.locked_window = None
self._temp_rect = None
self._drag_start = None
self._moving_annotation = None
self._resizing_annotation = None
self._resize_handle = None
self.hide()
self.hidden_by_user.emit()
self.update()
def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802
if self.mode == OverlayMode.IDLE:
return
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, False)
painter.fillRect(self.rect(), QColor(0, 0, 0, 120))
target = self.locked_window or self.hover_window
if target is not None:
self._clear_target_rect(painter, target.rect)
self._draw_window_highlight(painter, target)
self._draw_annotations(painter)
if self._temp_rect is not None:
self._draw_rect_outline(painter, self._temp_rect, QColor(51, 153, 255), 2)
super().paintEvent(event)
def mousePressEvent(self, event: QMouseEvent) -> None: # noqa: N802
if event.button() != Qt.MouseButton.LeftButton:
super().mousePressEvent(event)
return
point = event.globalPosition().toPoint()
if self.mode == OverlayMode.PICKING_WINDOW:
self.locked_window = self.hover_window or self.provider.window_at(point.x(), point.y())
self._timer.stop()
if self.locked_window is not None:
self._ensure_project(self.locked_window)
self.mode = OverlayMode.WINDOW_LOCKED
self.project_changed.emit(self.project)
self.update()
event.accept()
return
if self.locked_window is None:
event.accept()
return
hit_annotation, handle = self._hit_test(point.x(), point.y())
if handle is not None and hit_annotation is not None:
self.selected_annotation_id = hit_annotation.id
self._resizing_annotation = hit_annotation
self._resizing_original = hit_annotation.rect
self._resize_handle = handle
self._drag_start = point
self.mode = OverlayMode.RESIZING_ANNOTATION
self.update()
event.accept()
return
if hit_annotation is not None:
self.selected_annotation_id = hit_annotation.id
self._moving_annotation = hit_annotation
self._moving_original = hit_annotation.rect
self._drag_start = point
self.mode = OverlayMode.MOVING_ANNOTATION
self.update()
event.accept()
return
if self.locked_window.rect.contains(point.x(), point.y()):
self._drag_start = point
self._temp_rect = Rect(point.x(), point.y(), 0, 0)
self.mode = OverlayMode.DRAWING_ANNOTATION
self.update()
event.accept()
def mouseMoveEvent(self, event: QMouseEvent) -> None: # noqa: N802
point = event.globalPosition().toPoint()
if self.mode == OverlayMode.DRAWING_ANNOTATION and self._drag_start is not None:
self._temp_rect = Rect(
self._drag_start.x(),
self._drag_start.y(),
point.x() - self._drag_start.x(),
point.y() - self._drag_start.y(),
).normalized().clamped_to(self.locked_window.rect) # type: ignore[union-attr]
self.update()
event.accept()
return
if self.mode == OverlayMode.MOVING_ANNOTATION and self._moving_annotation is not None and self._moving_original is not None and self._drag_start is not None:
dx = point.x() - self._drag_start.x()
dy = point.y() - self._drag_start.y()
moved = self._move_rect_clamped(
self._moving_original,
dx,
dy,
self.locked_window.rect, # type: ignore[union-attr]
)
self._moving_annotation.rect = moved
self.project_changed.emit(self.project)
self.update()
event.accept()
return
if self.mode == OverlayMode.RESIZING_ANNOTATION and self._resizing_annotation is not None and self._resizing_original is not None and self._drag_start is not None:
resized = self._resize_rect(self._resizing_original, self._resize_handle or "", point)
self._resizing_annotation.rect = resized.clamped_to(self.locked_window.rect) # type: ignore[union-attr]
self.project_changed.emit(self.project)
self.update()
event.accept()
return
super().mouseMoveEvent(event)
def mouseReleaseEvent(self, event: QMouseEvent) -> None: # noqa: N802
if event.button() != Qt.MouseButton.LeftButton:
super().mouseReleaseEvent(event)
return
if self.mode == OverlayMode.DRAWING_ANNOTATION and self._temp_rect is not None:
rect = self._temp_rect.normalized().clamped_to(self.locked_window.rect) # type: ignore[union-attr]
self._drag_start = None
self._temp_rect = None
if rect.width >= MIN_ANNOTATION_SIZE and rect.height >= MIN_ANNOTATION_SIZE:
annotation = Annotation(rect=rect)
self._ensure_project(self.locked_window) # type: ignore[arg-type]
self.project.annotations.append(annotation) # type: ignore[union-attr]
self.selected_annotation_id = annotation.id
self.mode = OverlayMode.EDITING_ANNOTATION
self.project_changed.emit(self.project)
self.annotation_edit_requested.emit(annotation, rect)
else:
self.mode = OverlayMode.DRAWING_ANNOTATION
self.update()
event.accept()
return
if self.mode in {OverlayMode.MOVING_ANNOTATION, OverlayMode.RESIZING_ANNOTATION}:
edited = self._moving_annotation or self._resizing_annotation
self._moving_annotation = None
self._moving_original = None
self._resizing_annotation = None
self._resizing_original = None
self._resize_handle = None
self._drag_start = None
self.mode = OverlayMode.EDITING_ANNOTATION
self.project_changed.emit(self.project)
if edited is not None:
self.annotation_edit_requested.emit(edited, edited.rect)
self.update()
event.accept()
return
super().mouseReleaseEvent(event)
def keyPressEvent(self, event: QKeyEvent) -> None: # noqa: N802
if event.key() in {Qt.Key.Key_Escape, Qt.Key.Key_Return, Qt.Key.Key_Enter}:
self.cancel_flow()
event.accept()
return
super().keyPressEvent(event)
def _prepare_screen_geometry(self) -> None:
self._virtual_rect = self.provider.virtual_screen_rect()
self.setGeometry(self._virtual_rect.to_qrect())
def _poll_hover_window(self) -> None:
if self.mode != OverlayMode.PICKING_WINDOW:
return
x, y = self._cursor_xy()
self.hover_window = self.provider.window_at(x, y)
self.update()
def _cursor_xy(self) -> tuple[int, int]:
pos = QCursor.pos()
return pos.x(), pos.y()
def _ensure_project(self, window: WindowInfo) -> None:
if self.project is None or self.project.window != window:
self.project = AnnotationProject(window=window)
self.locked_window = window
def _clear_target_rect(self, painter: QPainter, rect: Rect) -> None:
painter.save()
painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Clear)
painter.fillRect(self._to_local_qrect(rect), Qt.GlobalColor.transparent)
painter.restore()
def _draw_window_highlight(self, painter: QPainter, window: WindowInfo) -> None:
self._draw_rect_outline(painter, window.rect, QColor(0, 122, 255), 2)
qrect = self._to_local_qrect(window.rect)
label = f"{window.title} [{window.rect.x}, {window.rect.y}, {window.rect.width}, {window.rect.height}]"
painter.setFont(QFont(self.font().family(), 9))
metrics = painter.fontMetrics()
label_rect = metrics.boundingRect(label).adjusted(-8, -4, 8, 4)
label_rect.moveTopRight(qrect.topRight() + QPoint(-6, 6))
painter.fillRect(label_rect, QColor(0, 122, 255, 220))
painter.setPen(QColor(255, 255, 255))
painter.drawText(label_rect.adjusted(8, 4, -8, -4), Qt.AlignmentFlag.AlignCenter, label)
def _draw_annotations(self, painter: QPainter) -> None:
if self.project is None:
return
for index, annotation in enumerate(self.project.annotations, start=1):
selected = annotation.id == self.selected_annotation_id
self._draw_rect_outline(painter, annotation.rect, QColor(255, 145, 0), 2)
qrect = self._to_local_qrect(annotation.rect)
painter.fillRect(QRect(qrect.left(), qrect.top() - 18, 24, 18), QColor(255, 145, 0, 230))
painter.setPen(QColor(255, 255, 255))
painter.drawText(QRect(qrect.left(), qrect.top() - 18, 24, 18), Qt.AlignmentFlag.AlignCenter, str(index))
if selected:
for handle in self._handle_rects(annotation.rect).values():
painter.fillRect(self._to_local_qrect(handle), QColor(255, 255, 255))
painter.setPen(QPen(QColor(0, 122, 255), 1))
painter.drawRect(self._to_local_qrect(handle))
def _draw_rect_outline(self, painter: QPainter, rect: Rect, color: QColor, width: int) -> None:
painter.setPen(QPen(color, width))
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawRect(self._to_local_qrect(rect).adjusted(0, 0, -1, -1))
def _to_local_qrect(self, rect: Rect) -> QRect:
normalized = rect.normalized()
return QRect(
normalized.x - self._virtual_rect.x,
normalized.y - self._virtual_rect.y,
normalized.width,
normalized.height,
)
def _hit_test(self, x: int, y: int) -> tuple[Annotation | None, str | None]:
if self.project is None:
return None, None
for annotation in reversed(self.project.annotations):
for name, handle_rect in self._handle_rects(annotation.rect).items():
if handle_rect.contains(x, y):
return annotation, name
if annotation.rect.contains(x, y):
return annotation, None
return None, None
def _handle_rects(self, rect: Rect) -> dict[str, Rect]:
r = rect.normalized()
half = HANDLE_SIZE // 2
cx = r.x + r.width // 2
cy = r.y + r.height // 2
points = {
"nw": (r.x, r.y),
"n": (cx, r.y),
"ne": (r.right(), r.y),
"e": (r.right(), cy),
"se": (r.right(), r.bottom()),
"s": (cx, r.bottom()),
"sw": (r.x, r.bottom()),
"w": (r.x, cy),
}
return {name: Rect(px - half, py - half, HANDLE_SIZE, HANDLE_SIZE) for name, (px, py) in points.items()}
def _move_rect_clamped(self, original: Rect, dx: int, dy: int, bounds: Rect) -> Rect:
rect = original.normalized()
limit = bounds.normalized()
max_x = max(limit.x, limit.right() - rect.width)
max_y = max(limit.y, limit.bottom() - rect.height)
x = max(limit.x, min(rect.x + dx, max_x))
y = max(limit.y, min(rect.y + dy, max_y))
return Rect(x, y, rect.width, rect.height)
def _resize_rect(self, original: Rect, handle: str, point: QPoint) -> Rect:
left = original.x
top = original.y
right = original.right()
bottom = original.bottom()
if "w" in handle:
left = min(point.x(), right - MIN_ANNOTATION_SIZE)
if "e" in handle:
right = max(point.x(), left + MIN_ANNOTATION_SIZE)
if "n" in handle:
top = min(point.y(), bottom - MIN_ANNOTATION_SIZE)
if "s" in handle:
bottom = max(point.y(), top + MIN_ANNOTATION_SIZE)
return Rect(left, top, right - left, bottom - top)

View File

@ -0,0 +1,93 @@
"""JSON persistence for annotation projects."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from .models import Annotation, AnnotationProject, AnnotationType, Rect, WindowInfo
DEFAULT_PROJECT_PATH = Path.home() / ".desktop_mark" / "last_project.json"
def rect_to_dict(rect: Rect) -> dict[str, int]:
return {"x": rect.x, "y": rect.y, "width": rect.width, "height": rect.height}
def rect_from_dict(data: dict[str, Any]) -> Rect:
return Rect(
x=int(data["x"]),
y=int(data["y"]),
width=int(data["width"]),
height=int(data["height"]),
)
def project_to_dict(project: AnnotationProject) -> dict[str, Any]:
return {
"window": {
"hwnd": project.window.hwnd,
"title": project.window.title,
"process_name": project.window.process_name,
"rect": rect_to_dict(project.window.rect),
"is_desktop": project.window.is_desktop,
},
"custom_window_name": project.custom_window_name,
"window_description": project.window_description,
"annotations": [
{
"id": annotation.id,
"rect": rect_to_dict(annotation.rect),
"control_name": annotation.control_name,
"description": annotation.description,
"type": annotation.type.value,
"actions": list(annotation.actions),
"feature_preview_path": annotation.feature_preview_path,
}
for annotation in project.annotations
],
}
def project_from_dict(data: dict[str, Any]) -> AnnotationProject:
window_data = data["window"]
window = WindowInfo(
hwnd=window_data.get("hwnd"),
title=str(window_data.get("title", "")),
process_name=str(window_data.get("process_name", "")),
rect=rect_from_dict(window_data["rect"]),
is_desktop=bool(window_data.get("is_desktop", False)),
)
annotations = []
for item in data.get("annotations", []):
annotation_type = AnnotationType(item.get("type", AnnotationType.OTHER.value))
annotations.append(
Annotation(
id=str(item["id"]),
rect=rect_from_dict(item["rect"]),
control_name=str(item.get("control_name", "")),
description=str(item.get("description", "")),
type=annotation_type,
actions=[str(action) for action in item.get("actions", [])],
feature_preview_path=item.get("feature_preview_path"),
)
)
return AnnotationProject(
window=window,
custom_window_name=str(data.get("custom_window_name", "")),
window_description=str(data.get("window_description", "")),
annotations=annotations,
)
def save_project(project: AnnotationProject, path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(project_to_dict(project), ensure_ascii=False, indent=2),
encoding="utf-8",
)
def load_project(path: Path) -> AnnotationProject:
return project_from_dict(json.loads(path.read_text(encoding="utf-8")))

View File

@ -0,0 +1,118 @@
"""Win32 top-level window discovery."""
from __future__ import annotations
from pathlib import Path
import win32api
import win32con
import win32gui
import win32process
from .models import Rect, WindowInfo
PROCESS_QUERY_LIMITED_INFORMATION = getattr(
win32con, "PROCESS_QUERY_LIMITED_INFORMATION", 0x1000
)
class WindowProvider:
def __init__(self, excluded_hwnds: set[int] | None = None):
self._excluded_hwnds: set[int] = set(excluded_hwnds or set())
def set_excluded_hwnds(self, hwnds: set[int]) -> None:
self._excluded_hwnds = set(hwnds)
def window_at(self, x: int, y: int) -> WindowInfo:
for hwnd in self._iter_top_level_windows():
if not self._is_candidate_window(hwnd):
continue
rect = self._rect_for_hwnd(hwnd)
if rect is not None and rect.contains(x, y):
return self._window_info(hwnd, rect)
return self._desktop_window()
def foreground_window(self) -> WindowInfo | None:
hwnd = win32gui.GetForegroundWindow()
if not hwnd or not self._is_candidate_window(hwnd):
return None
rect = self._rect_for_hwnd(hwnd)
if rect is None:
return None
return self._window_info(hwnd, rect)
def virtual_screen_rect(self) -> Rect:
return Rect(
win32api.GetSystemMetrics(win32con.SM_XVIRTUALSCREEN),
win32api.GetSystemMetrics(win32con.SM_YVIRTUALSCREEN),
win32api.GetSystemMetrics(win32con.SM_CXVIRTUALSCREEN),
win32api.GetSystemMetrics(win32con.SM_CYVIRTUALSCREEN),
)
def _iter_top_level_windows(self):
hwnd = win32gui.GetTopWindow(None)
while hwnd:
yield hwnd
hwnd = win32gui.GetWindow(hwnd, win32con.GW_HWNDNEXT)
def _is_candidate_window(self, hwnd: int) -> bool:
if hwnd in self._excluded_hwnds:
return False
if not win32gui.IsWindowVisible(hwnd):
return False
if win32gui.IsIconic(hwnd):
return False
ex_style = win32gui.GetWindowLong(hwnd, win32con.GWL_EXSTYLE)
if ex_style & win32con.WS_EX_TOOLWINDOW:
return False
rect = self._rect_for_hwnd(hwnd)
return rect is not None and rect.width > 0 and rect.height > 0
def _rect_for_hwnd(self, hwnd: int) -> Rect | None:
try:
left, top, right, bottom = win32gui.GetWindowRect(hwnd)
except win32gui.error:
return None
width = right - left
height = bottom - top
if width <= 0 or height <= 0:
return None
return Rect(left, top, width, height)
def _window_info(self, hwnd: int, rect: Rect) -> WindowInfo:
title = win32gui.GetWindowText(hwnd) or "Untitled"
return WindowInfo(
hwnd=hwnd,
title=title,
process_name=self._process_name(hwnd),
rect=rect,
is_desktop=False,
)
def _desktop_window(self) -> WindowInfo:
return WindowInfo(
hwnd=None,
title="Desktop",
process_name="explorer.exe",
rect=self.virtual_screen_rect(),
is_desktop=True,
)
def _process_name(self, hwnd: int) -> str:
process_handle = None
try:
_, pid = win32process.GetWindowThreadProcessId(hwnd)
process_handle = win32api.OpenProcess(
PROCESS_QUERY_LIMITED_INFORMATION, False, pid
)
executable = win32process.GetModuleFileNameEx(process_handle, 0)
return Path(executable).name
except Exception:
return ""
finally:
if process_handle is not None:
try:
win32api.CloseHandle(process_handle)
except Exception:
pass

View File

@ -0,0 +1,26 @@
from desktop_mark.models import Rect
def test_normalized_keeps_positive_drag() -> None:
assert Rect(10, 20, 30, 40).normalized() == Rect(10, 20, 30, 40)
def test_normalized_handles_negative_drag() -> None:
assert Rect(50, 60, -20, -30).normalized() == Rect(30, 30, 20, 30)
def test_clamped_to_bounds() -> None:
rect = Rect(80, 90, 50, 40).clamped_to(Rect(100, 100, 100, 100))
assert rect == Rect(100, 100, 30, 30)
def test_clamped_negative_drag_to_bounds() -> None:
rect = Rect(250, 250, -200, -200).clamped_to(Rect(100, 100, 100, 100))
assert rect == Rect(100, 100, 100, 100)
def test_contains_uses_normalized_half_open_bounds() -> None:
rect = Rect(20, 20, -10, -10)
assert rect.contains(10, 10)
assert rect.contains(19, 19)
assert not rect.contains(20, 20)

View File

@ -0,0 +1,24 @@
from desktop_mark.models import Annotation, AnnotationProject, AnnotationType, Rect, WindowInfo
from desktop_mark.storage import project_from_dict, project_to_dict
def test_project_json_round_trip() -> None:
project = AnnotationProject(
window=WindowInfo(123, "记事本", "notepad.exe", Rect(1, 2, 300, 400)),
custom_window_name="登录页",
window_description="用户登录窗口",
annotations=[
Annotation(
id="a1",
rect=Rect(10, 20, 30, 40),
control_name="用户名输入框",
description="输入登录用户名",
type=AnnotationType.INPUT,
actions=["input_text"],
)
],
)
restored = project_from_dict(project_to_dict(project))
assert restored == project

168
desktop_mark/python/uv.lock generated Normal file
View File

@ -0,0 +1,168 @@
version = 1
revision = 3
requires-python = ">=3.11"
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "desktop-mark"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "pyside6" },
{ name = "pywin32" },
]
[package.optional-dependencies]
dev = [
{ name = "pytest" },
]
[package.metadata]
requires-dist = [
{ name = "pyside6", specifier = ">=6.7,<7" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8,<9" },
{ name = "pywin32", specifier = ">=306" },
]
provides-extras = ["dev"]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "packaging"
version = "26.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pyside6"
version = "6.11.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyside6-addons" },
{ name = "pyside6-essentials" },
{ name = "shiboken6" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/a6/27ba5947ed48918f7b74b7c43a1e280aac069e36f25adeb4c9adfac835c4/pyside6-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:537682c3b7530817203e667c1f5a2f00486b37bf52c52eeab438544c7a0917f6", size = 571921, upload-time = "2026-05-13T09:47:36.402Z" },
{ url = "https://files.pythonhosted.org/packages/d8/de/af89d71410c83b10654d86ff9aff2a4f87c30163658f1cc145242e222526/pyside6-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b1fc521ba2bb5109425ab8add06bddbdd524abcad06cfa012cc39a22a189feb2", size = 572102, upload-time = "2026-05-13T09:47:38.249Z" },
{ url = "https://files.pythonhosted.org/packages/b6/0e/d583bd3f7bf5046a4497b36f3902cfb64aa29554489a5a25c18e6b4ac0ac/pyside6-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:75f0005c3eb95c07cfb65522ec50d0815ac007a96482c21dc3cb4b4c04895d84", size = 572098, upload-time = "2026-05-13T09:47:39.44Z" },
{ url = "https://files.pythonhosted.org/packages/57/f2/d9d8ce1373dabb37e5919f63cd18446556079631d3f2eea3ada03c29f6b8/pyside6-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:0968877ab1fb4ef3587a284da6fe05e8647ada56a6a3750b6395188e01f4aba6", size = 578377, upload-time = "2026-05-13T09:47:40.76Z" },
{ url = "https://files.pythonhosted.org/packages/96/02/a6057d8bd2bdb1940820fff2d627fdf4013148c9c57adf69fa40d3452ac3/pyside6-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:acee467cb5f256cc47ebb9d815a054c1d8416da380c191b247a76d164aa3f805", size = 561765, upload-time = "2026-05-13T09:47:41.9Z" },
]
[[package]]
name = "pyside6-addons"
version = "6.11.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyside6-essentials" },
{ name = "shiboken6" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/3f/6b/8bc94aff48b63f788f2d84e5467c12362d68906ba742c0942f46cb04c879/pyside6_addons-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:54733c77f789bef5f03c6aff4ad3bec8b2eff021f0cfcbc53d5e6c250ded24f9", size = 331714589, upload-time = "2026-05-13T09:39:12.36Z" },
{ url = "https://files.pythonhosted.org/packages/dd/62/fb1428a523b2a4541e232aab50d9e789e6b4526f37fd9593452a7ea5b6b3/pyside6_addons-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:8e6c65fbd73a512d6f72cda8d8277444a85a34dc99dd1dae9c21d35b8671bb1f", size = 175063224, upload-time = "2026-05-13T09:39:34.185Z" },
{ url = "https://files.pythonhosted.org/packages/ee/9b/2ccd52f66db55c06de65d0501170a1935d04d64d0a230c0d892284a02ce3/pyside6_addons-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:bf1c6c4e954e5eba3d2a7c661ad4b9689e8f09c7f4a16bdf29713371d11af993", size = 170553429, upload-time = "2026-05-13T09:39:54.424Z" },
{ url = "https://files.pythonhosted.org/packages/9a/bd/8adc4d350b3b363f3dfc8fccdcf5bfed25f7e36c2fff30c64e106f4f1572/pyside6_addons-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:0d13c4dfd671b050a48e4f8d8ddc724b7248f9c0437e7fc47fdf316278572923", size = 168816308, upload-time = "2026-05-13T09:40:13.541Z" },
{ url = "https://files.pythonhosted.org/packages/65/b7/9a840d97f0f0f04e372a87e205dd30ee285b4e3b021b188459a917c9dc76/pyside6_addons-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:3494f480dee92f415be2f2d989c0b3f4755ac332b28045cbf4ba0f5c5a22ba37", size = 35759347, upload-time = "2026-05-13T09:40:21.199Z" },
]
[[package]]
name = "pyside6-essentials"
version = "6.11.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "shiboken6" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/da/10d9197e7370eb4fed8df5fc547b7548dec88e5c5949e2d450db4ae96feb/pyside6_essentials-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:228de53c2bc26b07e5021fbe3614fc44ca08e4dab9999af08c2b389d2c239957", size = 110352945, upload-time = "2026-05-13T09:43:08.006Z" },
{ url = "https://files.pythonhosted.org/packages/5c/49/0e1237c4400bec7e335d2c4eeb49bc40d9fd88a9ac44ca9083ce1abdc308/pyside6_essentials-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:e3ef7027b41e4e55fadb56e3b3257dc8ee92154b639fe67fc4c8e05e9d976c60", size = 79908535, upload-time = "2026-05-13T09:43:24.836Z" },
{ url = "https://files.pythonhosted.org/packages/4c/c5/da4c5f23c6540ac5211a1f60177c8dee84b1bf40f2719479587ab8c60731/pyside6_essentials-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:a039b6da68a3a4b9d243217b2b98d475eed3f617159ef6be925badab53c11b0d", size = 78960051, upload-time = "2026-05-13T09:43:35.423Z" },
{ url = "https://files.pythonhosted.org/packages/64/0e/b663ecc96ca57b5c91b83b6615d6b174380b0faf30338125c26e053d6aa7/pyside6_essentials-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:63311bd48e32c584599ab04b9ef7c324082374cd2c9fa533f978fb893bb47e40", size = 77549267, upload-time = "2026-05-13T09:43:44.92Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/eb6723faf5cb7fa581145da1c15f40d641b96e080f0491af2f1859fdeedb/pyside6_essentials-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:11253ea52aabecefe9febddbbe78b43a824129e3af1cec98431028fba7fa954f", size = 57964512, upload-time = "2026-05-13T09:43:52.968Z" },
]
[[package]]
name = "pytest"
version = "8.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
]
[[package]]
name = "pywin32"
version = "312"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1f/f5/10a6e845a00fc5e7afd0a988b744f403d4d57162a28d160a093c4d9322f0/pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c", size = 6362659, upload-time = "2026-06-04T07:49:21.349Z" },
{ url = "https://files.pythonhosted.org/packages/35/c4/dcd2d62b5944b6d5db53413a5899016ccd57ffcb7278f3f81655d25d2027/pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a", size = 6928825, upload-time = "2026-06-04T07:49:23.934Z" },
{ url = "https://files.pythonhosted.org/packages/b7/56/3cbb433fe4501cdba2eb9040f56a4e1a8243faa4186b25295564d1a7a79d/pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47", size = 6721875, upload-time = "2026-06-04T07:49:26.416Z" },
{ url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" },
{ url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" },
{ url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" },
{ url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" },
{ url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" },
{ url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" },
{ url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" },
{ url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" },
{ url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" },
{ url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" },
{ url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" },
{ url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" },
]
[[package]]
name = "shiboken6"
version = "6.11.1"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/17/f3/f2b63df0251e7cd3172ea28e32ede52739de9566bcefcd0178681538ac81/shiboken6-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:1a16867f103ef1c662a5f09dfed03273a9f81688b174555162c58e83650a3f02", size = 476874, upload-time = "2026-05-13T09:47:01.091Z" },
{ url = "https://files.pythonhosted.org/packages/c7/9b/e0355d8897b5c150770f1d95718aad17d432fcc9c035c04f3f58427d4693/shiboken6-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9a8bccfafc8805254cabcfa1edfaf55cd52889f4998c91ad0d9a4433fb1bcdbe", size = 272222, upload-time = "2026-05-13T09:47:02.653Z" },
{ url = "https://files.pythonhosted.org/packages/57/d5/dd4f1defed400be03340f2ede34b61f846776650b4e7ed9ebaf4c71979a2/shiboken6-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:1bd2f4314414df2d122d9f646e03b731bc6d6b5f77a5f53f99a4fe4e97d84e6f", size = 270350, upload-time = "2026-05-13T09:47:04.02Z" },
{ url = "https://files.pythonhosted.org/packages/52/b5/3f6fb2ee65b534193fb4ef713dd619dc31dadff5d12c16979a7699ad58be/shiboken6-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:c2c6863aa80ec18c0f82cea3417837b279cdc60024ac17123461dc9042577df7", size = 1223647, upload-time = "2026-05-13T09:47:05.924Z" },
{ url = "https://files.pythonhosted.org/packages/98/d1/f15ca0e1666faae02c945f48e745ea35f8fcd8243b176109b4e2c4251f47/shiboken6-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:7c8d9af17db4495d4fa5b1c393f218311c4855546b9dfa6a0bd21bcd66b55e9d", size = 1784170, upload-time = "2026-05-13T09:47:07.617Z" },
]

View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Desktop Mark</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1197
desktop_mark/tauri/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1 @@
{"scripts":{"dev":"tauri dev","build":"tauri build","typecheck":"tsc --noEmit"},"dependencies":{"@tauri-apps/api":"^2.0.0","@vitejs/plugin-react":"latest","vite":"latest","typescript":"latest","react":"latest","react-dom":"latest"},"devDependencies":{"@tauri-apps/cli":"^2.0.0","@types/react":"latest","@types/react-dom":"latest"}}

4568
desktop_mark/tauri/src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,17 @@
[package]
name = "desktop-mark-tauri"
version = "0.1.0"
description = "Rust Tauri rewrite of Desktop Mark"
authors = ["Desktop Mark"]
edition = "2021"
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
dirs = "6"
tauri-plugin-global-shortcut = "2"
windows = { version = "0.58", features = ["Win32_Foundation", "Win32_UI_WindowsAndMessaging", "Win32_UI_HiDpi", "Win32_System_SystemInformation", "Win32_System_Threading", "Win32_System_ProcessStatus", "Win32_System_SystemServices"] }

View File

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@ -0,0 +1,16 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Desktop Mark windows and IPC permissions",
"windows": ["floating", "overlay", "panel", "annotation_form"],
"permissions": [
"core:event:default",
"core:window:default",
"core:window:allow-show",
"core:window:allow-hide",
"core:window:allow-set-focus",
"core:window:allow-set-position",
"core:window:allow-set-size",
"core:window:allow-start-dragging"
]
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
{"default":{"identifier":"default","description":"Desktop Mark windows and IPC permissions","local":true,"windows":["floating","overlay","panel","annotation_form"],"permissions":["core:event:default","core:window:default","core:window:allow-show","core:window:allow-hide","core:window:allow-set-focus","core:window:allow-set-position","core:window:allow-set-size","core:window:allow-start-dragging"]}}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 B

View File

@ -0,0 +1,30 @@
use tauri::AppHandle;
use crate::models::{AnnotationProject, Rect, WindowInfo};
use crate::{storage, win32_window, window_manager};
#[tauri::command]
pub fn save_project(project: AnnotationProject) -> Result<(), String> { storage::save_project(&project) }
#[tauri::command]
pub fn load_project() -> Result<Option<AnnotationProject>, String> { storage::load_project() }
#[tauri::command]
pub fn get_virtual_screen_rect() -> Rect { win32_window::virtual_screen_rect() }
#[tauri::command]
pub fn window_at(x: i32, y: i32, excluded_hwnds: Vec<isize>) -> WindowInfo { win32_window::window_at(x, y, &excluded_hwnds) }
#[tauri::command]
pub fn foreground_window(excluded_hwnds: Vec<isize>) -> Option<WindowInfo> { win32_window::foreground_window(&excluded_hwnds) }
#[tauri::command]
pub fn cursor_position() -> (i32, i32) { win32_window::cursor_position() }
#[tauri::command]
pub fn tool_window_hwnds(app: AppHandle) -> Result<Vec<isize>, String> { Ok(window_manager::tool_window_hwnds(&app)) }
#[tauri::command]
pub fn exit_app(app: AppHandle) {
app.exit(0);
}

View File

@ -0,0 +1,24 @@
use tauri::{AppHandle, Emitter};
use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut};
pub fn register(app: &AppHandle) -> Vec<String> {
let mut errors = Vec::new();
let ctrl_1 = Shortcut::new(Some(Modifiers::CONTROL), Code::Digit1);
let ctrl_r = Shortcut::new(Some(Modifiers::CONTROL), Code::KeyR);
if app.global_shortcut().register(ctrl_1).is_err() {
errors.push("全局快捷键 Ctrl+1 注册失败,可能已被其他程序占用。".to_string());
}
if app.global_shortcut().register(ctrl_r).is_err() {
errors.push("全局快捷键 Ctrl+R 注册失败,可能已被其他程序占用。".to_string());
}
errors
}
pub fn unregister(app: &AppHandle) {
let _ = app.global_shortcut().unregister_all();
}
pub fn emit_registration_errors(app: &AppHandle, errors: Vec<String>) {
for error in errors { let _ = app.emit("hotkey:registration-error", error); }
}

View File

@ -0,0 +1,54 @@
mod commands;
mod hotkeys;
mod models;
mod rect;
mod storage;
mod win32_window;
mod window_manager;
use tauri::{Emitter, Manager};
use tauri_plugin_global_shortcut::{Code, Modifiers, Shortcut, ShortcutState};
fn main() {
win32_window::set_process_dpi_awareness();
tauri::Builder::default()
.plugin(
tauri_plugin_global_shortcut::Builder::new()
.with_handler(|app, shortcut, event| {
if event.state() != ShortcutState::Pressed { return; }
let ctrl_1 = Shortcut::new(Some(Modifiers::CONTROL), Code::Digit1);
let ctrl_r = Shortcut::new(Some(Modifiers::CONTROL), Code::KeyR);
if shortcut == &ctrl_1 {
let _ = app.emit("hotkey:start-window-picking", ());
} else if shortcut == &ctrl_r {
let _ = app.emit("hotkey:start-annotation", ());
}
})
.build(),
)
.invoke_handler(tauri::generate_handler![
commands::save_project,
commands::load_project,
commands::get_virtual_screen_rect,
commands::window_at,
commands::foreground_window,
commands::cursor_position,
commands::tool_window_hwnds,
commands::exit_app,
])
.setup(|app| {
let handle = app.handle().clone();
window_manager::create_all_windows(&handle)?;
let errors = hotkeys::register(&handle);
hotkeys::emit_registration_errors(&handle, errors);
Ok(())
})
.on_window_event(|window, event| {
if matches!(event, tauri::WindowEvent::Destroyed) {
if window.label() == "floating" { hotkeys::unregister(&window.app_handle()); }
}
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View File

@ -0,0 +1,48 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub struct Rect {
pub x: i32,
pub y: i32,
pub width: i32,
pub height: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WindowInfo {
pub hwnd: Option<isize>,
pub title: String,
pub process_name: String,
pub rect: Rect,
pub is_desktop: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AnnotationType {
Button,
Input,
Text,
Image,
Other,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Annotation {
pub id: String,
pub rect: Rect,
pub control_name: String,
pub description: String,
#[serde(rename = "type")]
pub annotation_type: AnnotationType,
pub actions: Vec<String>,
pub feature_preview_path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AnnotationProject {
pub window: WindowInfo,
pub custom_window_name: String,
pub window_description: String,
pub annotations: Vec<Annotation>,
}

View File

@ -0,0 +1,86 @@
use crate::models::Rect;
impl Rect {
pub fn right(&self) -> i32 { self.x + self.width }
pub fn bottom(&self) -> i32 { self.y + self.height }
pub fn contains(&self, x: i32, y: i32) -> bool {
let rect = self.normalized();
rect.x <= x && x < rect.right() && rect.y <= y && y < rect.bottom()
}
pub fn normalized(&self) -> Rect {
let x1 = self.x.min(self.right());
let y1 = self.y.min(self.bottom());
let x2 = self.x.max(self.right());
let y2 = self.y.max(self.bottom());
Rect { x: x1, y: y1, width: x2 - x1, height: y2 - y1 }
}
pub fn clamped_to(&self, bounds: Rect) -> Rect {
let rect = self.normalized();
let limit = bounds.normalized();
let x1 = limit.x.max(rect.x.min(limit.right()));
let y1 = limit.y.max(rect.y.min(limit.bottom()));
let mut x2 = limit.x.max(rect.right().min(limit.right()));
let mut y2 = limit.y.max(rect.bottom().min(limit.bottom()));
if x2 < x1 { x2 = x1; }
if y2 < y1 { y2 = y1; }
Rect { x: x1, y: y1, width: x2 - x1, height: y2 - y1 }
}
pub fn move_clamped(&self, dx: i32, dy: i32, bounds: Rect) -> Rect {
let rect = self.normalized();
let limit = bounds.normalized();
let max_x = limit.x.max(limit.right() - rect.width);
let max_y = limit.y.max(limit.bottom() - rect.height);
let x = limit.x.max((rect.x + dx).min(max_x));
let y = limit.y.max((rect.y + dy).min(max_y));
Rect { x, y, width: rect.width, height: rect.height }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn positive_drag_normalization() {
assert_eq!(Rect { x: 10, y: 20, width: 30, height: 40 }.normalized(), Rect { x: 10, y: 20, width: 30, height: 40 });
}
#[test]
fn negative_drag_normalization() {
assert_eq!(Rect { x: 40, y: 60, width: -30, height: -40 }.normalized(), Rect { x: 10, y: 20, width: 30, height: 40 });
}
#[test]
fn clamps_shrinking_corners() {
let bounds = Rect { x: 100, y: 100, width: 100, height: 100 };
let input = Rect { x: 80, y: 90, width: 50, height: 40 };
assert_eq!(input.clamped_to(bounds), Rect { x: 100, y: 100, width: 30, height: 30 });
}
#[test]
fn clamps_negative_drag_to_bounds() {
let bounds = Rect { x: 100, y: 100, width: 100, height: 100 };
let input = Rect { x: 250, y: 250, width: -200, height: -200 };
assert_eq!(input.clamped_to(bounds), Rect { x: 100, y: 100, width: 100, height: 100 });
}
#[test]
fn contains_is_half_open_after_normalization() {
let rect = Rect { x: 20, y: 20, width: -10, height: -10 };
assert!(rect.contains(10, 10));
assert!(rect.contains(19, 19));
assert!(!rect.contains(20, 20));
assert!(!rect.contains(9, 10));
}
#[test]
fn move_clamped_preserves_size_at_edges() {
let bounds = Rect { x: 100, y: 100, width: 100, height: 100 };
let input = Rect { x: 150, y: 150, width: 40, height: 30 };
assert_eq!(input.move_clamped(80, 80, bounds), Rect { x: 160, y: 170, width: 40, height: 30 });
}
}

View File

@ -0,0 +1,24 @@
use std::fs;
use std::path::PathBuf;
use crate::models::AnnotationProject;
pub fn default_project_path() -> Result<PathBuf, String> {
let home = dirs::home_dir().ok_or_else(|| "无法定位用户目录".to_string())?;
Ok(home.join(".desktop_mark").join("last_project.json"))
}
pub fn save_project(project: &AnnotationProject) -> Result<(), String> {
let path = default_project_path()?;
let parent = path.parent().ok_or_else(|| "无法定位项目目录".to_string())?;
fs::create_dir_all(parent).map_err(|err| format!("无法创建项目目录:{err}"))?;
let json = serde_json::to_string_pretty(project).map_err(|err| format!("无法序列化标注项目:{err}"))?;
fs::write(&path, json).map_err(|err| format!("无法保存标注项目:{err}"))
}
pub fn load_project() -> Result<Option<AnnotationProject>, String> {
let path = default_project_path()?;
if !path.exists() { return Ok(None); }
let text = fs::read_to_string(&path).map_err(|err| format!("无法读取标注项目:{err}"))?;
serde_json::from_str(&text).map(Some).map_err(|err| format!("无法解析标注项目:{err}"))
}

View File

@ -0,0 +1,121 @@
use std::path::Path;
use windows::core::PWSTR;
use windows::Win32::Foundation::{CloseHandle, BOOL, HWND, POINT, RECT};
use windows::Win32::System::Threading::{OpenProcess, QueryFullProcessImageNameW, PROCESS_NAME_FORMAT, PROCESS_QUERY_LIMITED_INFORMATION};
use windows::Win32::UI::HiDpi::{SetProcessDpiAwarenessContext, DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2};
use windows::Win32::UI::WindowsAndMessaging::{
GetCursorPos, GetForegroundWindow, GetSystemMetrics, GetTopWindow, GetWindow,
GetWindowLongPtrW, GetWindowRect, GetWindowTextLengthW, GetWindowTextW,
GetWindowThreadProcessId, IsIconic, IsWindowVisible, GW_HWNDNEXT, GWL_EXSTYLE,
SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN,
WS_EX_TOOLWINDOW,
};
use crate::models::{Rect, WindowInfo};
pub fn set_process_dpi_awareness() {
unsafe { let _ = SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); }
}
pub fn virtual_screen_rect() -> Rect {
unsafe {
Rect {
x: GetSystemMetrics(SM_XVIRTUALSCREEN),
y: GetSystemMetrics(SM_YVIRTUALSCREEN),
width: GetSystemMetrics(SM_CXVIRTUALSCREEN),
height: GetSystemMetrics(SM_CYVIRTUALSCREEN),
}
}
}
pub fn cursor_position() -> (i32, i32) {
let mut point = POINT::default();
unsafe { let _ = GetCursorPos(&mut point); }
(point.x, point.y)
}
pub fn window_at(x: i32, y: i32, excluded_hwnds: &[isize]) -> WindowInfo {
let mut hwnd = unsafe { GetTopWindow(HWND(std::ptr::null_mut())).ok() };
while let Some(current) = hwnd {
if is_candidate_window(current, excluded_hwnds) {
if let Some(rect) = rect_for_hwnd(current) {
if rect.contains(x, y) { return window_info(current, rect); }
}
}
hwnd = unsafe { GetWindow(current, GW_HWNDNEXT).ok() };
}
desktop_window()
}
pub fn foreground_window(excluded_hwnds: &[isize]) -> Option<WindowInfo> {
let hwnd = unsafe { GetForegroundWindow() };
if hwnd.0.is_null() || !is_candidate_window(hwnd, excluded_hwnds) { return None; }
rect_for_hwnd(hwnd).map(|rect| window_info(hwnd, rect))
}
fn is_candidate_window(hwnd: HWND, excluded_hwnds: &[isize]) -> bool {
if excluded_hwnds.iter().any(|value| *value == hwnd.0 as isize) { return false; }
unsafe {
if !IsWindowVisible(hwnd).as_bool() || IsIconic(hwnd).as_bool() { return false; }
let ex_style = GetWindowLongPtrW(hwnd, GWL_EXSTYLE) as u32;
if ex_style & WS_EX_TOOLWINDOW.0 != 0 { return false; }
}
rect_for_hwnd(hwnd).is_some_and(|rect| rect.width > 0 && rect.height > 0)
}
fn rect_for_hwnd(hwnd: HWND) -> Option<Rect> {
let mut rect = RECT::default();
if unsafe { GetWindowRect(hwnd, &mut rect) }.is_err() { return None; }
let width = rect.right - rect.left;
let height = rect.bottom - rect.top;
if width <= 0 || height <= 0 { return None; }
Some(Rect { x: rect.left, y: rect.top, width, height })
}
fn window_info(hwnd: HWND, rect: Rect) -> WindowInfo {
WindowInfo {
hwnd: Some(hwnd.0 as isize),
title: window_title(hwnd),
process_name: process_name(hwnd),
rect,
is_desktop: false,
}
}
fn desktop_window() -> WindowInfo {
WindowInfo {
hwnd: None,
title: "Desktop".into(),
process_name: "explorer.exe".into(),
rect: virtual_screen_rect(),
is_desktop: true,
}
}
fn window_title(hwnd: HWND) -> String {
let len = unsafe { GetWindowTextLengthW(hwnd) };
if len <= 0 { return "Untitled".into(); }
let mut buf = vec![0u16; (len + 1) as usize];
let copied = unsafe { GetWindowTextW(hwnd, &mut buf) };
if copied <= 0 { return "Untitled".into(); }
let text = String::from_utf16_lossy(&buf[..copied as usize]).trim().to_string();
if text.is_empty() { "Untitled".into() } else { text }
}
fn process_name(hwnd: HWND) -> String {
let mut pid = 0u32;
unsafe { GetWindowThreadProcessId(hwnd, Some(&mut pid)); }
if pid == 0 { return String::new(); }
let handle = match unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, BOOL(0), pid) } {
Ok(handle) => handle,
Err(_) => return String::new(),
};
let mut buf = vec![0u16; 32768];
let mut size = buf.len() as u32;
let result = unsafe { QueryFullProcessImageNameW(handle, PROCESS_NAME_FORMAT(0), PWSTR(buf.as_mut_ptr()), &mut size) };
unsafe { let _ = CloseHandle(handle); }
if result.is_err() || size == 0 { return String::new(); }
let path = String::from_utf16_lossy(&buf[..size as usize]);
Path::new(&path).file_name().map(|name| name.to_string_lossy().into_owned()).unwrap_or_default()
}

View File

@ -0,0 +1,71 @@
use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder};
use crate::win32_window;
const LABELS: [&str; 4] = ["floating", "overlay", "panel", "annotation_form"];
pub fn create_all_windows(app: &AppHandle) -> Result<(), String> {
let screen = win32_window::virtual_screen_rect();
WebviewWindowBuilder::new(app, "floating", WebviewUrl::App("index.html".into()))
.title("RPA")
.inner_size(80.0, 80.0)
.position(32.0, 160.0)
.decorations(false)
.transparent(true)
.resizable(false)
.always_on_top(true)
.skip_taskbar(true)
.visible(true)
.build()
.map_err(|err| err.to_string())?;
WebviewWindowBuilder::new(app, "overlay", WebviewUrl::App("index.html".into()))
.title("Desktop Mark Overlay")
.inner_size(screen.width as f64, screen.height as f64)
.position(screen.x as f64, screen.y as f64)
.decorations(false)
.transparent(true)
.resizable(false)
.always_on_top(true)
.skip_taskbar(true)
.visible(false)
.build()
.map_err(|err| err.to_string())?;
WebviewWindowBuilder::new(app, "panel", WebviewUrl::App("index.html".into()))
.title("标注信息")
.inner_size(300.0, screen.height as f64)
.position(screen.x as f64, screen.y as f64)
.decorations(false)
.transparent(false)
.resizable(false)
.always_on_top(true)
.skip_taskbar(true)
.visible(false)
.build()
.map_err(|err| err.to_string())?;
WebviewWindowBuilder::new(app, "annotation_form", WebviewUrl::App("index.html".into()))
.title("编辑标注")
.inner_size(320.0, 420.0)
.decorations(false)
.transparent(false)
.resizable(false)
.always_on_top(true)
.skip_taskbar(true)
.visible(false)
.build()
.map_err(|err| err.to_string())?;
Ok(())
}
pub fn tool_window_hwnds(app: &AppHandle) -> Vec<isize> {
let mut hwnds = Vec::new();
for label in LABELS {
if let Some(window) = app.get_webview_window(label) {
#[cfg(target_os = "windows")]
if let Ok(hwnd) = window.hwnd() { hwnds.push(hwnd.0 as isize); }
}
}
hwnds
}

View File

@ -0,0 +1,17 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Desktop Mark RPA",
"version": "0.1.0",
"identifier": "com.desktopmark.tauri",
"build": {
"beforeDevCommand": "vite --host 127.0.0.1",
"devUrl": "http://127.0.0.1:1420",
"beforeBuildCommand": "npm run typecheck && vite build",
"frontendDist": "../dist"
},
"app": {
"windows": [],
"security": { "csp": null }
},
"bundle": { "active": true, "targets": "all", "icon": ["icons/icon.ico"] }
}

View File

@ -0,0 +1,42 @@
import type { Rect } from '../types';
export function right(rect: Rect): number { return rect.x + rect.width; }
export function bottom(rect: Rect): number { return rect.y + rect.height; }
export function normalized(rect: Rect): Rect {
const x1 = Math.min(rect.x, right(rect));
const y1 = Math.min(rect.y, bottom(rect));
const x2 = Math.max(rect.x, right(rect));
const y2 = Math.max(rect.y, bottom(rect));
return { x: x1, y: y1, width: x2 - x1, height: y2 - y1 };
}
export function contains(rect: Rect, x: number, y: number): boolean {
const r = normalized(rect);
return r.x <= x && x < right(r) && r.y <= y && y < bottom(r);
}
export function clampedTo(rect: Rect, bounds: Rect): Rect {
const r = normalized(rect);
const limit = normalized(bounds);
const x1 = Math.max(limit.x, Math.min(r.x, right(limit)));
const y1 = Math.max(limit.y, Math.min(r.y, bottom(limit)));
let x2 = Math.max(limit.x, Math.min(right(r), right(limit)));
let y2 = Math.max(limit.y, Math.min(bottom(r), bottom(limit)));
if (x2 < x1) x2 = x1;
if (y2 < y1) y2 = y1;
return { x: x1, y: y1, width: x2 - x1, height: y2 - y1 };
}
export function moveClamped(rect: Rect, dx: number, dy: number, bounds: Rect): Rect {
const r = normalized(rect);
const limit = normalized(bounds);
const maxX = Math.max(limit.x, right(limit) - r.width);
const maxY = Math.max(limit.y, bottom(limit) - r.height);
return {
x: Math.max(limit.x, Math.min(r.x + dx, maxX)),
y: Math.max(limit.y, Math.min(r.y + dy, maxY)),
width: r.width,
height: r.height,
};
}

View File

@ -0,0 +1,46 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { getCurrentWindow } from '@tauri-apps/api/window';
import './styles.css';
import { Floating } from './windows/Floating';
import { Overlay, startAnnotation, startWindowPicking } from './windows/Overlay';
import { Panel } from './windows/Panel';
import { AnnotationForm } from './windows/AnnotationForm';
import { installStateSync, setState } from './state/appStore';
import type { AnnotationProject } from './types';
async function bootstrap(): Promise<void> {
await installStateSync();
const label = getCurrentWindow().label;
const root = createRoot(document.getElementById('root')!);
const componentByLabel: Record<string, React.ReactElement> = {
floating: <Floating />,
overlay: <Overlay />,
panel: <Panel />,
annotation_form: <AnnotationForm />,
};
root.render(componentByLabel[label] ?? <Floating />);
await listen('hotkey:start-window-picking', () => { void startWindowPicking(); });
await listen('hotkey:start-annotation', () => { void startAnnotation(); });
await listen<string>('hotkey:registration-error', (event) => setState({ alertMessage: event.payload }));
if (label === 'floating') {
try {
const project = await invoke<AnnotationProject | null>('load_project');
if (project) setState({ project }, { broadcast: true });
} catch (error) {
setState({ alertMessage: `无法加载标注项目:${String(error)}`, project: null });
}
try {
const hwnds = await invoke<number[]>('tool_window_hwnds');
setState({ toolWindowHwnds: hwnds });
} catch (error) {
setState({ alertMessage: `无法读取工具窗口句柄:${String(error)}` });
}
}
}
void bootstrap();

View File

@ -0,0 +1,47 @@
import { useSyncExternalStore } from 'react';
import { emit, listen } from '@tauri-apps/api/event';
import type { AppState } from '../types';
const initialState: AppState = {
mode: 'idle',
hoverWindow: null,
lockedWindow: null,
project: null,
selectedAnnotationId: null,
toolWindowHwnds: [],
tempRect: null,
alertMessage: null,
};
let state = initialState;
let applyingRemote = false;
const listeners = new Set<() => void>();
export function getState(): AppState { return state; }
function notify(): void { for (const listener of listeners) listener(); }
export function setState(update: Partial<AppState> | ((current: AppState) => AppState), options: { broadcast?: boolean } = {}): AppState {
state = typeof update === 'function' ? update(state) : { ...state, ...update };
notify();
if (options.broadcast !== false && !applyingRemote) void emit('state:sync', state);
return state;
}
export function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
export function useAppState(): AppState {
return useSyncExternalStore(subscribe, getState, getState);
}
export async function installStateSync(): Promise<void> {
await listen<AppState>('state:sync', (event) => {
applyingRemote = true;
state = event.payload;
notify();
applyingRemote = false;
});
}

View File

@ -0,0 +1,27 @@
html, body, #root { margin: 0; width: 100%; height: 100%; overflow: hidden; font-family: "Microsoft YaHei", "Segoe UI", sans-serif; }
* { box-sizing: border-box; }
button, input, textarea, select { font: inherit; }
.floating-shell { position: relative; width: 100%; height: 100%; user-select: none; }
.floating-canvas { width: 80px; height: 80px; display: block; cursor: grab; }
.floating-menu { position: absolute; left: 4px; top: 84px; width: 142px; padding: 6px; border-radius: 10px; background: rgba(24, 24, 28, .96); color: #fff; border: 1px solid rgba(255,255,255,.16); box-shadow: 0 10px 28px rgba(0,0,0,.36); }
.floating-menu button { width: 100%; min-height: 30px; border: 0; border-radius: 7px; padding: 6px 8px; color: #fff; background: transparent; text-align: left; cursor: pointer; }
.floating-menu button:hover { background: rgba(255,255,255,.12); }
.floating-menu button.danger { color: #ffb3b3; }
.overlay-root { width: 100vw; height: 100vh; cursor: crosshair; }
.panel { width: 300px; height: 100vh; padding: 14px; background: rgba(246, 248, 252, .96); color: #20242a; border-right: 1px solid #d8dde7; overflow: auto; }
.panel h2 { font-size: 16px; margin: 8px 0 12px; }
.panel label { display: block; font-size: 12px; color: #596273; margin-top: 10px; }
.panel .value { padding: 6px 0; word-break: break-all; }
.panel input, .panel textarea, .form input, .form textarea, .form select { width: 100%; border: 1px solid #c7cedb; border-radius: 6px; padding: 7px 8px; background: #fff; }
.panel textarea { height: 90px; resize: none; }
.annotation-list { display: flex; flex-direction: column; gap: 6px; }
.annotation-item { border: 1px solid #d5dbea; background: #fff; border-radius: 8px; padding: 8px; text-align: left; cursor: pointer; }
.annotation-item.selected { border-color: #007aff; box-shadow: 0 0 0 2px rgba(0,122,255,.15); }
.form { width: 100vw; height: 100vh; padding: 14px; background: #f8f9fb; color: #20242a; border: 1px solid #d4d9e4; }
.form label { display:block; font-size: 12px; color: #596273; margin: 8px 0 4px; }
.form textarea { height: 82px; resize: none; }
.actions { display: grid; grid-template-columns: 1fr 1fr; gap: 4px 8px; }
.actions label { color: #20242a; font-size: 12px; display:flex; align-items:center; gap:4px; margin:0; }
.preview { height: 56px; border: 1px dashed #999; color: #666; display:flex; align-items:center; justify-content:center; margin-top: 4px; }
.form-buttons { display:flex; justify-content:flex-end; gap: 8px; margin-top: 12px; }
.warning { background:#fff7d6; border:1px solid #f2c94c; color:#5c4700; padding:8px; border-radius:6px; margin-bottom:8px; }

View File

@ -0,0 +1,49 @@
export interface Rect { x: number; y: number; width: number; height: number }
export interface WindowInfo {
hwnd: number | null;
title: string;
process_name: string;
rect: Rect;
is_desktop: boolean;
}
export type AnnotationType = 'button' | 'input' | 'text' | 'image' | 'other';
export type AnnotationAction = 'click' | 'double_click' | 'right_click' | 'input_text' | 'hover' | 'scroll';
export interface Annotation {
id: string;
rect: Rect;
control_name: string;
description: string;
type: AnnotationType;
actions: AnnotationAction[];
feature_preview_path: string | null;
}
export interface AnnotationProject {
window: WindowInfo;
custom_window_name: string;
window_description: string;
annotations: Annotation[];
}
export type OverlayMode =
| 'idle'
| 'picking_window'
| 'window_locked'
| 'drawing_annotation'
| 'editing_annotation'
| 'moving_annotation'
| 'resizing_annotation';
export interface AppState {
mode: OverlayMode;
hoverWindow: WindowInfo | null;
lockedWindow: WindowInfo | null;
project: AnnotationProject | null;
selectedAnnotationId: string | null;
toolWindowHwnds: number[];
tempRect: Rect | null;
alertMessage: string | null;
}

1
desktop_mark/tauri/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
declare module '*.css';

View File

@ -0,0 +1,55 @@
import { invoke } from '@tauri-apps/api/core';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { useEffect, useState, type ReactElement } from 'react';
import { setState, useAppState } from '../state/appStore';
import type { AnnotationAction, AnnotationType } from '../types';
const ACTIONS: AnnotationAction[] = ['click', 'double_click', 'right_click', 'input_text', 'hover', 'scroll'];
const TYPES: AnnotationType[] = ['button', 'input', 'text', 'image', 'other'];
export function AnnotationForm(): ReactElement {
const state = useAppState();
const annotation = state.project?.annotations.find((item) => item.id === state.selectedAnnotationId) ?? null;
const [controlName, setControlName] = useState('');
const [description, setDescription] = useState('');
const [type, setType] = useState<AnnotationType>('other');
const [actions, setActions] = useState<AnnotationAction[]>([]);
useEffect(() => {
setControlName(annotation?.control_name ?? '');
setDescription(annotation?.description ?? '');
setType(annotation?.type ?? 'other');
setActions(annotation?.actions ?? []);
}, [annotation?.id]);
const save = async () => {
if (!state.project || !annotation) return;
const updated = { ...annotation, control_name: controlName.trim(), description: description.trim(), type, actions, feature_preview_path: null };
const project = { ...state.project, annotations: state.project.annotations.map((item) => item.id === updated.id ? updated : item) };
setState({ project });
try { await invoke('save_project', { project }); await getCurrentWindow().hide(); }
catch (error) { setState({ alertMessage: `无法保存标注项目:${String(error)}` }); }
};
return <div className="form">
{state.alertMessage ? <div className="warning">{state.alertMessage}</div> : null}
<label></label>
<input value={controlName} onChange={(event) => setControlName(event.target.value)} />
<label></label>
<textarea value={description} onChange={(event) => setDescription(event.target.value)} />
<label></label>
<select value={type} onChange={(event) => setType(event.target.value as AnnotationType)}>
{TYPES.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
<label></label>
<div className="actions">{ACTIONS.map((action) => <label key={action}><input type="checkbox" checked={actions.includes(action)} onChange={(event) => {
setActions((current) => event.target.checked ? [...current, action] : current.filter((item) => item !== action));
}} />{action}</label>)}</div>
<label></label>
<div className="preview"></div>
<div className="form-buttons">
<button onClick={() => { void getCurrentWindow().hide(); }}></button>
<button onClick={() => { void save(); }}></button>
</div>
</div>;
}

View File

@ -0,0 +1,110 @@
import { useEffect, useRef, useState, type ReactElement } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { PhysicalSize, getCurrentWindow } from '@tauri-apps/api/window';
import { useAppState } from '../state/appStore';
import { startWindowPicking } from './Overlay';
export function Floating(): ReactElement {
const state = useAppState();
const canvasRef = useRef<HTMLCanvasElement>(null);
const [menuOpen, setMenuOpen] = useState(false);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const size = 80;
const dpr = window.devicePixelRatio || 1;
canvas.width = size * dpr;
canvas.height = size * dpr;
canvas.style.width = `${size}px`;
canvas.style.height = `${size}px`;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, size, size);
const body = ctx.createLinearGradient(10, 4, 72, 76);
body.addColorStop(0, '#30333d');
body.addColorStop(0.55, '#171a22');
body.addColorStop(1, '#0b0d12');
roundRect(ctx, 1, 1, 78, 78, 18);
ctx.fillStyle = body;
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,.18)';
ctx.lineWidth = 1;
ctx.stroke();
const glow = ctx.createRadialGradient(30, 24, 2, 30, 24, 34);
glow.addColorStop(0, 'rgba(0,122,255,.55)');
glow.addColorStop(1, 'rgba(0,122,255,0)');
ctx.fillStyle = glow;
ctx.fillRect(0, 0, size, size);
ctx.fillStyle = '#ffffff';
ctx.font = '700 22px "Segoe UI", sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('RPA', 40, 31);
ctx.fillStyle = 'rgba(255,255,255,.72)';
ctx.font = '500 12px "Segoe UI", sans-serif';
ctx.fillText('Ctrl+1', 40, 55);
if (state.alertMessage) {
ctx.beginPath();
ctx.arc(63, 17, 7, 0, Math.PI * 2);
ctx.fillStyle = '#ffd966';
ctx.fill();
ctx.fillStyle = '#5c4700';
ctx.font = '700 11px "Segoe UI", sans-serif';
ctx.fillText('!', 63, 17);
}
}, [state.alertMessage]);
useEffect(() => {
void getCurrentWindow().setSize(new PhysicalSize(menuOpen ? 150 : 80, menuOpen ? 126 : 80));
}, [menuOpen]);
return (
<div
className="floating-shell"
onContextMenu={(event) => {
event.preventDefault();
setMenuOpen(true);
}}
onPointerDown={(event) => {
if (event.button === 0 && !menuOpen) void getCurrentWindow().startDragging();
}}
title={state.alertMessage ?? 'Ctrl+1'}
>
<canvas
ref={canvasRef}
className="floating-canvas"
aria-label={state.alertMessage ?? 'RPA 悬浮窗,左键开始识别窗口,右键打开菜单'}
role="button"
onClick={() => {
if (menuOpen) {
setMenuOpen(false);
return;
}
void startWindowPicking();
}}
/>
{menuOpen ? (
<div className="floating-menu" role="menu">
<button type="button" role="menuitem" onClick={() => { setMenuOpen(false); void startWindowPicking(); }}></button>
<button type="button" role="menuitem" className="danger" onClick={() => { void invoke('exit_app'); }}>退</button>
</div>
) : null}
</div>
);
}
function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, radius: number): void {
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.arcTo(x + width, y, x + width, y + height, radius);
ctx.arcTo(x + width, y + height, x, y + height, radius);
ctx.arcTo(x, y + height, x, y, radius);
ctx.arcTo(x, y, x + width, y, radius);
ctx.closePath();
}

View File

@ -0,0 +1,253 @@
import { invoke } from '@tauri-apps/api/core';
import { PhysicalPosition, PhysicalSize } from '@tauri-apps/api/window';
import { WebviewWindow } from '@tauri-apps/api/webviewWindow';
import { useEffect, type ReactElement } from 'react';
import { getState, setState, useAppState } from '../state/appStore';
import type { Annotation, Rect, WindowInfo } from '../types';
import { bottom, clampedTo, contains, moveClamped, normalized, right } from '../geometry/rect';
const MIN_ANNOTATION_SIZE = 6;
const HANDLE_SIZE = 8;
type HandleName = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w';
let pollId: number | null = null;
let virtualRect: Rect = { x: 0, y: 0, width: 1, height: 1 };
let dragStart: { x: number; y: number } | null = null;
let moving: { id: string; original: Rect } | null = null;
let resizing: { id: string; original: Rect; handle: HandleName } | null = null;
async function overlayWindow(): Promise<WebviewWindow> { return existingWindow('overlay'); }
async function panelWindow(): Promise<WebviewWindow> { return existingWindow('panel'); }
async function formWindow(): Promise<WebviewWindow> { return existingWindow('annotation_form'); }
async function existingWindow(label: string): Promise<WebviewWindow> {
const win = await WebviewWindow.getByLabel(label);
if (!win) throw new Error(`窗口 ${label} 尚未创建`);
return win;
}
function toLocal(rect: Rect): Rect { const r = normalized(rect); return { x: r.x - virtualRect.x, y: r.y - virtualRect.y, width: r.width, height: r.height }; }
function screenPoint(event: React.PointerEvent): { x: number; y: number } { return { x: Math.round(event.clientX + virtualRect.x), y: Math.round(event.clientY + virtualRect.y) }; }
function stopPoll(): void { if (pollId !== null) window.clearInterval(pollId); pollId = null; }
function sameWindow(a: WindowInfo, b: WindowInfo): boolean { return a.hwnd === b.hwnd && a.title === b.title && a.process_name === b.process_name && JSON.stringify(a.rect) === JSON.stringify(b.rect); }
async function pollHover(): Promise<void> {
const [x, y] = await invoke<[number, number]>('cursor_position');
const hoverWindow = await invoke<WindowInfo>('window_at', { x, y, excludedHwnds: getState().toolWindowHwnds });
setState({ hoverWindow });
}
export async function startWindowPicking(): Promise<void> {
virtualRect = await invoke<Rect>('get_virtual_screen_rect');
const overlay = await overlayWindow();
await overlay.setPosition(new PhysicalPosition(virtualRect.x, virtualRect.y));
await overlay.setSize(new PhysicalSize(virtualRect.width, virtualRect.height));
await overlay.show();
await overlay.setFocus();
stopPoll();
setState({ mode: 'picking_window', hoverWindow: null, tempRect: null });
await pollHover();
pollId = window.setInterval(() => { void pollHover(); }, 50);
}
export async function startAnnotation(): Promise<void> {
const current = getState();
if (!current.lockedWindow) { await startWindowPicking(); return; }
virtualRect = await invoke<Rect>('get_virtual_screen_rect');
const overlay = await overlayWindow();
await overlay.show();
await overlay.setFocus();
stopPoll();
setState({ mode: 'drawing_annotation', tempRect: null });
}
export async function cancelFlow(): Promise<void> {
stopPoll();
dragStart = null; moving = null; resizing = null;
setState({ mode: 'idle', hoverWindow: null, lockedWindow: null, tempRect: null });
await (await overlayWindow()).hide();
await (await panelWindow()).hide();
await (await formWindow()).hide();
}
async function openFormNear(rect: Rect): Promise<void> {
const form = await formWindow();
const width = 320;
const height = 420;
let x = right(rect) + 12;
let y = bottom(rect) + 12;
if (x + width > right(virtualRect)) x = rect.x - width - 12;
if (y + height > bottom(virtualRect)) y = rect.y - height - 12;
x = Math.max(virtualRect.x, Math.min(x, right(virtualRect) - width));
y = Math.max(virtualRect.y, Math.min(y, bottom(virtualRect) - height));
await form.setPosition(new PhysicalPosition(x, y));
await form.show();
await form.setFocus();
}
function updateAnnotation(id: string, rect: Rect): void {
setState((state) => {
if (!state.project) return state;
return { ...state, project: { ...state.project, annotations: state.project.annotations.map((item) => item.id === id ? { ...item, rect } : item) } };
});
}
function handleRects(rect: Rect): Record<HandleName, Rect> {
const r = normalized(rect);
const half = HANDLE_SIZE / 2;
const cx = r.x + Math.floor(r.width / 2);
const cy = r.y + Math.floor(r.height / 2);
return {
nw: { x: r.x - half, y: r.y - half, width: HANDLE_SIZE, height: HANDLE_SIZE },
n: { x: cx - half, y: r.y - half, width: HANDLE_SIZE, height: HANDLE_SIZE },
ne: { x: right(r) - half, y: r.y - half, width: HANDLE_SIZE, height: HANDLE_SIZE },
e: { x: right(r) - half, y: cy - half, width: HANDLE_SIZE, height: HANDLE_SIZE },
se: { x: right(r) - half, y: bottom(r) - half, width: HANDLE_SIZE, height: HANDLE_SIZE },
s: { x: cx - half, y: bottom(r) - half, width: HANDLE_SIZE, height: HANDLE_SIZE },
sw: { x: r.x - half, y: bottom(r) - half, width: HANDLE_SIZE, height: HANDLE_SIZE },
w: { x: r.x - half, y: cy - half, width: HANDLE_SIZE, height: HANDLE_SIZE },
};
}
function hitTest(x: number, y: number): { annotation: Annotation | null; handle: HandleName | null } {
const { project, selectedAnnotationId } = getState();
if (!project) return { annotation: null, handle: null };
const selected = project.annotations.find((annotation) => annotation.id === selectedAnnotationId);
if (selected) {
for (const [name, rect] of Object.entries(handleRects(selected.rect)) as [HandleName, Rect][]) {
if (contains(rect, x, y)) return { annotation: selected, handle: name };
}
}
for (let i = project.annotations.length - 1; i >= 0; i -= 1) {
const annotation = project.annotations[i];
if (contains(annotation.rect, x, y)) return { annotation, handle: null };
}
return { annotation: null, handle: null };
}
function resizeRect(original: Rect, handle: HandleName, point: { x: number; y: number }): Rect {
let left = original.x;
let top = original.y;
let rectRight = right(original);
let rectBottom = bottom(original);
if (handle.includes('w')) left = Math.min(point.x, rectRight - MIN_ANNOTATION_SIZE);
if (handle.includes('e')) rectRight = Math.max(point.x, left + MIN_ANNOTATION_SIZE);
if (handle.includes('n')) top = Math.min(point.y, rectBottom - MIN_ANNOTATION_SIZE);
if (handle.includes('s')) rectBottom = Math.max(point.y, top + MIN_ANNOTATION_SIZE);
return { x: left, y: top, width: rectRight - left, height: rectBottom - top };
}
export function Overlay(): ReactElement {
const state = useAppState();
const target = state.lockedWindow ?? state.hoverWindow;
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (['Escape', 'Enter', 'NumpadEnter'].includes(event.code)) void cancelFlow();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
const onPointerDown = async (event: React.PointerEvent) => {
if (event.button !== 0) return;
const point = screenPoint(event);
if (state.mode === 'picking_window') {
stopPoll();
const lockedWindow = state.hoverWindow ?? await invoke<WindowInfo>('window_at', { x: point.x, y: point.y, excludedHwnds: state.toolWindowHwnds });
setState((current) => {
const keepProject = current.project && sameWindow(current.project.window, lockedWindow);
return { ...current, lockedWindow, hoverWindow: null, mode: 'window_locked', project: keepProject ? current.project : { window: lockedWindow, custom_window_name: '', window_description: '', annotations: [] } };
});
await (await panelWindow()).show();
return;
}
if (!state.lockedWindow) return;
const hit = hitTest(point.x, point.y);
if (hit.annotation && hit.handle) {
resizing = { id: hit.annotation.id, original: hit.annotation.rect, handle: hit.handle };
dragStart = point;
setState({ selectedAnnotationId: hit.annotation.id, mode: 'resizing_annotation' });
return;
}
if (hit.annotation) {
moving = { id: hit.annotation.id, original: hit.annotation.rect };
dragStart = point;
setState({ selectedAnnotationId: hit.annotation.id, mode: 'moving_annotation' });
return;
}
if (contains(state.lockedWindow.rect, point.x, point.y)) {
dragStart = point;
setState({ mode: 'drawing_annotation', tempRect: { x: point.x, y: point.y, width: 0, height: 0 } });
}
};
const onPointerMove = (event: React.PointerEvent) => {
const point = screenPoint(event);
const lockedWindow = getState().lockedWindow;
if (!lockedWindow || !dragStart) return;
if (getState().mode === 'drawing_annotation') {
setState({ tempRect: clampedTo({ x: dragStart.x, y: dragStart.y, width: point.x - dragStart.x, height: point.y - dragStart.y }, lockedWindow.rect) });
} else if (getState().mode === 'moving_annotation' && moving) {
updateAnnotation(moving.id, moveClamped(moving.original, point.x - dragStart.x, point.y - dragStart.y, lockedWindow.rect));
} else if (getState().mode === 'resizing_annotation' && resizing) {
updateAnnotation(resizing.id, clampedTo(resizeRect(resizing.original, resizing.handle, point), lockedWindow.rect));
}
};
const onPointerUp = async (event: React.PointerEvent) => {
if (event.button !== 0) return;
const current = getState();
if (current.mode === 'drawing_annotation' && current.tempRect && current.lockedWindow) {
const rect = clampedTo(current.tempRect, current.lockedWindow.rect);
dragStart = null;
if (rect.width >= MIN_ANNOTATION_SIZE && rect.height >= MIN_ANNOTATION_SIZE) {
const annotation: Annotation = { id: crypto.randomUUID(), rect, control_name: '', description: '', type: 'other', actions: [], feature_preview_path: null };
setState((state) => ({ ...state, mode: 'editing_annotation', tempRect: null, selectedAnnotationId: annotation.id, project: state.project ? { ...state.project, annotations: [...state.project.annotations, annotation] } : { window: current.lockedWindow!, custom_window_name: '', window_description: '', annotations: [annotation] } }));
await openFormNear(rect);
} else {
setState({ mode: 'drawing_annotation', tempRect: null });
}
return;
}
if ((current.mode === 'moving_annotation' && moving) || (current.mode === 'resizing_annotation' && resizing)) {
const editedId = moving?.id ?? resizing?.id;
moving = null; resizing = null; dragStart = null;
setState({ mode: 'editing_annotation' });
const edited = getState().project?.annotations.find((annotation) => annotation.id === editedId);
if (edited) await openFormNear(edited.rect);
}
};
const localTarget = target ? toLocal(target.rect) : null;
const annotations = state.project?.annotations ?? [];
return (
<div className="overlay-root" onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp}>
{state.alertMessage ? <div className="warning" style={{ position: 'absolute', top: 12, left: 12, zIndex: 2 }}>{state.alertMessage}</div> : null}
<svg width="100%" height="100%">
<defs>
<mask id="target-mask">
<rect x="0" y="0" width="100%" height="100%" fill="white" />
{localTarget ? <rect x={localTarget.x} y={localTarget.y} width={localTarget.width} height={localTarget.height} fill="black" /> : null}
</mask>
</defs>
<rect x="0" y="0" width="100%" height="100%" fill="black" opacity="0.47" mask="url(#target-mask)" />
{localTarget && target ? <>
<rect x={localTarget.x} y={localTarget.y} width={localTarget.width} height={localTarget.height} fill="none" stroke="#007aff" strokeWidth="2" />
<text x={localTarget.x + localTarget.width - 6} y={localTarget.y + 20} textAnchor="end" fill="white" fontSize="12">{target.title} [{target.rect.x}, {target.rect.y}, {target.rect.width}, {target.rect.height}]</text>
</> : null}
{annotations.map((annotation, index) => {
const r = toLocal(annotation.rect);
const selected = annotation.id === state.selectedAnnotationId;
return <g key={annotation.id}>
<rect x={r.x} y={r.y} width={r.width} height={r.height} fill="none" stroke="#ff9100" strokeWidth="2" />
<rect x={r.x} y={r.y - 18} width="24" height="18" fill="#ff9100" opacity="0.9" />
<text x={r.x + 12} y={r.y - 5} textAnchor="middle" fill="white" fontSize="12">{index + 1}</text>
{selected ? (Object.values(handleRects(annotation.rect)).map((handle, handleIndex) => {
const h = toLocal(handle);
return <rect key={handleIndex} x={h.x} y={h.y} width={h.width} height={h.height} fill="white" stroke="#007aff" strokeWidth="1" />;
})) : null}
</g>;
})}
{state.tempRect ? (() => { const r = toLocal(state.tempRect); return <rect x={r.x} y={r.y} width={r.width} height={r.height} fill="none" stroke="#3399ff" strokeWidth="2" />; })() : null}
</svg>
</div>
);
}

View File

@ -0,0 +1,54 @@
import { invoke } from '@tauri-apps/api/core';
import { WebviewWindow } from '@tauri-apps/api/webviewWindow';
import type { ReactElement } from 'react';
import { setState, useAppState } from '../state/appStore';
import type { AnnotationProject } from '../types';
async function showAnnotationForm(): Promise<void> {
const form = await WebviewWindow.getByLabel('annotation_form');
if (!form) throw new Error('窗口 annotation_form 尚未创建');
await form.show();
await form.setFocus();
}
async function saveProjectOrWarn(project: AnnotationProject): Promise<void> {
try { await invoke('save_project', { project }); }
catch (error) { setState({ alertMessage: `无法保存标注项目:${String(error)}` }); }
}
export function Panel(): ReactElement {
const state = useAppState();
const project = state.project;
const window = project?.window;
return <div className="panel">
{state.alertMessage ? <div className="warning">{state.alertMessage}</div> : null}
<h2></h2>
<label></label><div className="value">{window?.title ?? '-'}</div>
<label></label><div className="value">{window?.process_name || '-'}</div>
<label>BBox</label><div className="value">{window ? `${window.rect.x}, ${window.rect.y}, ${window.rect.width}, ${window.rect.height}` : '-'}</div>
<label></label>
<input value={project?.custom_window_name ?? ''} onChange={(event) => {
if (!project) return;
const next = { ...project, custom_window_name: event.target.value };
setState({ project: next });
void saveProjectOrWarn(next);
}} />
<label></label>
<textarea value={project?.window_description ?? ''} onChange={(event) => {
if (!project) return;
const next = { ...project, window_description: event.target.value };
setState({ project: next });
void saveProjectOrWarn(next);
}} />
<h2></h2>
<div className="annotation-list">
{(project?.annotations ?? []).map((annotation, index) => {
const label = annotation.control_name || annotation.description || `标注 ${index + 1}`;
return <button key={annotation.id} className={`annotation-item ${annotation.id === state.selectedAnnotationId ? 'selected' : ''}`} onClick={() => {
setState({ selectedAnnotationId: annotation.id, mode: state.mode !== 'picking_window' && state.mode !== 'idle' ? 'editing_annotation' : state.mode });
void showAnnotationForm().catch((error) => setState({ alertMessage: `无法打开标注弹窗:${String(error)}` }));
}}>{index + 1}. {label} ({annotation.type})</button>;
})}
</div>
</div>;
}

View File

@ -0,0 +1 @@
{"compilerOptions":{"target":"ES2022","useDefineForClassFields":true,"lib":["ES2022","DOM","DOM.Iterable"],"allowJs":false,"skipLibCheck":true,"esModuleInterop":true,"allowSyntheticDefaultImports":true,"strict":true,"forceConsistentCasingInFileNames":true,"module":"ESNext","moduleResolution":"Bundler","resolveJsonModule":true,"isolatedModules":true,"noEmit":true,"jsx":"react-jsx"},"include":["src"],"references":[]}

View File

@ -0,0 +1,17 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
clearScreen: false,
server: {
strictPort: true,
port: 1420,
host: '127.0.0.1',
watch: {
ignored: ['**/src-tauri/target/**'],
},
},
envPrefix: ['VITE_', 'TAURI_'],
build: { target: 'es2022', minify: false },
});

7
desktop_rap/README.md Normal file
View File

@ -0,0 +1,7 @@
# 这个目录做一些python|go|rust的rpa控制自动化实验
# 基于desktop_mark 标注工具已完成,并且已经有了一定的视觉节点树信息
# 在这里测试Agent通过视觉节点树去控制电脑自动化执行
# 包括使用豆包VLM模型进行内容识别还有一些OCR识别
# 包括区域滚动、控件点击双击拖动等
# agent 需要支持收到任务、读取记忆、理解需求、 执行、验证、总结经验教训、存储记忆、错误补标、重标。任务打断、打断恢复等

10
test_ocr/py_ocr/app.py Normal file
View File

@ -0,0 +1,10 @@
from rapidocr import RapidOCR
engine = RapidOCR()
img_url = "history_wangzhen_long.png"
result = engine(img_url)
print(result)
# 可视化会触发程序自动下载所需字体文件。如果是离线环境,可注释掉该行
# result.vis("vis_result.jpg")