From 7bca3ec996d44bc5f58623481493b8d197b79eda Mon Sep 17 00:00:00 2001 From: "J.S.Patrick" <980141374@qq.com> Date: Sat, 29 Aug 2026 17:03:09 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20macOS=20=E5=85=A8=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E6=88=AA=E5=9B=BE=E6=94=AF=E6=8C=81=E5=B9=B6=E6=8C=89=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E6=8B=86=E5=88=86=E6=88=AA=E5=9B=BE=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 macOS 截图完整实现(Swift):选区/窗口吸附、编辑标注、马赛克、 长截图、圆角导出、保存对话框,行为与 Windows 版对齐 - 截图模块重构为 src/screenshot/{windows,macos,algo} 三层目录, 长截图拼接算法独立为平台无关的 algo 模块 - setClipboardFiles 支持 macOS;ScreenCapture.start 返回选区坐标与 错误信息,base64 结果带 data URL 前缀;长截图取消帧数上限 - 同步更新 binding.gyp、build-swift.sh、CI 构建流程与 README/DEPLOY 文档 --- .github/workflows/build.yml | 55 +- DEPLOY.md | 40 +- README.md | 98 +- binding.gyp | 33 +- index.js | 49 +- scripts/build-swift.sh | 65 +- scripts/gen-icons.js | 322 ++- src/binding_mac.cpp | 241 ++- src/binding_windows.cpp | 4 +- src/screenshot/{ => algo}/lc_match_core.cpp | 10 +- src/screenshot/algo/lc_platform.h | 81 + src/screenshot/{ => algo}/lc_stitch_state.cpp | 8 +- src/screenshot/algo/long_capture_internal.h | 647 ++++++ src/screenshot/internal.h | 1162 ----------- src/screenshot/long_capture_internal.h | 312 --- src/screenshot/macos/LCBridgeMac.swift | 679 +++++++ .../macos/ScreenshotAnnotationsMac.swift | 835 ++++++++ .../macos/ScreenshotLCPanelMac.swift | 396 ++++ .../macos/ScreenshotLCToolbarMac.swift | 1026 ++++++++++ .../macos/ScreenshotLongCaptureMac.swift | 1275 ++++++++++++ src/screenshot/macos/ScreenshotMac.swift | 404 ++++ .../macos/ScreenshotMosaicMac.swift | 456 +++++ .../macos/ScreenshotOutputMac.swift | 307 +++ .../macos/ScreenshotOverlayMac.swift | 1789 +++++++++++++++++ src/screenshot/macos/ScreenshotPaintMac.swift | 785 ++++++++ src/screenshot/macos/ScreenshotTextMac.swift | 813 ++++++++ .../macos/ScreenshotToolbarMac.swift | 1293 ++++++++++++ src/screenshot/macos/lc_bridge_mac.cpp | 491 +++++ src/screenshot/macos/lc_bridge_mac.h | 326 +++ .../{ => windows}/annotations_windows.cpp | 0 src/screenshot/windows/capture_context.h | 195 ++ .../{ => windows}/capture_windows.cpp | 0 .../{ => windows}/icons_windows.cpp | 8 +- src/screenshot/windows/internal.h | 272 +++ .../lc_frame_io_windows.cpp} | 2 +- .../lc_panel_ui_windows.cpp} | 6 +- .../lc_session_windows.cpp} | 16 +- .../lc_toolbar_ui_windows.cpp} | 6 +- .../{ => windows}/mosaic_windows.cpp | 0 .../{ => windows}/output_windows.cpp | 8 +- .../{ => windows}/overlay_input_windows.cpp | 0 .../{ => windows}/overlay_paint_windows.cpp | 0 .../{ => windows}/overlay_ui_windows.cpp | 0 src/screenshot/windows/sc_annotations.h | 119 ++ src/screenshot/windows/sc_theme.h | 31 + src/screenshot/windows/sc_types.h | 284 +++ .../{ => windows}/screenshot_windows.h | 0 .../{ => windows}/session_windows.cpp | 21 +- .../{ => windows}/wndproc_windows.cpp | 3 +- 49 files changed, 13355 insertions(+), 1618 deletions(-) mode change 100644 => 100755 scripts/build-swift.sh rename src/screenshot/{ => algo}/lc_match_core.cpp (99%) create mode 100644 src/screenshot/algo/lc_platform.h rename src/screenshot/{ => algo}/lc_stitch_state.cpp (98%) create mode 100644 src/screenshot/algo/long_capture_internal.h delete mode 100644 src/screenshot/internal.h delete mode 100644 src/screenshot/long_capture_internal.h create mode 100644 src/screenshot/macos/LCBridgeMac.swift create mode 100644 src/screenshot/macos/ScreenshotAnnotationsMac.swift create mode 100644 src/screenshot/macos/ScreenshotLCPanelMac.swift create mode 100644 src/screenshot/macos/ScreenshotLCToolbarMac.swift create mode 100644 src/screenshot/macos/ScreenshotLongCaptureMac.swift create mode 100644 src/screenshot/macos/ScreenshotMac.swift create mode 100644 src/screenshot/macos/ScreenshotMosaicMac.swift create mode 100644 src/screenshot/macos/ScreenshotOutputMac.swift create mode 100644 src/screenshot/macos/ScreenshotOverlayMac.swift create mode 100644 src/screenshot/macos/ScreenshotPaintMac.swift create mode 100644 src/screenshot/macos/ScreenshotTextMac.swift create mode 100644 src/screenshot/macos/ScreenshotToolbarMac.swift create mode 100644 src/screenshot/macos/lc_bridge_mac.cpp create mode 100644 src/screenshot/macos/lc_bridge_mac.h rename src/screenshot/{ => windows}/annotations_windows.cpp (100%) create mode 100644 src/screenshot/windows/capture_context.h rename src/screenshot/{ => windows}/capture_windows.cpp (100%) rename src/screenshot/{ => windows}/icons_windows.cpp (96%) create mode 100644 src/screenshot/windows/internal.h rename src/screenshot/{lc_frame_io.cpp => windows/lc_frame_io_windows.cpp} (99%) rename src/screenshot/{lc_panel_ui.cpp => windows/lc_panel_ui_windows.cpp} (99%) rename src/screenshot/{lc_session.cpp => windows/lc_session_windows.cpp} (95%) rename src/screenshot/{lc_toolbar_ui.cpp => windows/lc_toolbar_ui_windows.cpp} (99%) rename src/screenshot/{ => windows}/mosaic_windows.cpp (100%) rename src/screenshot/{ => windows}/output_windows.cpp (99%) rename src/screenshot/{ => windows}/overlay_input_windows.cpp (100%) rename src/screenshot/{ => windows}/overlay_paint_windows.cpp (100%) rename src/screenshot/{ => windows}/overlay_ui_windows.cpp (100%) create mode 100644 src/screenshot/windows/sc_annotations.h create mode 100644 src/screenshot/windows/sc_theme.h create mode 100644 src/screenshot/windows/sc_types.h rename src/screenshot/{ => windows}/screenshot_windows.h (100%) rename src/screenshot/{ => windows}/session_windows.cpp (96%) rename src/screenshot/{ => windows}/wndproc_windows.cpp (98%) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6db1f29..de613ab 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -38,20 +38,70 @@ jobs: run: | mkdir -p lib + # 长截图算法层 C++ 对象(与 scripts/build-swift.sh + # 逐字一致,两处编译参数必须同步修改。Apple clang 的 C++ 不支持 -Osize, + # 取等价的 -Oz) + LC_CXX_SOURCES=( + src/screenshot/algo/lc_match_core.cpp + src/screenshot/algo/lc_stitch_state.cpp + src/screenshot/macos/lc_bridge_mac.cpp + ) + LC_CXX_FLAGS=(-c -std=c++17 -Oz -DNDEBUG -I src/screenshot/algo) + + echo "Compiling long-capture algorithm layer objects (arm64)..." + mkdir -p build/lc-obj-arm64 + LC_OBJS_ARM64=() + for src in "${LC_CXX_SOURCES[@]}"; do + obj="build/lc-obj-arm64/$(basename "${src%.cpp}").o" + clang++ "${LC_CXX_FLAGS[@]}" -arch arm64 -mmacosx-version-min=11.0 -o "$obj" "$src" + LC_OBJS_ARM64+=("$obj") + done + + echo "Compiling long-capture algorithm layer objects (x86_64)..." + mkdir -p build/lc-obj-x86_64 + LC_OBJS_X86_64=() + for src in "${LC_CXX_SOURCES[@]}"; do + obj="build/lc-obj-x86_64/$(basename "${src%.cpp}").o" + clang++ "${LC_CXX_FLAGS[@]}" -arch x86_64 -mmacosx-version-min=10.15 -o "$obj" "$src" + LC_OBJS_X86_64+=("$obj") + done + + # Swift 源文件清单(多文件单模块编译;必须与 scripts/build-swift.sh 保持一致) + SWIFT_SOURCES=( + src/ZToolsNative.swift + src/screenshot/macos/ScreenshotMac.swift + src/screenshot/macos/ScreenshotOverlayMac.swift + src/screenshot/macos/ScreenshotPaintMac.swift + src/screenshot/macos/ScreenshotAnnotationsMac.swift + src/screenshot/macos/ScreenshotToolbarMac.swift + src/screenshot/macos/ScreenshotTextMac.swift + src/screenshot/macos/ScreenshotMosaicMac.swift + src/screenshot/macos/ScreenshotOutputMac.swift + src/screenshot/macos/ScreenshotLongCaptureMac.swift + src/screenshot/macos/ScreenshotLCPanelMac.swift + src/screenshot/macos/ScreenshotLCToolbarMac.swift + src/screenshot/macos/LCBridgeMac.swift + src/generated/icon_svgs.swift + ) + # 分别编译两个架构 echo "Building arm64 version..." swiftc -emit-library \ -o lib/libZToolsNative_arm64.dylib \ - src/ZToolsNative.swift \ + "${SWIFT_SOURCES[@]}" \ + "${LC_OBJS_ARM64[@]}" \ -framework Cocoa \ + -lc++ \ -target arm64-apple-macosx11.0 \ -Osize echo "Building x86_64 version..." swiftc -emit-library \ -o lib/libZToolsNative_x86_64.dylib \ - src/ZToolsNative.swift \ + "${SWIFT_SOURCES[@]}" \ + "${LC_OBJS_X86_64[@]}" \ -framework Cocoa \ + -lc++ \ -target x86_64-apple-macosx10.15 \ -Osize @@ -64,6 +114,7 @@ jobs: # 清理临时文件 rm lib/libZToolsNative_arm64.dylib lib/libZToolsNative_x86_64.dylib + rm -rf build/lc-obj-arm64 build/lc-obj-x86_64 - name: Verify build outputs run: | diff --git a/DEPLOY.md b/DEPLOY.md index ca0a24f..fafd2eb 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -5,7 +5,12 @@ 本项目编译后会生成两个关键文件: 1. **`build/Release/ztools_native.node`** - Node.js 原生插件(C++ binding) -2. **`lib/libZToolsNative.dylib`** - Swift 动态库 +2. **`lib/libZToolsNative.dylib`** - Swift 动态库(Universal Binary,arm64 + x86_64) + +> 结构说明(macOS 截图子系统合入后核对):长截图匹配算法层(`lc_match_core.cpp` / +> `lc_stitch_state.cpp`)与 C ABI shim(`lc_bridge_mac.cpp`)在构建期由 clang++ 编出 .o 后随 +> `swiftc -emit-library` **静态链入 dylib 内部**,不产生额外文件——部署产物仍然只有上面 +> 两个文件,无需调整打包清单。 ## 部署到其他项目 @@ -19,7 +24,7 @@ your-project/ │ └── lib/ │ └── mac/ │ ├── ztools_native.node -│ └── libZToolsNative.dylib ��� 必须在同一目录 +│ └── libZToolsNative.dylib ← 必须在同一目录 ``` **使用方法:** @@ -95,7 +100,7 @@ const { app } = require('electron'); if (process.env.NODE_ENV === 'development') { const addon = require('ztools-native-api/build/Release/ztools_native.node'); } -// 生产环��(打包后) +// 生产环境(打包后) else { const resourcePath = process.resourcesPath; const addonPath = path.join(resourcePath, 'lib/mac/ztools_native.node'); @@ -141,17 +146,36 @@ else { ## 权限说明 -### macOS 权限(可选) +### macOS 权限 + +按功能需要授予(系统设置 → 隐私与安全性): -部分功能可能需要: -- **辅助功能权限**:窗口监控功能 -- **屏幕录制权限**:某些窗口信息获取 +- **辅助功能权限**:窗口监控、键盘模拟;截图功能的 ESC/右键兜底取消、长截图滚轮观察与自动滚动(未授权时截图仍可用,兜底能力降级) +- **屏幕录制权限**:区域截图/长截图(`ScreenCapture.start()` 首次调用会弹系统授权框,授权后可能需重启宿主进程;`prime()` 预检未授权时直接返回 false,不弹框) 授权方式: ``` -系统设置 → 隐私与安全性 → 辅助功能 +系统设置 → 隐私与安全性 → 辅助功能 / 屏幕录制 ``` +### macOS 屏幕共享 / 远程桌面注意事项 + +截图捕获底层使用 `CGWindowListCreateImage`(macOS 14 起被系统标记 deprecated,目前仍可用; +升级 ScreenCaptureKit 的评估见 `docs/SCK_UPGRADE_EVALUATION.md`)。其内容来自 WindowServer +的合成结果,在屏幕共享 / 远程桌面会话(如 Safari 网页共享屏幕、macOS「屏幕共享」、第三方 +远程桌面)下,合成路径与本地会话存在差异,且此类环境无法被 CI 覆盖——发布前建议开启屏幕 +共享后人工回归以下场景: + +- 权限预检与授权框弹出行为(`prime()` 不弹框、`start()` 弹框) +- 整屏底图与选区裁剪正确性(多屏 + Retina) +- 放大镜取色与坐标/HEX/RGB 显示 +- 长截图滚动拼接正确率与到底判定 +- ESC 兜底取消(覆盖层失焦时) + +与 Windows 版的 RDP 场景对应:Windows 侧已针对 RDP 鼠标事件节流/合并做了实时命中测试 +(不依赖 hover 缓存,见 `src/screenshot/overlay_input_windows.cpp` 注释);macOS 侧交互为 +NSView 事件 + 实时命中、同样不依赖 hover 缓存,具备对应鲁棒性,仍建议按上表人工回归。 + --- ## 完整示例 diff --git a/README.md b/README.md index c21f7de..888a0b6 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,13 @@ macOS 和 Windows 原生 API 的 Node.js 封装,使用 Swift + Win32 API + Nod 4. **设置激活窗口** - 根据标识符激活指定应用 5. **键盘模拟** - 模拟键盘按键和快捷键(支持修饰键) 6. **粘贴模拟** - 模拟 Cmd+V (macOS) / Ctrl+V (Windows) -7. **区域截图** - 选区截图并自动保存到剪贴板(Windows) -8. **获取选中内容** - 获取当前选中的文本、文件或图像(支持 Cursor/VS Code 等编辑器) -9. **鼠标监控** - 实时监听鼠标移动、点击事件 -10. **鼠标模拟** - 模拟鼠标移动、点击操作 -11. **取色器** - 全屏取色工具 -12. **设置文件窗口地址栏** - 跳转 Finder/Explorer 或文件选择对话框到指定路径 +7. **区域截图** - 选区截图并自动保存到剪贴板(双平台全功能:选区 + 编辑标注 + 圆角导出 + 保存对话框) +8. **长截图** - 手动滚动捕获拼接长图(双平台全功能:特征匹配拼接、自动滚动、小地图、裁剪) +9. **获取选中内容** - 获取当前选中的文本、文件或图像(支持 Cursor/VS Code 等编辑器) +10. **鼠标监控** - 实时监听鼠标移动、点击事件 +11. **鼠标模拟** - 模拟鼠标移动、点击操作 +12. **取色器** - 全屏取色工具 +13. **设置文件窗口地址栏** - 跳转 Finder/Explorer 或文件选择对话框到指定路径 ## 🔧 系统要求 @@ -99,18 +100,21 @@ WindowManager.simulateKeyboardTap('left'); // 6. 模拟粘贴操作 WindowManager.simulatePaste(); -// 7. 区域截图(仅 Windows) +// 7. 区域截图(双平台全功能:选区 + 编辑标注 + 长截图) const { ScreenCapture } = require('ztools-native-api'); +// 默认:拖拽选区松手即出图 ScreenCapture.start((result) => { if (result.success) { console.log(`截图成功!尺寸: ${result.width} x ${result.height}`); - console.log('截图已保存到剪贴板,可按 Ctrl+V 粘贴'); + console.log('截图已保存到剪贴板,可按 Ctrl+V / Cmd+V 粘贴'); } else { - console.log('截图已取消'); + console.log('截图失败或已取消:', result.error || ''); } }); -// 操作:拖拽选择区域后释放鼠标,或按 ESC 取消 +// 双平台流程一致:全屏暗化覆盖层 → 拖拽选区(或单击智能吸附窗口)→ 出图; +// 按 ESC / 点右键取消;autoConfirm=false 可停留在编辑态进行标注/长截图 +// macOS 需要屏幕录制权限(+辅助功能权限),详见下文 API 说明 // 8. 获取选中内容(支持文本、文件、图像) const { getSelectedContent } = require('ztools-native-api'); @@ -313,33 +317,56 @@ WindowManager.simulatePaste(); ### `ScreenCapture` -#### `ScreenCapture.start(callback)` -启动区域截图(仅 Windows) -- **参数**: `callback(result)` - 截图完成时的回调函数 - - `result.success` (boolean) - 是否成功截图 - - `result.width` (number) - 截图宽度(成功时) - - `result.height` (number) - 截图高度(成功时) -- **平台**: ⚠️ 仅支持 Windows +#### `ScreenCapture.prime()` +预抓取当前虚拟屏幕帧(macOS 为所有显示器的并集)。`start()` 会优先消费未过期的预抓帧(2 秒内有效),过期/未命中时现场重抓。 +- **返回**: `boolean` - 是否抓取成功(macOS 未授权屏幕录制时返回 false,但不弹授权框——授权框只在 `start()` 会话内出现) +- **平台**: ✅ Windows 和 macOS -**功能说明**: -- 调用后会创建全屏半透明黑色遮罩 -- 鼠标变为十字光标 -- 拖拽鼠标选择截图区域 -- 释放鼠标后自动截图并保存到剪贴板 -- 按 ESC 键可取消截图 +#### `ScreenCapture.start(options, callback)` +启动区域截图 +- **参数**: + - `options` (Object,可选): + - `autoConfirm` (boolean,默认 `true`) - 选区确定后直接出图,跳过编辑态 + - `longCapture.interval` (number,50~2000ms,默认 250) - 长截图采样防抖间隔(拼接无帧数/像素上限,可持续合并至用户主动结束) + - `callback(result)` - 截图完成时的回调函数 + - `result.success` (boolean) - 是否成功截图 + - `result.x` / `result.y` (number) - 选区左上角(成功时;屏幕全局逻辑坐标,左上原点) + - `result.x2` / `result.y2` (number) - 选区右下角(成功时) + - `result.width` (number) - 截图宽度(成功时) + - `result.height` (number) - 截图高度(成功时) + - `result.base64` (string) - 截图 PNG 的 base64,带 `data:image/png;base64,` 前缀(成功时;已同时写入剪贴板) + - `result.error` (string,可选) - 失败原因(macOS 屏幕录制权限不足时为 `'screen recording permission required'`) +- **平台**: ✅ Windows 和 macOS(全功能对等) + +**平台差异**: +- **交互流程一致**:全屏暗化覆盖层 + 拖拽选区/单击智能窗口吸附 → autoConfirm=true 松手直接出图, + autoConfirm=false 进入编辑态(工具栏 16 按钮、矩形/圆形/箭头/画笔/文字/马赛克标注、撤销/重做、 + 选区圆角手柄)→ 确定/保存/取消/长截图 +- **macOS**: + - 需要屏幕录制权限(未授权时首次 `start()` 弹系统授权框,拒绝后回调 `{ success: false, error: ... }`) + - 另需辅助功能权限:ESC/右键兜底取消(覆盖层失焦时仍可取消)、长截图滚轮观察与自动滚动(CGEventTap) + - `start()` 会**阻塞 JS 主线程**直至会话收束(覆盖层事件循环运行在调用线程上); + 长截图会话期间需从中止时,请从另一进程/线程调用 `abortLongCapture()` + - 选区坐标为屏幕全局逻辑坐标(左上原点);输出图像为逻辑尺寸(Retina 下内部按物理像素捕获后缩回) +- **Windows**: 全屏遮罩 + 拖拽选区 + 编辑标注 + 长截图全功能(坐标系为虚拟屏绝对坐标) +- 会话进行中重复调用 `start()` 会抛出 `Error('Screenshot already in progress')`(双平台一致) **示例**: ```javascript ScreenCapture.start((result) => { if (result.success) { console.log(`截图成功!尺寸: ${result.width}x${result.height}`); - // 截图已在剪贴板中,可按 Ctrl+V 粘贴 + // 截图已在剪贴板中,可按 Ctrl+V / Cmd+V 粘贴 } else { - console.log('截图已取消'); + console.log('截图失败或已取消:', result.error || ''); } }); ``` +#### `ScreenCapture.abortLongCapture()` +中止进行中的长截图滚动捕获。滚动捕获会以失败结果(`success: false`)回调后结束(ESC/工具栏取消同语义:取消 = 失败收束);无进行中的长截图时为安全空操作。 +- **平台**: ✅ Windows 和 macOS。macOS 的 `start()` 阻塞 JS 主线程期间,可从另一进程/工作线程调用(参考 `test/test-screenshot-mac.js` 的子进程注入示例) + --- ### `getSelectedContent()` @@ -410,6 +437,7 @@ npm test node test/test-keyboard.js # 完整键盘测试 node test/test-keyboard-simple.js # 简单键盘测试 node test/test-selected-content.js # 获取选中内容测试 +node test/test-screenshot-mac.js # macOS 区域截图交互测试(真机手工验收,见脚本头说明) ``` ## ⚠️ 平台差异 @@ -420,9 +448,13 @@ node test/test-selected-content.js # 获取选中内容测试 | **激活限制** | 较宽松 | 严格(需要线程附加 hack) | | **剪贴板监控** | 轮询 `changeCount` | 消息循环 + `WM_CLIPBOARDUPDATE` | | **键盘模拟** | ✅ 需要辅助功能权限 | ✅ 无需特殊权限 | -| **区域截图** | ❌ 暂不支持 | ✅ 支持(分层窗口 + GDI) | +| **区域截图** | ✅ 支持(全功能:选区 + 标注 + 圆角导出 + 保存) | ✅ 支持(全功能:选区 + 标注 + 圆角导出 + 保存) | +| **长截图** | ✅ 支持(拼接/自动滚动/小地图/裁剪) | ✅ 支持(拼接/自动滚动/小地图/裁剪) | +| **截图线程模型** | ⚠️ `start()` 阻塞 JS 主线程直至会话收束(覆盖层事件循环在调用线程上) | 独立捕获线程,`start()` 立即返回 | +| **截图标注** | ✅ 矩形/椭圆/箭头/画笔/文字(IME)/马赛克,行为对齐 | ✅ 同左 | +| **截图输出** | ✅ PNG base64 + 剪贴板(原生支持透明 alpha)+ 保存对话框 + 圆角透明导出 | ✅ 同左(圆角透明走 `CF_DIB(V4)+PNG` 双格式) | | **获取选中内容** | ✅ 支持(模拟复制) | ✅ 支持(UI Automation + 剪贴板回退) | -| **权限要求** | 辅助功能权限(键盘模拟) | 无特殊要求 | +| **权限要求** | 辅助功能权限(键盘模拟;截图的 ESC 兜底取消/滚轮观察/自动滚动)+ 屏幕录制权限(截图) | 无特殊要求 | ## 📝 注意事项 @@ -433,6 +465,18 @@ node test/test-selected-content.js # 获取选中内容测试 - 系统偏好设置 → 隐私与安全性 → 辅助功能 - 将你的应用或终端添加到允许列表 - 首次调用会自动提示授权 +- **区域截图需要屏幕录制权限**: + - 系统设置 → 隐私与安全性 → 屏幕录制 + - 首次调用 `ScreenCapture.start()` 会自动弹出系统授权框;授权可能需要重启宿主进程后生效 + - 未授权时回调 `{ success: false, error: 'screen recording permission required' }` + - `ScreenCapture.prime()` 预检未授权时直接返回 false,不弹授权框 +- **截图完整体验还建议授予辅助功能权限**: + - ESC/右键兜底取消(覆盖层失焦时仍可取消,未授权时降级为覆盖层自身按键处理) + - 长截图的滚轮观察与自动滚动(CGEventTap) +- **macOS 截图会话会阻塞 JS 主线程**:`start()` 从调用起阻塞至会话收束(回调在其后触发); + Electron/Node 宿主如需在会话期间执行其他逻辑,请放在 worker 线程或提前调度 +- **交互测试脚本**:`node test/test-screenshot-mac.js`(权限预检 / 成功回调契约 / 编辑态与 + ESC 取消 / 长截图 abort,全交互式,需在真机上按脚本指引执行) ### Windows - Process ID 每次启动都会变化,不适合持久化存储 diff --git a/binding.gyp b/binding.gyp index 9486601..bcf3914 100644 --- a/binding.gyp +++ b/binding.gyp @@ -27,24 +27,25 @@ [ "OS=='win'", { + "include_dirs": ["src/screenshot/algo"], "sources": [ "src/binding_windows.cpp", - "src/screenshot/capture_windows.cpp", - "src/screenshot/icons_windows.cpp", - "src/screenshot/overlay_ui_windows.cpp", - "src/screenshot/overlay_paint_windows.cpp", - "src/screenshot/overlay_input_windows.cpp", - "src/screenshot/annotations_windows.cpp", - "src/screenshot/mosaic_windows.cpp", - "src/screenshot/output_windows.cpp", - "src/screenshot/lc_match_core.cpp", - "src/screenshot/lc_stitch_state.cpp", - "src/screenshot/lc_frame_io.cpp", - "src/screenshot/lc_panel_ui.cpp", - "src/screenshot/lc_toolbar_ui.cpp", - "src/screenshot/lc_session.cpp", - "src/screenshot/wndproc_windows.cpp", - "src/screenshot/session_windows.cpp" + "src/screenshot/windows/capture_windows.cpp", + "src/screenshot/windows/icons_windows.cpp", + "src/screenshot/windows/overlay_ui_windows.cpp", + "src/screenshot/windows/overlay_paint_windows.cpp", + "src/screenshot/windows/overlay_input_windows.cpp", + "src/screenshot/windows/annotations_windows.cpp", + "src/screenshot/windows/mosaic_windows.cpp", + "src/screenshot/windows/output_windows.cpp", + "src/screenshot/algo/lc_match_core.cpp", + "src/screenshot/algo/lc_stitch_state.cpp", + "src/screenshot/windows/lc_frame_io_windows.cpp", + "src/screenshot/windows/lc_panel_ui_windows.cpp", + "src/screenshot/windows/lc_toolbar_ui_windows.cpp", + "src/screenshot/windows/lc_session_windows.cpp", + "src/screenshot/windows/wndproc_windows.cpp", + "src/screenshot/windows/session_windows.cpp" ], "defines": ["_SILENCE_EXPERIMENTAL_COROUTINE_DEPRECATION_WARNINGS"], "libraries": [ diff --git a/index.js b/index.js index 907c586..98e59f1 100644 --- a/index.js +++ b/index.js @@ -113,11 +113,9 @@ class ClipboardMonitor { throw new Error('files array cannot be empty'); } - if (platform === 'win32') { + if (platform === 'win32' || platform === 'darwin') { + // Windows 与 macOS 原生层均已实现(macOS 经 Swift NSPasteboard 写入文件 URL) return addon.setClipboardFiles(files); - } else if (platform === 'darwin') { - // macOS 暂不支持 - throw new Error('setClipboardFiles is not yet supported on macOS'); } return false; } @@ -476,14 +474,11 @@ class ColorPicker { // 区域截图类 class ScreenCapture { /** - * 预抓取当前虚拟屏幕帧 + * 预抓取当前虚拟屏幕帧(macOS 为所有显示器的并集) + * 帧在 2 秒内有效:start() 会优先消费未过期的预抓帧,过期/未命中时现场重抓 * @returns {boolean} 是否抓取成功 */ static prime() { - if (platform === 'darwin') { - throw new Error('ScreenCapture is not yet supported on macOS'); - } - return addon.primeScreenshotFrame(); } @@ -495,16 +490,28 @@ class ScreenCapture { * 点击后进入长截图预览界面:全屏黑色遮罩保留、选区框与底部工具栏保持展示(仅剩完成/取消, * 其他操作禁用);选区内直通底层应用,滚轮滚动页面(鼠标按键被吞,防止误触), * 侧边小地图实时展示拼接长图(外框=已捕获区域,蓝色内框=当前可见区域,随滚动移动); - * 向下滚动追加新内容到底部、向上滚动前插到头部;点「完成」出图、「取消」或 ESC 中止 - * @param {number} [options.longCapture.maxFrames=100] - 最大拼接帧数(1~200,达到上限自动完成) + * 向下滚动追加新内容到底部、向上滚动前插到头部;点「完成」出图、「取消」或 ESC 中止; + * 拼接无帧数/像素上限,可持续合并至用户主动结束 * @param {number} [options.longCapture.interval=250] - 滚轮停止后等待内容稳定的毫秒数(50~2000,采样防抖; * 滚动进行中也会按不低于 min(interval, 250)ms 的节拍主动采样,保证相邻帧有大重叠区域) * @param {Function} [callback] - 截图完成时的回调函数 - * - 参数: { success: boolean, width?: number, height?: number, base64?: string } + * - 参数: { success: boolean, x?: number, y?: number, x2?: number, y2?: number, width?: number, height?: number, base64?: string, error?: string } * - success: 是否成功截图 + * - x/y: 选区左上角(成功时;屏幕全局逻辑坐标,左上原点) + * - x2/y2: 选区右下角(成功时) * - width: 截图宽度(成功时;长截图为拼接后的总宽度) * - height: 截图高度(成功时;长截图为拼接后的总高度) - * - base64: 截图 PNG 的 base64(成功时;长截图已同时写入剪贴板) + * - base64: 截图 PNG 的 base64,带 data:image/png;base64, 前缀(成功时;已同时写入剪贴板) + * - error: 失败原因(可选;macOS 屏幕录制权限不足时为 'screen recording permission required') + * + * macOS 说明(macOS 已具备与 Windows 对等的全功能截图): + * - 需要屏幕录制权限,未授权时首次调用会弹出系统授权框,拒绝后回调 { success: false, error: ... } + * - 选区 UI(暗化蒙版 + 拖拽框选/窗口吸附/放大镜)、编辑态(工具栏/矢量与文字标注/IME/ + * 马赛克/撤销重做/选区圆角)、圆角透明导出、保存对话框与长截图全子系统均已实现, + * 行为与 Windows 版对齐;差异项见 README「平台差异」表 + * - 额外需要辅助功能权限:ESC/右键兜底取消、长截图滚轮观察与 autoScroll(CGEventTap) + * - macOS 的 start() 会阻塞 JS 主线程直至会话收束(覆盖层事件循环运行在调用线程上), + * 会话期间无法用本进程定时器触发 abortLongCapture,需要时请从另一进程/线程调用 * * @example * // 默认:框选/点选完成即出图,不再二次编辑 @@ -516,15 +523,10 @@ class ScreenCapture { * // 编辑态 + 长截图:选区确定后点工具栏「长截图」按钮进入手动滚动捕获 * ScreenCapture.start({ * autoConfirm: false, - * longCapture: { maxFrames: 100, interval: 250 } + * longCapture: { interval: 250 } * }, (result) => { ... }); */ static start(options, callback) { - if (platform === 'darwin') { - // macOS 暂不支持 - throw new Error('ScreenCapture is not yet supported on macOS'); - } - // 兼容旧签名 start(callback) if (typeof options === 'function') { callback = options; @@ -541,13 +543,12 @@ class ScreenCapture { } /** - * 中止进行中的长截图滚动捕获(Windows) - * 滚动捕获会以失败结果(success: false)回调后结束 + * 中止进行中的长截图滚动捕获(Windows / macOS 双平台) + * 滚动捕获会以失败结果(success: false)回调后结束(ESC/取消同语义:取消 = 失败收束); + * 无进行中的长截图时为安全空操作。可在任意线程/进程调用(macOS 会话期间 JS 主线程 + * 被阻塞时,从另一进程或工作线程调用即可,见 test/test-screenshot-mac.js 的注入示例) */ static abortLongCapture() { - if (platform === 'darwin') { - throw new Error('ScreenCapture is not yet supported on macOS'); - } addon.abortLongCapture(); } } diff --git a/scripts/build-swift.sh b/scripts/build-swift.sh old mode 100644 new mode 100755 index 35da81d..0b03fb4 --- a/scripts/build-swift.sh +++ b/scripts/build-swift.sh @@ -10,24 +10,83 @@ cd "$(dirname "$0")/.." # 创建 lib 目录 mkdir -p lib +# 前置:生成图标资源(icon_svgs.swift 是 SWIFT_SOURCES 之一,缺失会导致 swiftc 失败; +# 脚本幂等,npm run build 链路中会重复执行但开销可忽略) +node scripts/gen-icons.js + # 检测当前架构 ARCH=$(uname -m) echo "📱 当前架构: $ARCH" +# 长截图算法层 C++ 对象(lc_match_core + lc_stitch_state +# 纯算法层与 lc_bridge C ABI shim,clang++ 编出 .o 后随 swiftc -emit-library 链入 +# dylib;Swift 侧经 src/screenshot/macos/LCBridgeMac.swift 的 @_silgen_name 调用。 +# 注意:本段与 .github/workflows/build.yml「Build Swift library」步骤内联实现逐字一致 +# (两处编译参数必须同步修改)。Apple clang 的 C++ 不支持 -Osize,取等价的 -Oz。 +# -I:算法层头文件目录(lc_bridge_mac.cpp 位于 macos/,需检索 ../algo 的 +# long_capture_internal.h / lc_platform.h)。 +LC_CXX_SOURCES=( + src/screenshot/algo/lc_match_core.cpp + src/screenshot/algo/lc_stitch_state.cpp + src/screenshot/macos/lc_bridge_mac.cpp +) +LC_CXX_FLAGS=(-c -std=c++17 -Oz -DNDEBUG -I src/screenshot/algo) + +echo "🔧 Compiling long-capture algorithm layer objects (arm64)..." +mkdir -p build/lc-obj-arm64 +LC_OBJS_ARM64=() +for src in "${LC_CXX_SOURCES[@]}"; do + obj="build/lc-obj-arm64/$(basename "${src%.cpp}").o" + clang++ "${LC_CXX_FLAGS[@]}" -arch arm64 -mmacosx-version-min=11.0 -o "$obj" "$src" + LC_OBJS_ARM64+=("$obj") +done + +echo "🔧 Compiling long-capture algorithm layer objects (x86_64)..." +mkdir -p build/lc-obj-x86_64 +LC_OBJS_X86_64=() +for src in "${LC_CXX_SOURCES[@]}"; do + obj="build/lc-obj-x86_64/$(basename "${src%.cpp}").o" + clang++ "${LC_CXX_FLAGS[@]}" -arch x86_64 -mmacosx-version-min=10.15 -o "$obj" "$src" + LC_OBJS_X86_64+=("$obj") +done + # 构建 Universal Binary(同时支持 arm64 和 x86_64) +# Swift 源文件清单(多文件单模块编译;新增截图等模块文件时必须同步修改 +# .github/workflows/build.yml 中内联的 swiftc 步骤,两处保持一致) +SWIFT_SOURCES=( + src/ZToolsNative.swift + src/screenshot/macos/ScreenshotMac.swift + src/screenshot/macos/ScreenshotOverlayMac.swift + src/screenshot/macos/ScreenshotPaintMac.swift + src/screenshot/macos/ScreenshotAnnotationsMac.swift + src/screenshot/macos/ScreenshotToolbarMac.swift + src/screenshot/macos/ScreenshotTextMac.swift + src/screenshot/macos/ScreenshotMosaicMac.swift + src/screenshot/macos/ScreenshotOutputMac.swift + src/screenshot/macos/ScreenshotLongCaptureMac.swift + src/screenshot/macos/ScreenshotLCPanelMac.swift + src/screenshot/macos/ScreenshotLCToolbarMac.swift + src/screenshot/macos/LCBridgeMac.swift + src/generated/icon_svgs.swift +) + echo "🔧 Building arm64 version..." swiftc -emit-library \ -o lib/libZToolsNative_arm64.dylib \ - src/ZToolsNative.swift \ + "${SWIFT_SOURCES[@]}" \ + "${LC_OBJS_ARM64[@]}" \ -framework Cocoa \ + -lc++ \ -target arm64-apple-macosx11.0 \ -Osize echo "🔧 Building x86_64 version..." swiftc -emit-library \ -o lib/libZToolsNative_x86_64.dylib \ - src/ZToolsNative.swift \ + "${SWIFT_SOURCES[@]}" \ + "${LC_OBJS_X86_64[@]}" \ -framework Cocoa \ + -lc++ \ -target x86_64-apple-macosx10.15 \ -Osize @@ -39,8 +98,8 @@ lipo -create \ # 清理临时文件 rm lib/libZToolsNative_arm64.dylib lib/libZToolsNative_x86_64.dylib +rm -rf build/lc-obj-arm64 build/lc-obj-x86_64 # 验证 Universal Binary echo "✅ Swift library built successfully: lib/libZToolsNative.dylib" lipo -info lib/libZToolsNative.dylib - diff --git a/scripts/gen-icons.js b/scripts/gen-icons.js index 95e42d9..1b9021c 100644 --- a/scripts/gen-icons.js +++ b/scripts/gen-icons.js @@ -1,19 +1,29 @@ #!/usr/bin/env node /** - * 把 src/assets/*.svg 生成成 C 头文件 src/generated/icon_svgs.h。 + * 把 src/assets/*.svg 生成成 C 头文件 src/generated/icon_svgs.h, + * 以及(macOS 有 clang 时)Swift 图标文件 src/generated/icon_svgs.swift。 * - * 输出形如: + * C 输出形如: * static const char* kIconSvg_Rect = "..."; - * * SVG 原文原样保留(含 currentColor),运行时再把 currentColor 替换成 * 目标颜色(normal/hover/active 三态),从而一套文本支持多色。 * - * 自动生成,请勿手动编辑。 + * Swift 输出:生成期用 clang 现编一个极小 nanosvg 光栅化辅助程序 + * (单实例化编译单元,参照 icons_windows.cpp 的宏实例化写法),把每个 SVG + * (currentColor→黑色)光栅化为 PNG(2x、内容 72% 居中)→ base64 内联进 + * icon_svgs.swift;运行时 NSImage(data:) 解码为 template 图标并按需着色出 + * normal/hover/active 三态缓存,等价 Windows icons_windows.cpp 的双色位图缓存。 + * clang 不可用(如 Windows)时跳过 Swift 输出(文件已提交进仓库,不受影响)。 + * + * 自动生成,请勿手动编辑。重新生成命令:node scripts/gen-icons.js */ const fs = require('fs'); +const os = require('os'); const path = require('path'); +const zlib = require('zlib'); +const { execFileSync } = require('child_process'); -// 工具栏按钮 -> SVG 文件名映射(顺序与 src/screenshot/internal.h 的 ToolButton 枚举一致) +// 工具栏按钮 -> SVG 文件名映射(顺序与 src/screenshot/windows/internal.h 的 ToolButton 枚举一致) // 分隔线(Separator)对应 null,不生成图标。 const ICON_MAP = { Drag: 'drag', @@ -47,20 +57,271 @@ const ICON_MAP = { const ASSETS_DIR = path.join(__dirname, '..', 'src', 'assets'); const OUT_DIR = path.join(__dirname, '..', 'src', 'generated'); const OUT_FILE = path.join(OUT_DIR, 'icon_svgs.h'); +const OUT_SWIFT_FILE = path.join(OUT_DIR, 'icon_svgs.swift'); -function main() { - if (!fs.existsSync(ASSETS_DIR)) { - console.error(`✖ assets dir not found: ${ASSETS_DIR}`); - process.exit(1); +// Swift 图标 PNG 光栅化边长(物理像素):逻辑 26pt(= Windows CalcToolbarMetrics.iconSize +// = SC_TOOLBAR_BTN-8+2)× 2(Retina 2x,绘制到 26pt 矩形时逐像素对齐)。 +const SWIFT_ICON_PX = 52; + +// ---------------- PNG 编码(Node 内置 zlib,无第三方依赖) ---------------- + +// CRC32 查表(PNG chunk 校验用) +const CRC_TABLE = (() => { + const t = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1); + t[n] = c >>> 0; } - fs.mkdirSync(OUT_DIR, { recursive: true }); + return t; +})(); + +function crc32(buf) { + let c = 0xFFFFFFFF; + for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xFF] ^ (c >>> 8); + return (c ^ 0xFFFFFFFF) >>> 0; +} + +function pngChunk(type, data) { + const len = Buffer.alloc(4); + len.writeUInt32BE(data.length, 0); + const typeBuf = Buffer.from(type, 'ascii'); + const crcBuf = Buffer.alloc(4); + crcBuf.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0); + return Buffer.concat([len, typeBuf, data, crcBuf]); +} + +/** RGBA 原始字节 → PNG(8bit RGBA、filter 0;仅生成期使用)。 */ +function encodePng(width, height, rgba) { + const stride = width * 4; + const raw = Buffer.alloc((stride + 1) * height); + for (let y = 0; y < height; y++) { + raw[y * (stride + 1)] = 0; // filter type 0(None) + rgba.copy(raw, y * (stride + 1) + 1, y * stride, (y + 1) * stride); + } + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(width, 0); + ihdr.writeUInt32BE(height, 4); + ihdr[8] = 8; // bit depth + ihdr[9] = 6; // color type RGBA + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]), + pngChunk('IHDR', ihdr), + pngChunk('IDAT', zlib.deflateSync(raw, { level: 9 })), + pngChunk('IEND', Buffer.alloc(0)), + ]); +} + +// ---------------- 生成期 nanosvg 光栅化辅助程序(clang 现编现用) ---------------- + +// 极小 C 程序:把每个 SVG(currentColor→黑)光栅化为 px×px RGBA 原始文件。 +// NANOSVG_IMPLEMENTATION / NANOSVGRAST_IMPLEMENTATION 在同一编译单元各展开一次 +//(与 Windows icons_windows.cpp 的单实例化写法一致)。 +const RASTERIZER_C_TEMPLATE = `/* AUTO-GENERATED by scripts/gen-icons.js — 生成期临时文件,用完即删 */ +#define NANOSVG_IMPLEMENTATION +#include "nanosvg.h" +#define NANOSVGRAST_IMPLEMENTATION +#include "nanosvgrast.h" + +#include +#include +#include + +typedef struct { const char* key; const char* svg; } IconEntry; + +static const IconEntry kIcons[] = { +__ICON_ENTRIES__ +}; + +/* 把 src 中所有 token 替换为 rep 后写入 dst(rep 比 token 短,dst 容量取 src 长度 + 1 即可) */ +static void replaceToken(char* dst, const char* src, const char* token, const char* rep) { + const size_t tlen = strlen(token); + const size_t rlen = strlen(rep); + const char* p = src; + char* w = dst; + const char* hit; + while ((hit = strstr(p, token)) != NULL) { + memcpy(w, p, (size_t)(hit - p)); + w += hit - p; + memcpy(w, rep, rlen); + w += rlen; + p = hit + tlen; + } + strcpy(w, p); +} + +int main(int argc, char** argv) { + if (argc < 3) { fprintf(stderr, "usage: rasterizer \\n"); return 1; } + const char* outDir = argv[1]; + const int px = atoi(argv[2]); + NSVGrasterizer* rast = nsvgCreateRasterizer(); + if (!rast) return 1; + for (int i = 0; i < (int)(sizeof(kIcons) / sizeof(kIcons[0])); i++) { + /* currentColor -> 黑色(nsvgParse 会就地改写 buffer,需可写副本) */ + char* buf = (char*)malloc(strlen(kIcons[i].svg) + 1); + if (!buf) continue; + replaceToken(buf, kIcons[i].svg, "currentColor", "#000000"); + NSVGimage* image = nsvgParse(buf, "px", 96.0f); + if (!image) { free(buf); continue; } + unsigned char* rgba = (unsigned char*)calloc((size_t)px * px * 4, 1); + if (rgba) { + /* 内容缩放到 72% 并居中(与 Windows icons_windows.cpp RenderSvgToBitmap 一致) */ + const float contentScale = 0.72f; + const float pad = (1.0f - contentScale) * 0.5f; + float refSize = (image->width > 0) ? image->width + : (image->height > 0) ? image->height : 24.0f; + float scale = (float)px * contentScale / refSize; + nsvgRasterize(rast, image, px * pad, px * pad, scale, rgba, px, px, px * 4); + char outPath[1024]; + snprintf(outPath, sizeof(outPath), "%s/%s.rgba", outDir, kIcons[i].key); + FILE* f = fopen(outPath, "wb"); + if (f) { + fwrite(rgba, 1, (size_t)px * px * 4, f); + fclose(f); + } + free(rgba); + } + nsvgDelete(image); + free(buf); + } + nsvgDeleteRasterizer(rast); + return 0; +} +`; + +/** 生成期光栅化:返回 { key -> base64 PNG };clang/编译失败返回 null(调用方跳过 Swift 输出)。 */ +function rasterizeIconsToPngBase64(assetsByKey) { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ztools-icons-')); + try { + const cPath = path.join(tmpDir, 'rasterizer.c'); + const binPath = path.join(tmpDir, 'rasterizer'); + const entries = Object.entries(assetsByKey) + .map(([key, svg]) => ` {"${key}",\n "${svg.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"},`) + .join('\n'); + fs.writeFileSync(cPath, RASTERIZER_C_TEMPLATE.replace('__ICON_ENTRIES__', entries), 'utf8'); + execFileSync('clang', ['-O2', '-std=c99', '-o', binPath, cPath, + '-I', path.join(__dirname, '..', 'src', 'third_party')], { stdio: 'inherit' }); + const outDir = path.join(tmpDir, 'out'); + fs.mkdirSync(outDir, { recursive: true }); + execFileSync(binPath, [outDir, String(SWIFT_ICON_PX)], { stdio: 'inherit' }); + + const result = {}; + for (const key of Object.keys(assetsByKey)) { + const rawPath = path.join(outDir, `${key}.rgba`); + const rgba = fs.readFileSync(rawPath); + if (rgba.length !== SWIFT_ICON_PX * SWIFT_ICON_PX * 4) { + throw new Error(`unexpected rgba size for ${key}: ${rgba.length}`); + } + result[key] = encodePng(SWIFT_ICON_PX, SWIFT_ICON_PX, rgba).toString('base64'); + } + return result; + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +} + +// ---------------- Swift 输出 ---------------- + +/** ICON_MAP 键 → Swift 枚举 case 名(首字母小写驼峰化)。 */ +function swiftCaseName(key) { + const name = key.charAt(0).toLowerCase() + key.slice(1); + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) { + throw new Error(`ICON_MAP key ${key} is not a valid Swift identifier`); + } + return name; +} +/** 生成 src/generated/icon_svgs.swift(base64 PNG 表 + template 着色运行时)。 */ +function writeSwiftOutput(pngBase64ByKey) { + const keys = Object.keys(ICON_MAP); + // 防漂移断言:ICON_MAP 键 ↔ Swift 枚举 case 必须一一对齐(数量与顺序同源生成, + // 此处再显式校验一次,ICON_MAP 结构变化时立即报错而非静默漂移)。 + const cases = keys.map(swiftCaseName); + if (new Set(cases).size !== cases.length) { + throw new Error('ICON_MAP keys collide after camelCase conversion'); + } + const lines = []; + lines.push('import AppKit'); + lines.push(''); + lines.push('// AUTO-GENERATED by scripts/gen-icons.js — DO NOT EDIT.'); + lines.push('// 生成方式:node scripts/gen-icons.js(构建期工具;src/assets/*.svg 经 clang 现编的'); + lines.push('// nanosvg 极小光栅化辅助程序渲染为 PNG(currentColor→黑、2x、内容 72% 居中)→ base64。'); + lines.push('// 运行时 NSImage(data:) 解码为 template 图标,按需着色出 normal/hover/active 三态缓存,'); + lines.push('// 等价 Windows icons_windows.cpp SCIconCache 的 dark/active 双色位图缓存语义。'); + lines.push('// 重新生成命令:node scripts/gen-icons.js'); + lines.push(''); + lines.push('/// 工具栏图标枚举:raw 值按 scripts/gen-icons.js ICON_MAP 顺序对齐(键名首字母小写化),'); + lines.push('/// 生成器内已断言 ICON_MAP 键与本枚举一一对齐(防漂移)。分隔线无图标不列入。'); + lines.push('enum SCToolbarIcon: Int, CaseIterable {'); + cases.forEach((name, i) => { + lines.push(` case ${name} = ${i}`); + }); + lines.push('}'); + lines.push(''); + lines.push('/// base64 PNG 表(顺序与 SCToolbarIcon.allCases 一致,由生成器保证)。'); + lines.push('private let kIconPngBase64: [String] = ['); + keys.forEach((key) => { + lines.push(` /* ${swiftCaseName(key)} */ "${pngBase64ByKey[key]}",`); + }); + lines.push(']'); + lines.push(''); + lines.push('extension SCToolbarIcon {'); + lines.push(' /// 图标绘制逻辑边长(对齐 Windows CalcToolbarMetrics.iconSize = SC_TOOLBAR_BTN-8+2;'); + lines.push(' /// 生成 PNG 为其 2x 物理像素,Retina 下逐像素清晰)。'); + lines.push(' static let iconPointSize: CGFloat = 26'); + lines.push(''); + lines.push(' private static let cacheLock = NSLock()'); + lines.push(' private static var baseCache: [Int: NSImage] = [:]'); + lines.push(' private static var tintedCache: [String: NSImage] = [:]'); + lines.push(''); + lines.push(' /// 解码原始 PNG 为 template 图标(isTemplate=true,颜色语义仅保留 alpha)。'); + lines.push(' /// 解码结果按 rawValue 缓存,重复取用零开销(等价 Windows 位图缓存的命中路径)。'); + lines.push(' var baseImage: NSImage? {'); + lines.push(' if let hit = SCToolbarIcon.baseCache[rawValue] { return hit }'); + lines.push(' guard rawValue >= 0, rawValue < kIconPngBase64.count,'); + lines.push(' let data = Data(base64Encoded: kIconPngBase64[rawValue]),'); + lines.push(' let image = NSImage(data: data) else { return nil }'); + lines.push(' image.isTemplate = true'); + lines.push(' image.size = NSSize(width: SCToolbarIcon.iconPointSize, height: SCToolbarIcon.iconPointSize)'); + lines.push(' SCToolbarIcon.baseCache[rawValue] = image'); + lines.push(' return image'); + lines.push(' }'); + lines.push(''); + lines.push(' /// 按指定颜色着色的图标副本:纯色填充后以 destinationIn 用底图 alpha 裁形,'); + lines.push(' /// 得到任意单色版本(normal 深灰 / active 主题蓝 / 置灰半透明)。'); + lines.push(' /// 结果按「图标+颜色」缓存,三态派生只算一次(等价 Windows dark/active 双色缓存)。'); + lines.push(' /// - Parameter color: 目标着色(sRGB)'); + lines.push(' /// - Returns: 着色后的 26pt 图标;解码失败返回 nil'); + lines.push(' func tinted(_ color: NSColor) -> NSImage? {'); + lines.push(' guard let srgb = color.usingColorSpace(.sRGB) else { return baseImage }'); + lines.push(' let key = String(format: "%d-%02X%02X%02X%02X", rawValue,'); + lines.push(' Int(srgb.redComponent * 255), Int(srgb.greenComponent * 255),'); + lines.push(' Int(srgb.blueComponent * 255), Int(srgb.alphaComponent * 255))'); + lines.push(' if let hit = SCToolbarIcon.tintedCache[key] { return hit }'); + lines.push(' guard let base = baseImage else { return nil }'); + lines.push(' let image = NSImage(size: base.size, flipped: false) { rect in'); + lines.push(' srgb.setFill()'); + lines.push(' rect.fill()'); + lines.push(' base.draw(in: rect, from: .zero, operation: .destinationIn, fraction: 1.0)'); + lines.push(' return true'); + lines.push(' }'); + lines.push(' image.isTemplate = false'); + lines.push(' SCToolbarIcon.tintedCache[key] = image'); + lines.push(' return image'); + lines.push(' }'); + lines.push('}'); + lines.push(''); + fs.writeFileSync(OUT_SWIFT_FILE, lines.join('\n'), 'utf8'); +} + +// ---------------- C 头文件输出 ---------------- + +function writeHeaderOutput() { const lines = []; lines.push('#pragma once'); lines.push('// AUTO-GENERATED by scripts/gen-icons.js — DO NOT EDIT.'); lines.push('// SVG 原文保留 currentColor,运行时替换为目标颜色。'); lines.push(''); - for (const [key, fileBase] of Object.entries(ICON_MAP)) { const svgPath = path.join(ASSETS_DIR, `${fileBase}.svg`); if (!fs.existsSync(svgPath)) { @@ -87,7 +348,46 @@ function main() { lines.push(''); fs.writeFileSync(OUT_FILE, lines.join('\n'), 'utf8'); +} + +function main() { + if (!fs.existsSync(ASSETS_DIR)) { + console.error(`✖ assets dir not found: ${ASSETS_DIR}`); + process.exit(1); + } + fs.mkdirSync(OUT_DIR, { recursive: true }); + + // 1) C 头文件(Windows 侧运行时光栅化的输入,全平台生成) + writeHeaderOutput(); console.log(`✓ generated ${path.relative(path.join(__dirname, '..'), OUT_FILE)} (${Object.keys(ICON_MAP).length} icons)`); + + // 2) Swift 输出(生成期光栅化;无 clang 的环境跳过——文件已提交进仓库) + let hasClang = false; + if (process.platform === 'darwin') { + try { + execFileSync('clang', ['--version'], { stdio: 'ignore' }); + hasClang = true; + } catch { + hasClang = false; + } + } + if (!hasClang) { + console.log('ℹ clang unavailable - skip icon_svgs.swift regeneration (committed file kept)'); + return; + } + + const assetsByKey = {}; + for (const [key, fileBase] of Object.entries(ICON_MAP)) { + const svgPath = path.join(ASSETS_DIR, `${fileBase}.svg`); + if (!fs.existsSync(svgPath)) { + console.error(`✖ missing svg: ${svgPath}`); + process.exit(1); + } + assetsByKey[key] = fs.readFileSync(svgPath, 'utf8').trim().replace(/\r?\n/g, '').replace(/\s+/g, ' '); + } + const pngBase64ByKey = rasterizeIconsToPngBase64(assetsByKey); + writeSwiftOutput(pngBase64ByKey); + console.log(`✓ generated ${path.relative(path.join(__dirname, '..'), OUT_SWIFT_FILE)} (${Object.keys(ICON_MAP).length} icons, ${SWIFT_ICON_PX}px PNG @2x)`); } main(); diff --git a/src/binding_mac.cpp b/src/binding_mac.cpp index 1f59f64..12f0d14 100644 --- a/src/binding_mac.cpp +++ b/src/binding_mac.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include // For usleep // Swift 动态库函数类型定义 @@ -34,6 +35,10 @@ typedef void (*StopColorPickerFunc)(); // 停止取 typedef void *(*FetchFileIconFunc)(const char *, size_t *); // 获取文件图标 PNG typedef char *(*GetAllFinderWindowsFunc)(); // 获取所有 Finder 窗口 typedef int (*SetAddressBarFunc)(const char *, const char *); // 设置 Finder/文件对话框地址 +typedef void (*ScreenshotResultCB)(const char *); // 截图会话结果回调(JSON 字符串) +typedef int (*PrimeScreenshotFrameFunc)(); // 预抓整屏帧(返回 1/0) +typedef int (*StartRegionCaptureFunc)(const char *, ScreenshotResultCB); // 启动区域截图会话(返回 1 受理/0 拒绝) +typedef void (*AbortLongCaptureFunc)(); // 中止长截图滚动捕获 // 全局变量 static void *swiftLibHandle = nullptr; @@ -67,6 +72,16 @@ static GetAllFinderWindowsFunc getAllFinderWindowsFunc = nullptr; static SetAddressBarFunc setAddressBarFunc = nullptr; static bool g_isPaused = false; // 剪贴板监控暂停状态 +// 截图模块(Swift 实现,见 src/screenshot/macos/ScreenshotMac.swift) +static napi_threadsafe_function screenshotTsfn = nullptr; // 截图结果回调线程安全函数 +static PrimeScreenshotFrameFunc primeScreenshotFrameFunc = nullptr; +static StartRegionCaptureFunc startRegionCaptureFunc = nullptr; +static AbortLongCaptureFunc abortLongCaptureFunc = nullptr; +// 截图会话进行中标志(重入保护,对齐 Windows g_isCapturing):start 受理前置 true, +// Swift 会话出口回调(OnScreenshotResult)或 Swift 拒绝受理时复位 false。 +// JS 线程串行读写,Swift 侧仅通过回调间接复位,无需更强同步。 +static std::atomic g_screenshotInProgress(false); + // 在主线程调用 JS 回调 void CallJs(napi_env env, napi_value js_callback, void *context, void *data) { if (env != nullptr && js_callback != nullptr) { @@ -241,6 +256,12 @@ bool LoadSwiftLibrary(Napi::Env env) { (GetAllFinderWindowsFunc)dlsym(swiftLibHandle, "getAllFinderWindows"); setAddressBarFunc = (SetAddressBarFunc)dlsym(swiftLibHandle, "setAddressBar"); + primeScreenshotFrameFunc = + (PrimeScreenshotFrameFunc)dlsym(swiftLibHandle, "primeScreenshotFrame"); + startRegionCaptureFunc = + (StartRegionCaptureFunc)dlsym(swiftLibHandle, "startRegionCaptureWithPrimedFrame"); + abortLongCaptureFunc = + (AbortLongCaptureFunc)dlsym(swiftLibHandle, "abortLongCapture"); if (!startMonitorFunc || !stopMonitorFunc || !startWindowMonitorFunc || !stopWindowMonitorFunc || !getActiveWindowFunc || !activateWindowFunc || @@ -249,7 +270,9 @@ bool LoadSwiftLibrary(Napi::Env env) { !simulateMouseDoubleClickFunc || !simulateMouseRightClickFunc || !startMouseMonitorFunc || !stopMouseMonitorFunc || !startColorPickerFunc || !stopColorPickerFunc || - !setClipboardFilesFunc || !fetchFileIconFunc) { + !setClipboardFilesFunc || !fetchFileIconFunc || + !primeScreenshotFrameFunc || !startRegionCaptureFunc || + !abortLongCaptureFunc) { Napi::Error::New(env, "Failed to load Swift functions") .ThrowAsJavaScriptException(); dlclose(swiftLibHandle); @@ -1289,6 +1312,218 @@ Napi::Value SetAddressBar(const Napi::CallbackInfo &info) { return Napi::Boolean::New(env, success == 1); } +// ==================== 区域截图(macOS,Swift 实现见 src/screenshot/macos/ScreenshotMac.swift)==================== + +// 在主线程调用 JS 回调(截图结果):解析 Swift 会话出口的结果 JSON 并构造契约对象。 +// 契约字段与 Windows CallScreenshotJs 一致:success=true 时输出 +// x/y/x2/y2/width/height/base64;success=false 时仅输出 success 与可选 error +//(macOS 在既有契约上新增的可选字段,如屏幕录制权限不足),不改既有字段。 +// data 所有权归本函数(strdup 副本,进入时释放)。 +void CallScreenshotJs(napi_env env, napi_value js_callback, void *context, + void *data) { + if (env != nullptr && js_callback != nullptr && data != nullptr) { + char *jsonStr = static_cast(data); + Napi::Env napiEnv(env); + std::string jsonString(jsonStr); + free(jsonStr); + + Napi::Object result = Napi::Object::New(napiEnv); + + Napi::Value parsed = ParseJsonValue(napiEnv, jsonString); + bool success = false; + if (parsed.IsObject()) { + Napi::Object source = parsed.As(); + if (source.Has("success") && source.Get("success").IsBoolean()) { + success = source.Get("success").As().Value(); + } + + result.Set("success", Napi::Boolean::New(napiEnv, success)); + + if (success) { + // 数值字段缺省 0、base64 缺省空串,保证契约字段齐全 + const char *numFields[] = {"x", "y", "x2", "y2", "width", "height"}; + for (const char *fieldName : numFields) { + int value = 0; + if (source.Has(fieldName) && source.Get(fieldName).IsNumber()) { + value = source.Get(fieldName).As().Int32Value(); + } + result.Set(fieldName, Napi::Number::New(napiEnv, value)); + } + std::string base64; + if (source.Has("base64") && source.Get("base64").IsString()) { + base64 = source.Get("base64").As().Utf8Value(); + } + result.Set("base64", Napi::String::New(napiEnv, base64)); + } else if (source.Has("error") && source.Get("error").IsString()) { + result.Set("error", source.Get("error").As()); + } + } else { + // 非对象 JSON(理论不可达,Swift 出口恒产出合法 JSON):按失败收口 + result.Set("success", Napi::Boolean::New(napiEnv, false)); + } + + napi_value global; + napi_get_global(env, &global); + napi_value resultValue = result; + napi_call_function(env, global, js_callback, 1, &resultValue, nullptr); + } +} + +// 统一发射口(对齐 Windows session_windows.cpp 的 EmitScreenshotResult): +// Swift 会话出口的结果 JSON 经 screenshotTsfn 回传 JS。守卫:TSFN 未就绪或 +// napi_tsfn_nonblocking 因队列满返回非 napi_ok 时,自行释放分配的副本防泄漏 +//(CallScreenshotJs 只在成功入队时才 free)。 +void EmitScreenshotResult(const char *jsonStr) { + if (screenshotTsfn == nullptr || jsonStr == nullptr) { + return; + } + char *jsonCopy = strdup(jsonStr); + if (jsonCopy == nullptr) { + return; + } + if (napi_call_threadsafe_function(screenshotTsfn, jsonCopy, + napi_tsfn_nonblocking) != napi_ok) { + // 入队失败:回调不会取走所有权,自行释放防泄漏 + free(jsonCopy); + } +} + +// 会话出口统一收口:释放截图会话 TSFN 并置空(进程唯一释放点,保证 Node 优雅退出)。 +// 前提:Swift 会话出口恰好回调一次(成功/失败均必达——FailFast 语义),释放时已无 +// 后续发射点;释放后已入队的结果仍会送达 JS(与 Windows ReleaseScreenshotTsfn 一致)。 +void ReleaseScreenshotTsfn() { + if (screenshotTsfn != nullptr) { + napi_release_threadsafe_function(screenshotTsfn, napi_tsfn_release); + screenshotTsfn = nullptr; + } +} + +// Swift 截图会话出口回调(注册给 startRegionCaptureWithPrimedFrame;每会话恰好调用 +// 一次,任何初始化/执行失败都由 Swift FailFast 保证走到此处):先经统一发射口把结果 +// 回传 JS,再释放 TSFN 并复位进行中标志,允许下一次 start(时序对齐 Windows 捕获线程末尾)。 +void OnScreenshotResult(const char *jsonStr) { + EmitScreenshotResult(jsonStr); + ReleaseScreenshotTsfn(); + g_screenshotInProgress = false; +} + +// 供 JS 主动触发的整屏预抓帧(对齐 Windows PrimeScreenshotFrame 导出)。 +// 返回 boolean:true 抓帧成功;false 失败(无屏幕录制权限或抓帧失败)。 +Napi::Value PrimeScreenshotFrame(const Napi::CallbackInfo &info) { + Napi::Env env = info.Env(); + + if (!LoadSwiftLibrary(env)) { + return Napi::Boolean::New(env, false); + } + + const bool success = primeScreenshotFrameFunc() == 1; + return Napi::Boolean::New(env, success); +} + +// 启动区域截图(macOS 最小闭环:权限预检 → 整屏底图 → PNG → 剪贴板 → 回调)。 +// 参数解析对齐 Windows session_windows.cpp:回调函数 + 可选选项对象 +// { autoConfirm: boolean(默认 true), longCapture: { interval: 50~2000 默认 250 } }, +// 越界值忽略保持默认;长截图功能已落地,参数先行按契约钳制并透传给 Swift。 +Napi::Value StartRegionCaptureWithPrimedFrame(const Napi::CallbackInfo &info) { + Napi::Env env = info.Env(); + + if (!LoadSwiftLibrary(env)) { + return env.Undefined(); + } + + // 重入保护(对齐 Windows g_isCapturing):会话进行中再次 start 直接抛错 + if (g_screenshotInProgress) { + Napi::Error::New(env, "Screenshot already in progress") + .ThrowAsJavaScriptException(); + return env.Undefined(); + } + + bool autoConfirm = true; + int lcInterval = 250; + bool hasCallback = false; + Napi::Function callback; + for (size_t i = 0; i < info.Length(); i++) { + if (info[i].IsFunction()) { + callback = info[i].As(); + hasCallback = true; + } else if (info[i].IsObject()) { + Napi::Object opts = info[i].As(); + if (opts.Has("autoConfirm")) { + Napi::Value v = opts.Get("autoConfirm"); + if (v.IsBoolean()) { + autoConfirm = v.As().Value(); + } + } + if (opts.Has("longCapture")) { + Napi::Value v = opts.Get("longCapture"); + if (v.IsObject()) { + Napi::Object lc = v.As(); + if (lc.Has("interval")) { + Napi::Value t = lc.Get("interval"); + if (t.IsNumber()) { + int iv = t.As().Int32Value(); + if (iv >= 50 && iv <= 2000) lcInterval = iv; + } + } + } + } + } + } + + if (!hasCallback) { + Napi::TypeError::New(env, "Callback must be a function") + .ThrowAsJavaScriptException(); + return env.Undefined(); + } + + // 创建截图 TSFN(queue 0/1,对齐 Windows 会话模型;会话出口统一释放) + napi_value resource_name; + napi_create_string_utf8(env, "ScreenshotCallback", NAPI_AUTO_LENGTH, + &resource_name); + napi_status status = napi_create_threadsafe_function( + env, callback, nullptr, resource_name, 0, 1, nullptr, nullptr, nullptr, + CallScreenshotJs, &screenshotTsfn); + if (status != napi_ok) { + Napi::Error::New(env, "Failed to create threadsafe function") + .ThrowAsJavaScriptException(); + return env.Undefined(); + } + + // 组装 options JSON 传给 Swift(钳制已在上方完成,Swift 侧二次校验兜底) + std::string optionsJson = std::string("{\"autoConfirm\":") + + (autoConfirm ? "true" : "false") + + ",\"longCapture\":{\"interval\":" + + std::to_string(lcInterval) + + "}}"; + + g_screenshotInProgress = true; + int accepted = startRegionCaptureFunc(optionsJson.c_str(), OnScreenshotResult); + if (accepted != 1) { + // Swift 拒绝受理(重入兜底/参数非法):回滚会话状态,保证下次 start 可用; + // 此路径 Swift 不会回调,TSFN 必须就地释放防泄漏 + ReleaseScreenshotTsfn(); + g_screenshotInProgress = false; + } + + return env.Undefined(); +} + +// 中止进行中的长截图滚动捕获(对齐 Windows AbortLongCapture 导出)。 +// 仅设置锁内中止标记:无长截图会话时为安全 no-op。 +Napi::Value AbortLongCapture(const Napi::CallbackInfo &info) { + Napi::Env env = info.Env(); + + if (!LoadSwiftLibrary(env)) { + return env.Undefined(); + } + + if (abortLongCaptureFunc != nullptr) { + abortLongCaptureFunc(); + } + + return env.Undefined(); +} + // 模块初始化 Napi::Object Init(Napi::Env env, Napi::Object exports) { exports.Set("startMonitor", Napi::Function::New(env, StartMonitor)); @@ -1325,6 +1560,10 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) { exports.Set("getAllExplorerWindows", Napi::Function::New(env, GetAllExplorerWindows)); exports.Set("setAddressBar", Napi::Function::New(env, SetAddressBar)); exports.Set("getSelectedContent", Napi::Function::New(env, GetSelectedContent)); + exports.Set("primeScreenshotFrame", Napi::Function::New(env, PrimeScreenshotFrame)); + exports.Set("startRegionCaptureWithPrimedFrame", + Napi::Function::New(env, StartRegionCaptureWithPrimedFrame)); + exports.Set("abortLongCapture", Napi::Function::New(env, AbortLongCapture)); return exports; } diff --git a/src/binding_windows.cpp b/src/binding_windows.cpp index 724d2bc..18d19ac 100644 --- a/src/binding_windows.cpp +++ b/src/binding_windows.cpp @@ -40,7 +40,7 @@ #pragma comment(lib, "dwmapi.lib") #pragma comment(lib, "uiautomationcore.lib") -#include "screenshot/screenshot_windows.h" +#include "screenshot/windows/screenshot_windows.h" // DWMWA_CLOAKED 在较新的 Windows SDK 中定义,为了兼容性手动定义 #ifndef DWMWA_CLOAKED @@ -3972,7 +3972,7 @@ static std::unique_ptr CreateBitmapFromIcon( return bitmap; } -// GetPngEncoderClsid 已迁移至 src/screenshot/output_windows.cpp(CR-019), +// GetPngEncoderClsid 已迁移至 src/screenshot/output_windows.cpp, // 此处经 screenshot_windows.h 声明继续复用(含进程内 CLSID 缓存)。 // 将 HICON 转换为 PNG 字节数组 diff --git a/src/screenshot/lc_match_core.cpp b/src/screenshot/algo/lc_match_core.cpp similarity index 99% rename from src/screenshot/lc_match_core.cpp rename to src/screenshot/algo/lc_match_core.cpp index 72a569c..65df34c 100644 --- a/src/screenshot/lc_match_core.cpp +++ b/src/screenshot/algo/lc_match_core.cpp @@ -1,8 +1,12 @@ // 长截图子系统:纯匹配算法(帧间位移搜索、识别、富验证)。 -// CR-021 拆分自 long_capture_windows.cpp 的「帧间全局位移搜索 / 识别阶段」段。 +// 拆分自 long_capture_windows.cpp 的「帧间全局位移搜索 / 识别阶段」段。 // 本文件为纯函数:输入 LongMatchData 输出 LongMatchOutcome,不修改任何拼接状态, // 天然可单测。识别相关调参常量在此集中定义(extern,供 lc_stitch_state 等块共享)。 -#include "internal.h" +// 平台兼容(算法逻辑零改动):本文件为纯算法,只依赖 long_capture_internal.h +// (跨平台唯一权威,双平台同一份定义)与 lc_platform.h(平台类型别名 / +// GetTickCount 等价物,见其头部的依赖剥离验证结论);internal.h(napi / GDI+ +// 依赖链)仅 Windows UI/IO 层使用,算法层不再触碰。 +#include "lc_platform.h" #include "long_capture_internal.h" // 可条件编译的调试日志(构建加 /DLC_DEBUG_LOG 启用,经 OutputDebugStringA 输出到调试器): @@ -1394,7 +1398,7 @@ LongMatchOutcome LongCaptureDetectMatch(const LongMatchData& prevM, return w.confidence > m.confidence ? w : m; } -// CR-018: 防常量漂移。LC_RETRY_DELAY_WEAK 按弱候选重试档分档,调用方以 +// 防常量漂移。LC_RETRY_DELAY_WEAK 按弱候选重试档分档,调用方以 // LC_WEAK_RETRY_ATTEMPTS 为循环上界索引 LC_RETRY_DELAY_WEAK[weakRetries++], // 档数不一致将越界读或漏掉档位。 static_assert(sizeof(LC_RETRY_DELAY_WEAK) / sizeof(LC_RETRY_DELAY_WEAK[0]) == LC_WEAK_RETRY_ATTEMPTS, diff --git a/src/screenshot/algo/lc_platform.h b/src/screenshot/algo/lc_platform.h new file mode 100644 index 0000000..b81d93e --- /dev/null +++ b/src/screenshot/algo/lc_platform.h @@ -0,0 +1,81 @@ +// 长截图算法层平台兼容头(依赖剥离验证产物)。 +// +// 用途:lc_match_core.cpp / lc_stitch_state.cpp(纯算法层)与 long_capture_internal.h +// 在非 Windows 平台(macOS,经 lc_bridge C ABI 链入 Swift dylib)编译时,替代 +// internal.h 的 windows.h / napi / GDI+ 依赖链。_WIN32 下本头等价于 #include +// (透传,原行为超集);Windows 构建路径不引用本头(两算法 .cpp 的 _WIN32 分支仍走 +// internal.h),Windows 侧行为零影响。 +// +// 依赖剥离验证结论(逐符号 grep + 人工核对,2026-08): +// · 类型别名:DWORD / BYTE(算法层常量与 LongCaptureContext.stableRefTick 使用) +// · 宏:WHEEL_DELTA = 120(滚轮先验换算,winuser.h 同名宏) +// · 真实平台 API:仅 GetTickCount()(lc_stitch_state.cpp 稳定性闸门时间戳,2 处调用) +// —— 唯一非类型依赖,语义 = 单调毫秒时钟(uint32 自然回绕),非 Windows 用 +// clock_gettime(CLOCK_MONOTONIC) 等价实现(见 LcPlatformTickMs),算法逻辑零改动 +// · 仅 LC_DEBUG_LOG 调试分支:OutputDebugStringA(默认不编译;非 Windows 退化为 stderr) +// · 声明专用类型:HWND / HDC / HBITMAP / RECT / LRESULT / CALLBACK / UINT / WPARAM / +// LPARAM —— 只出现在 long_capture_internal.h 的 IO/UI 函数声明中,算法层从不调用, +// 给出占位定义仅满足类型完整性,不参与任何算法行为 +// · CaptureContext(编辑态会话上下文,含 GDI+ 成员):仅以指针出现在 IO/UI 声明中, +// 非平台由使用方前向声明,无需完整定义 +#ifndef LC_PLATFORM_H +#define LC_PLATFORM_H + +#ifdef _WIN32 + +// Windows:透传原头(本头在 _WIN32 下必须保持原行为的严格超集) +#include + +#else + +#include +#include +#include + +// ---- Win32 基本整型别名(宽度与 Windows 侧逐一对齐:DWORD/UINT 为 32 位)---- +typedef uint8_t BYTE; +typedef uint16_t WORD; +typedef uint32_t DWORD; +typedef unsigned int UINT; +typedef int32_t LONG; +typedef int BOOL; + +// ---- GDI/USER 句柄与几何结构(仅满足 IO/UI 函数声明的类型完整性;算法层不解引用)---- +typedef void* HWND; +typedef void* HDC; +typedef void* HBITMAP; +typedef struct { int32_t left, top, right, bottom; } RECT; +typedef struct { int32_t x, y; } POINT; +typedef uint32_t COLORREF; + +// ---- 窗口过程签名类型(声明专用)---- +typedef intptr_t LONG_PTR; +typedef uintptr_t UINT_PTR; +typedef UINT_PTR WPARAM; +typedef LONG_PTR LPARAM; +typedef LONG_PTR LRESULT; +#define CALLBACK + +// ---- 滚轮增量单位(winuser.h 同名宏,值 120;滚轮先验换算用)---- +#ifndef WHEEL_DELTA +#define WHEEL_DELTA 120 +#endif + +// 非 Windows 平台的 GetTickCount 等价物:单调毫秒时钟(CLOCK_MONOTONIC,系统启动 +// 以来计数;uint32 毫秒自然回绕,与 Win32 语义一致)。唯一调用方为 +// lc_stitch_state.cpp 的稳定性闸门(LC_STABLE_REF_MAX_GAP = 600ms 量级的新鲜度判定), +// 任一单调毫秒时钟语义等价;macOS 的 CLOCK_MONOTONIC 不计入系统睡眠,对该用途无影响。 +inline uint32_t LcPlatformTickMs() { + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (uint32_t)((uint64_t)ts.tv_sec * 1000u + (uint64_t)ts.tv_nsec / 1000000u); +} +inline uint32_t GetTickCount() { return LcPlatformTickMs(); } + +// 调试日志兼容(仅 LC_DEBUG_LOG 构建被调用;Windows 上经 OutputDebugStringA 输出到 +// 调试器,非 Windows 退化为 stderr,供 macOS 侧用真实数据做阈值调优时启用)。 +inline void OutputDebugStringA(const char* s) { fputs(s, stderr); } + +#endif // _WIN32 + +#endif // LC_PLATFORM_H diff --git a/src/screenshot/lc_stitch_state.cpp b/src/screenshot/algo/lc_stitch_state.cpp similarity index 98% rename from src/screenshot/lc_stitch_state.cpp rename to src/screenshot/algo/lc_stitch_state.cpp index b2beb70..64e789e 100644 --- a/src/screenshot/lc_stitch_state.cpp +++ b/src/screenshot/algo/lc_stitch_state.cpp @@ -1,8 +1,12 @@ // 长截图子系统:可写累计状态层(提交 / 跟踪 / 历史 / Weak 候选 / 裁剪 / 稳定性 / 管线)。 -// CR-021 拆分自 long_capture_windows.cpp 的「Tentative 跟踪 / 提交阶段 / 单帧管线」段。 +// 拆分自 long_capture_windows.cpp 的「Tentative 跟踪 / 提交阶段 / 单帧管线」段。 // 本文件是唯一允许修改累计拼接状态(body/headRev/stitchH/lastFrame/lastMatch/offsetHistory) // 的位置;LongCaptureTryStitch 是单帧「识别→校验→提交」管线入口。 -#include "internal.h" +// 平台兼容(算法逻辑零改动):本文件为纯算法,只依赖 long_capture_internal.h +// (跨平台唯一权威,双平台同一份定义)与 lc_platform.h(平台类型别名 / +// WHEEL_DELTA / GetTickCount 等价物,见其头部的依赖剥离验证结论); +// internal.h(napi / GDI+ 依赖链)仅 Windows UI/IO 层使用,算法层不再触碰。 +#include "lc_platform.h" #include "long_capture_internal.h" // 可条件编译的调试日志(构建加 /DLC_DEBUG_LOG 启用,经 OutputDebugStringA 输出到调试器): diff --git a/src/screenshot/algo/long_capture_internal.h b/src/screenshot/algo/long_capture_internal.h new file mode 100644 index 0000000..eac0da9 --- /dev/null +++ b/src/screenshot/algo/long_capture_internal.h @@ -0,0 +1,647 @@ +// 长截图子系统跨平台内部头:纯数据类型(匹配数据 / 枚举 / 会话上下文)、子系统内共享 +// 常量与跨文件函数声明。使用方:算法层 lc_match_core / lc_stitch_state(两平台编译)、 +// macOS C ABI shim lc_bridge_mac、Windows IO/UI 层 lc_frame_io_windows / lc_panel_ui_windows / +// lc_toolbar_ui_windows / lc_session_windows;不对外暴露。 +// +// 本头自包含,双平台共用同一份定义(2026-08 收口:原 Windows 侧 internal.h「长截图」 +// 纯数据段与 macOS 侧 lc_algo_types.h 逐字镜像合并为这里的单一权威,镜像头与「同步 +// 契约」随之删除——此前两处描述的是同一个算法层状态对象,漏同步会导致 macOS 与 +// Windows 的算法层行为悄然分叉)。 +// · 平台类型经 lc_platform.h 统一提供:_WIN32 → windows.h 原生类型;非 Windows → +// 依赖剥离验证后的声明级占位(见该头头部的逐符号核对结论)。 +// · 纯算法层只依赖本头 + lc_platform.h,不触碰 internal.h(napi / GDI+ 依赖链)。 +// · LongCaptureContext 含 HDC/HBITMAP 等 GDI 成员,但它们是拼接管线的帧缓冲 DIB / +// 缩略图缓冲状态:算法层仅经指针读写其纯数据字段,真实 GDI 资源的创建/释放归 +// Windows IO/UI 层;非 Windows 编译路径下这些成员为占位类型,不参与算法行为。 +#pragma once + +#include "lc_platform.h" + +#include // std::min / std::max / std::sort / std::stable_sort / std::lower_bound +#include // LongCaptureContext 的控制信号原子字段 +#include // std::sqrt(LongCaptureEdgeCorrelation 等加权相关度) +#include // 定宽整型 / INT64_MIN / INT64_MAX(裁剪内容坐标哨兵) +#include // memcpy / memcmp(提交拼接 / 全同帧判定) +#include // LongCaptureContext.base64 +#include // std::move / std::swap +#include // 帧缓冲 / 匹配数据 / 历史环形队列 + +// CaptureContext(Windows 编辑态会话上下文)仅以指针出现在下方 IO/UI 函数声明中: +// 完整定义在 Windows 侧 internal.h → capture_context.h,前向声明即可。 +struct CaptureContext; + +// ==================== 长截图纯数据类型(跨平台唯一权威,原 internal.h「长截图」段迁入)==================== + +// 行细节量 = 行内相邻采样列的灰度跳变数——空白/均匀区细节为 0,其上的“匹配”不可信 +// (在任何位移都能“匹配”,只会污染评分),以行权重(细节量截断到 0~4)参与评分, +// 空白行天然不计分;整帧细节总量过低则无对齐依据。 +// +// 匹配用帧数据:量化灰度行(h × cols)+ 每行匹配权重。 + +// 多 ROI 独立验证的列带数量(左/中/右)。列带把采样列均分为三段, +// 每段独立产出 Top-N 候选后做跨 ROI 加权聚类,防单个区域误匹配决定拼接结果。 +static const int LC_ROI_BANDS = 3; + +// 多尺度垂直结构 profile 的列桶数:每行按采样列均分为若干桶取平均量化灰度, +// 再做 4/8 行滑动聚合,供候选位移的结构一致性辅助验证(LongCaptureProfileCorrelation)。 +static const int LC_PROFILE_BUCKETS = 8; + +struct LongMatchData { + std::vector gray; // 4bit 量化灰度,行距 cols + std::vector weight; // 每行匹配权重 0~4(= min(4, 行细节量)) + std::vector bandWeight[LC_ROI_BANDS]; // 每行各列 ROI(左/中/右)权重 0~4 + std::vector edge; // 每行一维垂直结构强度:相邻采样列量化灰度差绝对值之和 + //(Weak 档匹配的边缘结构特征,见 LongCaptureEdgeCorrelation) + std::vector profile4; // 4 行聚合垂直结构 profile:h 行 × LC_PROFILE_BUCKETS 列桶, + // 桶值 = 窗口内行桶均值的平均(多尺度结构一致性验证用) + std::vector profile8; // 8 行聚合 profile(同上,更粗尺度) + int cols = 0; + int bandStart[LC_ROI_BANDS] = {0, 0, 0}; // 各 ROI 起始采样列 + int bandCols[LC_ROI_BANDS] = {0, 0, 0}; // 各 ROI 采样列数 + int h = 0; + int detailSum = 0; // 全帧行细节量总和(整帧近乎均匀时对齐不可信) +}; + +// 匹配模式(双模式识别):按候选位移的重叠量分档,overlap 越小要求证据越强。 +enum class LongCaptureMatchMode { + Normal, // 常规匹配:overlap >= max(24, 15% 视口高),沿用现有阈值与多数派 ROI 聚合 + WeakOverlap // 弱重叠/大跳变匹配:overlap >= max(32, 5% 视口高),以显著更严格的 + // 交叉验证(3/3 ROI 全票 + 边缘结构相关 + 动态高置信度 + 延迟确认)换取可识别性 +}; + +// 匹配状态(三值结论):只有 SUCCESS 允许进入拼接提交。 +// LOW_CONFIDENCE / FAILED 一律按“本帧被拒绝”处理(重试/计失败), +// 绝不允许用兜底 offset、上一帧推导 offset 或整帧插入的方式“继续流程”。 +enum LongMatchStatus { + LC_MATCH_FAILED = 0, // 无可信对齐(无候选峰 / ROI 相互矛盾) + LC_MATCH_LOW_CONFIDENCE, // 有候选但置信度不足(宽松档/单 ROI/置信度低于阈值) + LC_MATCH_SUCCESS // 可信对齐(严格档 + 多 ROI 一致 + 置信度达标) +}; + +// 识别失败的细分类别(可观测性增强:失败不再统一记为 FAILED,归类见 DetectPass / +// TryStitch 各拒绝点)。只用于失败归因、调试日志与重试节奏选择(瞬态 vs 结构性), +// 不参与任何验收阈值判定;成功路径一律保持 None。 +enum class LCFailReason { + None = 0, + NoCandidate, // NO_CANDIDATE:全部 ROI 无候选 / 整帧无对齐依据 + CandidateWeak, // CANDIDATE_WEAK:有候选但整体匹配或综合置信度不足 + PeakAmbiguous, // PEAK_AMBIGUOUS:峰值分离度不足(周期性/重复内容歧义) + GlobalMismatch, // GLOBAL_MISMATCH:全宽富验证不可评(重叠区近乎全空白) + SeamMismatch, // SEAM_MISMATCH:接缝窗证据不足 + SpatialMismatch, // SPATIAL_MISMATCH:top/middle/bottom 空间一致性崩塌 + ContinuityMismatch, // CONTINUITY_MISMATCH:匹配分布碎片化(连续性不足) + ProfileMismatch, // PROFILE_MISMATCH:多尺度垂直结构不一致 + RoiInconsistent, // ROI_INCONSISTENT:跨 ROI 候选冲突 / 聚类支持不足 + OffsetImplausible, // OFFSET_IMPLAUSIBLE:offset 被历史合理性校验拒绝 + DirectionConflict, // DIRECTION_CONFLICT:最佳候选与最近滚动方向相反 + FrameUnstable // FRAME_UNSTABLE:稳定性检测未过(本帧未进入正式匹配) +}; + +// 识别阶段输出(纯检测结果):未经 offset 合理性校验前不得用于提交。 +struct LongMatchOutcome { + LongMatchStatus status = LC_MATCH_FAILED; + LongCaptureMatchMode mode = LongCaptureMatchMode::Normal; // 本次候选的匹配档位 + int offset = 0; // 候选位移 d(>0 向下滚,<0 向上滚;|d|≤1 视为未滚动) + int overlap = 0; // 候选位移对应的重叠行数(= h − |d|) + float overall = 0.0f; // 全宽重叠区加权匹配率 + float seam = 0.0f; // 接缝窗加权匹配率 + float top = 0.0f; // 重叠区上 1/3 加权匹配率(该段无有效行时记 0) + float middle = 0.0f; // 重叠区中 1/3 加权匹配率 + float bottom = 0.0f; // 重叠区下 1/3 加权匹配率 + float spatial = 0.0f; // top/middle/bottom 空间一致性综合分(局部假匹配在此崩塌) + float continuity = 0.0f; // 匹配分布连续性(最长连续段占比 + 匹配率 − 断点罚) + float profileScore = 0.0f; // 4/8 行聚合 profile 多尺度垂直结构一致度 + float edgeCorrelation = 0.0f; // 重叠区行边缘结构强度的归一化相关度(Weak 档强证据) + float peakGap = 0.0f; // 与次优候选的综合分差(归一化;1 = 无竞争峰) + float roiWeighted = 0.0f; // 跨 ROI 加权证据(按 ROI 信息量加权融合的支持度) + float confidence = 0.0f; // 综合置信度(全宽匹配 + 空间/连续性 + 多尺度结构 + + // ROI 加权证据 + 峰值分离度的加权组合,见 DetectPass) + LCFailReason reason = LCFailReason::None; // 拒绝原因分类(成功时保持 None;仅归因/日志/重试节奏用) + float textureRatio = 0.0f; // 重叠区有效纹理行占比(证据量计价,进入综合置信度) + int bandOffsets[LC_ROI_BANDS] = {0, 0, 0}; // 各 ROI 独立求得的位移 + bool bandValid[LC_ROI_BANDS] = {false, false, false}; // 该 ROI 是否产出可信候选 + int validBandCount = 0; // 产出候选的 ROI 数 + int agreeCount = 0; // 最终候选所在聚类的支持 ROI 数(加权融合后) +}; + +// 单帧采样结论(识别→校验→提交管线的对外语义)。 +enum class LCSampleOutcome { + Stitched, // 已安全提交拼接且新增了拼接行(唯一扩展累计拼接内容的结局) + Repositioned,// 匹配成功但新视口完全落在已捕获内容范围内(反向回滚未越过捕获边界): + // 只推进当前视口基准(lastFrame/committedContentTop)并移动小地图当前 + // 区域标注,不新增行、不计帧数——已捕获内容的重复帧绝不再次拼接 + NoChange, // 匹配成功但内容未滚动(d=0);与“匹配失败”“滚动到底”均独立 + WeakPending, // Weak 候选首次成立:只登记待复核候选(pendingMatch),不提交、不改任何累计状态 + WeakRejected,// Weak 候选被复核否决(第二次候选不一致/置信度跌破门槛/offset 异常): + // 与“完全无候选”的硬失败不同,不计入普通失败计数,走独立重试预算 + Unstable, // 稳定性检测未过(页面仍处滚动/重绘/懒加载过渡):本帧不进入正式匹配, + // 不提交、不修改任何累计状态(含跟踪/历史/Weak 候选),由采样主循环 + // 短延迟后重新采样,等待页面稳定 + Failed // 硬失败(无候选/验证崩塌/Normal offset 异常);累计状态保持原样 +}; + +// Weak Match 的“延迟确认提交”候选(防污染核心机制):首次弱重叠可信候选只登记、 +// 不提交;下一次稳定采样必须独立复现一致候选(|Δoffset| ≤ LC_WEAK_CONFIRM_OFFSET_TOL +// 且置信度不跌破动态门槛)才允许 Commit。候选被否决/放弃时只清除本结构, +// 绝不触碰累计拼接状态(body/headRev/lastFrame/lastMatch/offsetHistory)。 +struct LongCapturePendingMatch { + bool valid = false; + int offset = 0; + float confidence = 0.0f; + LongCaptureMatchMode mode = LongCaptureMatchMode::Normal; +}; + +// 最近帧历史条目(多跳匹配恢复的基准池)。内容坐标 = 以首帧视口顶为原点、不随头部 +// 前插平移的滚动空间坐标(拼接图内位置 = contentY + headRows)。 +// 只保存匹配所需数据(量化灰度/行权重/ROI 权重/profile 等,约 1MB 级/帧), +// 不保存完整位图;环形容量见 LC_HISTORY_FRAMES(lc_match_core.cpp)。 +// contentY 语义:已提交帧精确(可作跨帧提交链锚点);未提交帧为 tentative 估计。 +struct LongCaptureFrameHistory { + int frameId = -1; // 采样序号(识别 lastFrame 直连条目 / 日志关联) + LongMatchData match; // 该帧匹配数据(LongCaptureBuildMatchData 全量产物) + int64_t contentY = 0; // 视口顶内容坐标(已提交=精确;未提交=估计) + bool committed = false; // 已提交帧:位置精确、可作提交链锚点 + bool validForMatching = true; // 近乎空白的帧不可作多跳匹配基准 +}; + +// 长截图工具栏二级菜单类型:方向与裁剪均为图标 popover(同一套展开/绘制/命中机制, +// lc_toolbar_ui_windows.cpp),popover 悬停各 cell 有 title 式 tooltip +enum LCMenuKind { + LCM_None = 0, // 无展开菜单 + LCM_Direction, // 方向 popover:纵向/横向图标 cell,悬停/点击方向按钮展开(已拼接多帧后锁定) + LCM_Crop // 裁剪 popover:悬停/点击裁剪按钮展开的图标浮层(丢弃上方/下方内容,随方向切换左右变体;已裁剪时含重置) +}; + +struct LongCaptureContext { + // 选项(由 start() 的 options.longCapture 注入,会话开始时拷贝) + int interval = 250; // 滚轮停止后等待内容稳定的毫秒数(采样防抖) + + // 虚拟屏幕(逻辑坐标)与 DPI + int vx = 0, vy = 0, vw = 0, vh = 0; + double dpiScale = 1.0; // 单一 scale 模型的已知限制见 CaptureContext.dpiScale 注释 + + // 选区(逻辑虚拟屏幕坐标,已规范化) + RECT selection = {}; + + // 采样裁剪矩形(逻辑虚拟屏幕坐标):选区每边内缩 LC_CROP_INSET_LOGI, + // 避开选区框描边与边缘抗锯齿像素污染拼接结果;滚轮过滤与结果 rect 均以此为准。 + RECT cropRect = {}; + + // 选区物理像素裁剪参数(相对虚拟屏幕原点的物理偏移 + 尺寸)。 + // 纵向模式 physW/physH 即帧尺寸;横向模式(horizontal=true)帧缓冲为屏幕采样的 + // 转置(physW=capH、physH=capW),下游匹配/拼接/缩略图管线与纵向完全同构。 + int physX = 0, physY = 0, physW = 0, physH = 0; + // 屏幕采样 DIB 尺寸(未转置的物理宽高;方向切换只交换 physW/physH,DIB 不变) + int capW = 0, capH = 0; + // 虚拟屏幕物理原点(屏幕 DC 坐标,DPI 感知下为物理像素),直接区域采样时作 BitBlt 源偏移 + int physOriginX = 0, physOriginY = 0; + // 专用 DIB 段:每帧采样的可复用目标(避免反复新建位图 + GetDIBits 回读) + HDC dibDC = NULL; + HBITMAP dibBmp = NULL; + void* dibBits = nullptr; + int dibW = 0, dibH = 0; + + // 预览面板放置在选区上方(空间兜底时的退化形态):向下生长受选区顶边约束, + // 避免面板长大后重新覆盖选区导致自身入画 + bool panelAbove = false; + + // 拼接结果:物理像素 BGRA,宽度恒为 physW;高度 stitchH 增长。 + // 分两段存储以让拼接保持 O(新增行):向上滚新增行存 headRev(倒序,reverse 后为显示顺序), + // 向下滚新增行存 body(正序);实际图像 = reverse(headRev) + body。 + std::vector headRev; + std::vector body; + int headRows = 0, bodyRows = 0; + int stitchH = 0; + // 最近一次采样的视口帧(相邻帧重叠检测基准,缓冲随拼接迭代复用) + std::vector lastFrame; + // lastFrame 的模糊对齐数据(两帧采样之间 lastFrame 不变,重叠检测直接复用, + // 只在拼接成功后随 lastFrame 旋转更新) + LongMatchData lastMatch; + + // 面板两级缩略图:先按固定列宽(面板预览宽)增量缩列,绘制时再合并缩行。 + // 分段与拼接缓冲同构(thumbHeadRev 倒序 / thumbBody 正序),thumbMerged 为绘制用临时合并缓冲。 + std::vector thumbHeadRev; + std::vector thumbBody; + std::vector thumbMerged; + int thumbW = 0; // 缩略图列宽(≤ physW) + int thumbHeadH = 0; // 头部段行数(合并后显示于顶部) + int thumbH = 0; // 缩略图总行数 + bool thumbDirty = false; + + // 滚轮观察状态(面板 WM_INPUT 经本线程消息泵分发,与主循环同线程,无需原子) + bool wheelPending = false; + DWORD lastWheelTick = 0; + DWORD lastSampleTick = 0; // 最近一次采样轮触发时刻(滚动中主动采样节拍用;首帧后初始化) + int lastDir = 0; // +1 最近一次向下滚(追加底部);-1 向上滚(前插头部) + + // —— 拼接失败恢复与 offset 合理性校验状态 —— + // 这些字段只在「成功提交拼接」后更新;识别失败/被拒绝的帧绝不写入, + // 保证单帧误识别无法通过污染匹配基准或位移历史把错误扩散到后续帧。 + std::vector offsetHistory; // 最近若干次成功拼接的 |d|(px,长度上限见 LC_OFFSET_HISTORY_LEN) + int noChangeCount = 0; // 连续「内容未变化」采样数(仅匹配成功且 d=0 时递增;与失败完全独立) + bool reachedBottom = false; // 滚动到底(由连续多次内容未变化确认;匹配失败绝不置位) + + // —— Weak(低重叠大跳变)延迟确认与独立重试状态 —— + // pendingMatch 只在本结构内暂存候选,任何未确认路径都不修改累计拼接状态; + // weakTries 计连续未决的 Weak 采样轮数,达 LC_WEAK_MAX_TRIES 即放弃当前候选链重新观察。 + LongCapturePendingMatch pendingMatch; // 待复核的 Weak 候选(有效时驱动无滚轮的继续采样) + int weakTries = 0; // 连续未决的 Weak 候选采样轮数 + int sampleIndex = 0; // 采样序号(LC_DEBUG_LOG 调试日志用) + + // —— Tentative 视觉跟踪状态(与正式拼接状态完全解耦的"预计当前位置"层)—— + // 正式拼接位置(committed)= committedContentTop,仅 LongCaptureCommitStitch 推进; + // 预计当前位置(tentative)= tentativeContentTop,由视觉验证(多跳匹配/零位移对齐/ + // 全同帧)直接设定或轻量预测推进。FAILED / LOW_CONFIDENCE 帧只允许触碰本组字段, + // 绝不修改 body/headRev/stitchH、lastFrame/lastMatch、offsetHistory 与拼接缓冲。 + // 防漂移约束:相对 committed 漂移上限(2×视口高)+ 连续无视觉依据冻结 + //(见 LongCaptureTrackingSetVisual / LongCaptureTrackingAdvancePredicted)。 + int64_t committedContentTop = 0; // lastFrame 视口顶(内容坐标;CommitStitch 内 += d) + int64_t tentativeContentTop = 0; // 预计当前视口顶(内容坐标;SUCCESS 后与 committed 对齐) + bool tentativeValid = false; // 是否已建立可信跟踪(小地图据此显示虚线预计框) + float tentativeConfidence = 0.0f; // tentative 置信度(视觉验证来源高,预测链逐帧衰减) + int trackUnreliableStreak = 0; // 连续无视觉依据采样数(达阈值冻结预测推进) + int trackingRevision = 0; // 跟踪状态变更计数(RunLongCapture 据此刷新小地图) + int lastCommittedFrameId = 0; // 最新已提交帧序号(多跳回溯时跳过 hop1 直连基准) + std::vector frameHistory; // 最近帧环形历史(容量 LC_HISTORY_FRAMES) + std::vector weakCandidateOffsets; // 最近弱重叠候选位移(Weak 时间一致性样本) + + // —— 帧稳定性检测(进入正式 DetectMatch 前的准入闸门,纯诊断层)—— + // stableRef* = 上一次抓帧的 4bit 灰度稀疏采样,供与本次抓帧对比判断页面是否仍在 + // 快速变化(滚动动画/重绘/懒加载过渡)。每次抓帧后滚动更新;只在稳定性闸门启用 + // 且参考帧新鲜(间隔 ≤ LC_STABLE_REF_MAX_GAP)时参与判定。绝不参与 offset 评分、 + // 不触碰任何累计拼接状态(含跟踪/历史/Weak 候选)。 + std::vector stableRefGray; + int stableRefCols = 0; + int stableRefH = 0; + DWORD stableRefTick = 0; + bool stableRefValid = false; + + // —— 失败可观测性:最近一次拒绝的原因分类与最佳候选证据快照 —— + // 仅调试日志与采样主循环的重试节奏选择(瞬态 vs 结构性)使用,不参与任何判定。 + LCFailReason lastFailReason = LCFailReason::None; + LongMatchOutcome lastReject; + + // —— 滚轮 delta 软先验(仅用于候选排序加分,绝不作为期望区间硬约束) —— + // wheelAccumDelta 在面板 WM_INPUT 累计,成功提交时折算 px/notch 并 EMA 平滑; + // 失败采样绝不更新估计。Windows/浏览器/平滑滚动/触控板都使位移与 notch 非线性相关, + // 因此先验只轻微影响同档候选的排序先后(LC_WHEEL_PRIOR_BONUS),不改变任何阈值。 + int wheelAccumDelta = 0; // 自上次成功提交以来累计的滚轮增量(带符号,WHEEL_DELTA=120) + float pixelsPerWheelNotch = 0.0f; // 「成功像素位移 ↔ 滚轮 notch」在线估计(0 = 尚无估计) + + // 控制信号(跨线程:abortLongCapture 由 JS 线程设置) + std::atomic abortFlag{false}; + std::atomic finishFlag{false}; // 用户点「完成并复制」 + std::atomic frameCount{0}; + + // —— 选区底部工具栏(宽高标签 / 方向 / 自动滚动 / 裁剪 / 保存 / 取消 / 完成并复制)—— + bool horizontal = false; // 长截图方向:false=纵向(默认);true=横向(帧缓冲转置复用纵向管线) + bool autoScroll = false; // 自动滚动(默认关闭):开启时光标一次性移到选区中心,此后定时器只注入滚轮 + //(SendInput 按光标下方窗口路由;单拍不动鼠标,见 LongCaptureAutoScrollTick) + std::atomic saveFlag{false}; // 用户点「保存到本地」:主循环内弹保存对话框并直接落盘 + // —— 裁剪状态(全部用「内容坐标」表达:拼接图行 = 内容坐标 + headRows)—— + // 内容坐标不随头部前插/主体追加平移,裁剪线因此永远锚定在页面的同一位置, + // 不会因继续滚动导致窗口错位(旧的绝对拼接行号在前插后会整体漂移)。 + // 裁剪动作只收紧输出行窗口并登记「待剔除区间」,绝不立即修改拼接缓冲与匹配基准; + // 待剔除区间在第一次朝该方向的成功提交时物理删除(见 CommitStitch 入口的延迟剔除), + // 删除后该侧边界重新开放——继续滚动的新增内容直接续接在裁剪线之后继续拼图。 + int64_t cropTopY = INT64_MIN; // 输出窗口上界(内容坐标);INT64_MIN = 该侧开放(未裁剪) + int64_t cropBottomY = INT64_MAX; // 输出窗口下界(内容坐标);INT64_MAX = 该侧开放(未裁剪) + bool cropPendTop = false; // 待剔除「上方已捕获内容」(丢弃上方登记):下一次 d<0 提交时删除 + int64_t cropPendTopLo = 0, cropPendTopHi = 0; // 待删内容区间 [lo, hi) + bool cropPendBottom = false; // 待剔除「下方已捕获内容」(丢弃下方登记):下一次 d>0 提交时删除 + int64_t cropPendBottomLo = 0, cropPendBottomHi = 0; + bool cropped = false; // 是否存在任何生效的裁剪约束(重置菜单项/图标徽标显示用; + // 含尚未触发的待剔除区间,触发或重置后自动回收) + int tbHover = -1; // 工具栏 hover 项(LongToolbarItem,-1=无) + int tbPressItem = -1; // 工具栏按下项(WM_LBUTTONDOWN 命中,-1=无):UP 必须命中与 + // DOWN 相同的目标才触发点击——编辑工具栏「长截图」按钮在按下 + // 瞬间创建本工具栏,残留的松开事件绝不能误触恰好同位的按钮 + int tbPressMenuRow = -1; // 二级菜单 popover 按下的 cell(-1=无;语义同 tbPressItem) + bool tbDragging = false; // 正在拖动工具栏窗口(按住最左 6 点把手,鼠标捕获中) + int tbDragGrabDX = 0, tbDragGrabDY = 0; // 按下点相对工具栏窗口左上角的偏移(物理像素) + LCMenuKind menuKind = LCM_None; // 展开中的二级菜单类型(方向/裁剪均为图标 popover) + int menuHover = -1; // 二级菜单 popover hover 的图标 cell(-1=无) + bool menuBelow = false; // 二级菜单绘制在工具栏下方(朝避让选区的一侧展开,见 LongCaptureSetMenu) + LCMenuKind popHoverDisarm = LCM_None; // 「悬停展开」被解除武装的菜单锚点(LCM_None=全部武装): + // 点击收起其 popover 后置为该菜单,光标移出锚点前不再 + // 因悬停重开,防止点击收起与悬停展开互相打架(方向/裁剪通用) + std::vector thumbDisplay; // 横向模式显示用回转缩略图(纵向直接用 thumbMerged) + int thumbDisplayW = 0, thumbDisplayH = 0; + bool thumbDisplayDirty = false; + int autoFailStreak = 0; // 自动滚动中连续硬失败采样轮数(达上限自动停止,防丢内容) + + // 输出结果(物理像素) + int outWidth = 0, outHeight = 0; + std::string base64; + bool success = false; +}; + +// 采样裁剪相对选区每边内缩(逻辑像素):避开选区框描边与边缘抗锯齿,防止污染拼接内容。 + +static const int LC_CROP_INSET_LOGI = 2; + +// ==================== 长截图子系统内共享类型(原 .cpp 文件内 static struct,跨块共享者)==================== + +// 轻量位移先验(滚轮累计增量 + 在线 px/notch 估计,仅用于候选排序加分与 tentative 预测幅度)。 +struct LongCaptureOffsetPrior { + bool valid = false; + int expectedAbsOffset = 0; // 期望 |d|(px);仅用于排序加分与 tentative 预测幅度 +}; + +// Weak 时间一致性上下文(短时序列共识,只给弱档置信度加分,不改任何验收门槛)。 +struct LCWeakTemporal { + bool active = false; + int refOffset = 0; // 最近弱候选簇中位数(带符号) + float bonus = 0.0f; // 共识加分(≤ LC_WEAK_TEMPORAL_BONUS_MAX) +}; + +// 全宽重叠区富验证证据:overall/seam/三段分布/连续段/profile/edge/纹理占比/动态屏蔽行数。 +struct LCOverlapEvidence { + bool valid = false; + float overall = 0.0f; // 全区加权匹配率 + float seam = 0.0f; // 接缝窗加权匹配率 + float part[3] = {0.0f, 0.0f, 0.0f}; // top/middle/bottom 匹配率(-1 = 该段无有效行) + float spatial = 0.0f; // 空间一致性:多段有证据取「最差段与均值折中」,单段打折 + float continuity = 0.0f; // 连续性 = 0.6×最长连续段 + 0.4×匹配率 − 断点罚 + float longestRunRatio = 0.0f; // 最长连续匹配段占有效行比例 + int gapCount = 0; // 匹配→失配跳变次数(匹配分布的碎片度) + float profileScore = 0.0f; // 4/8 行聚合 profile 相关度均值 + float edgeScore = 0.0f; // 行边缘结构强度相关度 + float textureRatio = 0.0f; // 重叠区有效纹理行占比(未屏蔽有效行 / 重叠行数): + // 大面积纯色重叠的证据量计价,进入综合置信度 + int dynamicMaskedRows = 0; // 被识别为动态变化区而从统计中剔除的行数 + //(受单段行数与总量占比双重硬上限,见常量块) +}; + +// 单 ROI 候选(Top-N 证据之一):只携带该列区域在此位移处的整段匹配率。 +struct LCBandCandidate { + int d = 0; // 候选位移(含符号) + float overall = 0.0f; // 该 ROI 限定列区间的整段加权匹配率 + bool strict = false; // 是否达到严格档验收(否则为宽松档) +}; + +// 多跳匹配恢复结果:matched 为真时 offset 是基准帧 H → 当前帧的已验证位移。 +struct LCMultihopResult { + bool matched = false; + bool hopCommitted = false; // 基准为已提交帧(位置精确,可推导提交位移) + LongCaptureMatchMode mode = LongCaptureMatchMode::Normal; + int offset = 0; // 已验证的 H→curr 位移(带符号) + float confidence = 0.0f; // 该匹配的综合置信度 + int64_t hopContentY = 0; // 基准帧视口顶(内容坐标) + int hopFrameId = -1; // 基准帧采样序号(日志关联) +}; + +// 失败帧的 TrackingEstimate(只服务 tentative 跟踪与下一帧先验,绝不进入提交)。 +struct LongCaptureTrackingEstimate { + bool valid = false; + int direction = 0; // +1 向下滚 / -1 向上滚 + double predictedOffset = 0.0; // 预计 |位移|(px) + double confidence = 0.0; // 低置信度(纯预测无视觉验证) +}; + +// ==================== 长截图子系统内共享常量(原 long_capture_windows.cpp 文件级 static)==================== +// 这些常量原本为单文件 static,拆分后需跨 lc_* 块共享,改为非 static 在此集中声明, +// 定义保留在对应块(算法调参 → lc_match_core.cpp)。 + +// 全位移搜索的采样列上限与单列容忍差(量化灰度容差行匹配)。 +extern const int LONG_MATCH_MAX_COLS; +extern const int LONG_MATCH_TOL; + +// 全位移扫描(探针粗筛→候选峰收集)参数。 +extern const int LC_SCAN_PROBES; +extern const int LC_SCAN_MIN_WEIGHT; +extern const int LC_PEAK_WIN; +extern const int LC_MAX_CANDIDATES; + +// 最小可信重叠(绝对下限 + 视口高比例)。 +extern const int LC_MIN_OVERLAP; +extern const float LC_MIN_OVERLAP_RATIO; + +// 跨 ROI 位移一致性容差。 +extern const int LC_ROI_OFFSET_TOLERANCE; + +// 综合置信度下限与 offset 跳变比率校验参数。 +extern const float LC_MIN_CONFIDENCE; +extern const float LC_OFFSET_JUMP_RATIO; +extern const int LC_OFFSET_HISTORY_LEN; +extern const int LC_OFFSET_HISTORY_MIN; + +// 采样尝试总数与重试间隔档。 +extern const int LC_SAMPLE_ATTEMPTS; +extern const int LC_RETRY_DELAY_NORMAL[]; // 长度 = LC_SAMPLE_ATTEMPTS - 1 +extern const int LC_RETRY_DELAY_WEAK[]; // 长度 = LC_WEAK_RETRY_ATTEMPTS + +// 帧稳定性检测参数。 +extern const DWORD LC_STABLE_REF_MAX_GAP; +extern const float LC_STABLE_CHANGED_ROW_FRAC; +extern const int LC_STABLE_MIN_WEIGHT; +extern const int LC_STABLE_MAX_WAITS; +extern const int LC_STABLE_RETRY_DELAY[]; // 长度 = LC_STABLE_MAX_WAITS + +// 瞬态快重采样参数。 +extern const int LC_QUICK_RESAMPLES; +extern const int LC_RESAMPLE_DELAY_QUICK[]; // 长度 = LC_QUICK_RESAMPLES + +// 滚动中主动采样最大间隔与到底确认采样数。 +extern const int LC_SCROLL_SAMPLE_MAX_GAP; +extern const int LC_BOTTOM_CONFIRM_SAMPLES; + +// Weak(低重叠大跳变)匹配参数。 +extern const int LC_WEAK_MIN_OVERLAP; +extern const float LC_WEAK_MIN_OVERLAP_RATIO; +extern const int LC_WEAK_ROI_TOLERANCE; +extern const float LC_WEAK_ACCEPT_OVERALL; +extern const float LC_WEAK_ACCEPT_SEAM; +extern const float LC_WEAK_ACCEPT_EDGE; +extern const float LC_WEAK_MIN_CONFIDENCE; +extern const float LC_WEAK_CONF_EXTRA_PENALTY; +extern const float LC_WEAK_OVERLAP_RATIO_LIMIT; +extern const int LC_WEAK_CONFIRM_OFFSET_TOL; +extern const int LC_WEAK_RETRY_ATTEMPTS; +extern const int LC_WEAK_MAX_TRIES; + +// 滚轮 delta 软先验加分。 +extern const float LC_WHEEL_PRIOR_BONUS; + +// Tentative 视觉跟踪 / 多跳恢复 / Weak 时间一致性参数。 +extern const int LC_HISTORY_FRAMES; +extern const int LC_HISTORY_HOPS; +extern const int LC_TRACK_FREEZE_FRAMES; +extern const int LC_TRACK_MIN_STEP; +extern const float LC_TRACK_PREDICT_CONFIDENCE; +extern const float LC_TRACK_CONFIDENCE_DECAY; +extern const int LC_WEAK_TEMPORAL_TOL; +extern const int LC_WEAK_TEMPORAL_MIN_SAMPLES; +extern const int LC_WEAK_TEMPORAL_MAX_HISTORY; +extern const float LC_WEAK_TEMPORAL_BONUS_MAX; + +// 接缝窗行数与全量验证有效权重下限。 +extern const int LC_SEAM_ROWS; +extern const int LC_VERIFY_MIN_WEIGHT; + +// 纹理置信度折扣参数。 +extern const float LC_TEXTURE_CONF_FLOOR; +extern const float LC_TEXTURE_CONF_SCALE; + +// 验收阈值(严格档 / 宽松档)。 +extern const float LC_ACCEPT_STRICT_OVERALL; +extern const float LC_ACCEPT_STRICT_SEAM; +extern const float LC_ACCEPT_LOOSE_OVERALL; +extern const float LC_ACCEPT_LOOSE_SEAM; + +// 方向罚与歧义裕度。 +extern const float LC_DIR_PENALTY; +extern const float LC_AMBIGUITY_MARGIN; + +// ROI 证据融合参数。 +extern const int LC_BAND_TOP_N; +extern const int LC_MAX_VERIFY_CANDIDATES; +extern const float LC_ROI_MIN_INFO_WEIGHT; +extern const int LC_BAND_MIN_COLS; +extern const float LC_PEAK_SEP_FULL; +extern const float LC_AMBIGUITY_CONF_PENALTY; +extern const float LC_CONTINUITY_GAP_MAX; + +// 动态变化区局部降权参数。 +extern const int LC_DYNAMIC_MIN_ROWS; +extern const int LC_DYNAMIC_MAX_ROWS; +extern const int LC_DYNAMIC_BRIDGE; +extern const float LC_DYNAMIC_MAX_FRACTION; + +// offset basin 合并半径。 +extern const int LC_BASIN_RADIUS; + +// Weak 档额外证据下限。 +extern const float LC_WEAK_ACCEPT_SPATIAL; +extern const float LC_WEAK_ACCEPT_CONTINUITY; +extern const float LC_WEAK_ACCEPT_PROFILE; +extern const float LC_WEAK_MIN_PEAK_SEP; + +// 跨文件的常量:原定义归属不同块但被另一块使用,故在此声明为 extern。 +// LC_TRACK_MIN_STEP 定义于 lc_stitch_state.cpp(跟踪逻辑用),panel UI 也读它判定 tentative 框显示。 +extern const int LC_TRACK_MIN_STEP; +// LC_AUTOSCROLL_STOP_FAILS 定义于 lc_toolbar_ui_windows.cpp(自动滚动参数),session 主循环读它决定自动停止。 +extern const int LC_AUTOSCROLL_STOP_FAILS; + +// ==================== 二级常量(原文件级 static,归各使用块)==================== + +// 蒙版样式(预乘 ARGB):整屏半透明灰,采样裁剪区整透明透出实况桌面。 +// 定义于 lc_panel_ui_windows.cpp(蒙版绘制唯一使用方)。 +extern const int LONG_MASK_GRAY; +extern const BYTE LONG_MASK_ALPHA; + +// 面板布局常量(逻辑像素):面板总宽与内边距被 toolbar(缩略图列宽)与 session(缩略图列宽) +// 共用,故在此声明为 extern;定义于 lc_panel_ui_windows.cpp。 +extern const int LC_PANEL_W; +extern const int LC_PANEL_PAD; + +// ==================== 长截图子系统内跨文件函数声明 ==================== + +// 失败分类名(LCFailReason → 日志字符串,仅可观测性用)。定义于 lc_match_core.cpp。 +const char* LcFailReasonName(LCFailReason r); + +// —— 纯算法(lc_match_core.cpp)—— +void LongCaptureBuildMatchData(const std::vector& frame, int w, int h, LongMatchData& m); +bool LongCaptureRowMatchesRange(const uint8_t* a, const uint8_t* b, int c0, int c1); +bool LongCaptureRowMatches(const uint8_t* a, const uint8_t* b, int cols); +LongMatchOutcome LongCaptureDetectMatch(const LongMatchData& prevM, const LongMatchData& currM, + int dir, const LongCaptureOffsetPrior& prior, + const LCWeakTemporal& wt, int logId); +LongMatchOutcome LongCaptureDetectPass(const LongMatchData& prevM, const LongMatchData& currM, + int dir, const LongCaptureOffsetPrior& prior, bool weak, + const LCWeakTemporal& wt, int logId); +bool LongCaptureVerifyCandidate(const LongMatchData& prevM, const LongMatchData& currM, + int d, LCOverlapEvidence& ev); +float LongCaptureWeakRequiredConfidence(int viewportH, int overlap); + +// —— 可写累计状态层(lc_stitch_state.cpp)—— +LongCaptureOffsetPrior LongCaptureBuildOffsetPrior(const LongCaptureContext* c); +LCWeakTemporal LongCaptureWeakTemporalContext(const LongCaptureContext* c); +void LongCapturePushWeakCandidate(LongCaptureContext* c, int offset); +bool LongCaptureOffsetPlausible(const LongCaptureContext* c, int d, + LongCaptureMatchMode mode = LongCaptureMatchMode::Normal); +// 以下两个函数实际定义在 lc_stitch_state.cpp 内为 static(仅本文件内部使用): +// MSVC 容忍「头文件非 static 声明 + static 定义」的非标准扩展(Windows 现状,声明 +// 保留不动);clang 按 IL 严格拒绝,非 Windows 编译路径(lc_bridge 复用链)不声明。 +#ifdef _MSC_VER +LCMultihopResult LongCaptureMultihopDetect(const LongCaptureContext* c, + const LongMatchData& currMatch, int dir, + const LongCaptureOffsetPrior& prior); +#endif +LongCaptureTrackingEstimate LongCaptureBuildTrackingEstimate(const LongCaptureContext* c, int dir, + const LongCaptureOffsetPrior& prior); +#ifdef _MSC_VER +int64_t LongCaptureTrackingDriftLimit(const LongCaptureContext* c); +#endif +void LongCaptureTrackingSetVisual(LongCaptureContext* c, int64_t contentY, float confidence); +void LongCaptureTrackingResetToCommitted(LongCaptureContext* c); +void LongCaptureTrackingAdvancePredicted(LongCaptureContext* c, int direction, double magnitude); +void LongCaptureHistoryPush(LongCaptureContext* c, int frameId, LongMatchData&& match, + int64_t contentY, bool committed); +void LongCaptureAfterCommit(LongCaptureContext* c, int frameId); +int LongCaptureCommitStitch(LongCaptureContext* c, std::vector& curr, + LongMatchData& currMatch, int d); +void LongCaptureUpdateWheelEstimate(LongCaptureContext* c, int d); +bool LongCaptureFrameUnstable(LongCaptureContext* c, const LongMatchData& curr); +void LongCaptureUpdateStabilityRef(LongCaptureContext* c, const LongMatchData& curr); +bool LongCaptureHasCropConstraint(const LongCaptureContext* c); +void LongCaptureEraseDisplayRows(LongCaptureContext* c, int64_t r0, int64_t r1); +void LongCaptureExecuteCropPurge(LongCaptureContext* c, bool below); +// 单帧「识别→offset 校验→(Weak 档)延迟确认→提交」管线(RunLongCapture 采样流程与单元测试共用入口): +// Normal 档识别 SUCCESS 且 offset 通过历史合理性校验即提交;Weak 档(低重叠大跳变)首次可信候选 +// 只登记 pendingMatch(WeakPending),下一次稳定采样独立复现一致候选才提交(Stitched)。 +// 任何拒绝(Failed/WeakPending/WeakRejected/Unstable)都不修改累计拼接状态。 +// allowStabilityGate:进入正式 DetectMatch 前启用轻量帧稳定性闸门(采样主循环传入 +// true;默认 false 保持单元测试/合成帧直连注入的旧行为完全不变)。闸门未过时返回 +// Unstable——本帧不匹配、不提交、不改任何状态,调用方短延迟后重新采样。 +LCSampleOutcome LongCaptureTryStitch(LongCaptureContext* c, std::vector& curr, int dir, + bool allowStabilityGate = false); + +// —— 抓帧 / DIB / 缩略图 / 位图构建 / 消息泵(lc_frame_io_windows.cpp)—— +void LongCaptureDownscaleRow(const uint32_t* src, uint32_t* dst, int srcW, int dstW); +void LongCaptureOutputRows(const LongCaptureContext* c, int& outTop, int& outBottom); +bool LongCaptureRegisterWheelObserver(HWND target); +void LongCaptureUnregisterWheelObserver(); +bool LongCaptureEnsureDib(LongCaptureContext* c, HDC screenDC); +bool LongCaptureCaptureFrameBuf(LongCaptureContext* c, std::vector& out); +void LongCaptureRebuildThumb(LongCaptureContext* c); +void LongCaptureRebuildThumbDisplay(LongCaptureContext* c); +HBITMAP LongCaptureBuildResultBitmap(LongCaptureContext* c); +void LongCapturePumpMessages(LongCaptureContext* c); +bool LongCaptureInitFirstFrame(LongCaptureContext* c, std::vector& frameBuf); +void LongCaptureInitBaseline(LongCaptureContext* c, std::vector& frame); +HBITMAP LongCaptureBuildFinalBitmap(LongCaptureContext* c); + +// —— 面板 / 蒙版 UI(lc_panel_ui_windows.cpp)—— +bool EnsureArgbSurface(HDC& dc, HBITMAP& bmp, void*& bits, int& w, int& h, int wantW, int wantH); +void FreeArgbSurface(HDC& dc, HBITMAP& bmp, void*& bits, int& w, int& h); +void LongCapturePanelRender(HWND panel, LongCaptureContext* c); +LRESULT CALLBACK LongCapturePanelWndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp); +void LongCapturePanelUpdate(LongCaptureContext* c); +HWND LongCaptureCreatePanel(CaptureContext* ctx, LongCaptureContext* c); +LRESULT CALLBACK LongCaptureMaskWndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp); +RECT CalcSampleCrop(const CaptureContext* ctx); +void EnterLongCaptureMask(const CaptureContext* ctx); + +// —— 工具栏 UI(lc_toolbar_ui_windows.cpp)—— +void LongCaptureToolbarRender(LongCaptureContext* c, int dstX, int dstY, int w, int h); +void LongCaptureToolbarRepaint(); +LRESULT CALLBACK LongCaptureToolbarWndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp); +HWND LongCaptureCreateToolbar(CaptureContext* ctx, LongCaptureContext* c); +void LongCaptureSetMenu(LongCaptureContext* c, LCMenuKind kind); +void LongCaptureResetSession(LongCaptureContext* c); +void LongCaptureSwitchDirection(LongCaptureContext* c); +void LongCaptureSetAutoScroll(LongCaptureContext* c, bool on); +void LongCaptureApplyCrop(LongCaptureContext* c, int row); +// 临时摘除/恢复长截图窗口组的置顶(保存对话框等系统弹窗需要真正置顶)。 +// 定义于 lc_toolbar_ui_windows.cpp,session 主循环弹保存对话框前后调用。 +void LongCaptureSetTopmost(bool topmost); + +// —— 会话主循环 / 生命周期(lc_session_windows.cpp)—— +void LongCaptureEmitFailure(); +void LongCaptureWaitMessages(LongCaptureContext* c, DWORD ms); +// 释放采样 DIB 段:把原 wndproc_windows.cpp 里手动释放 lc->dibDC/dibBmp 的跨界所有权 +// 收进此处,由 DestroyLongCaptureContext 统一释放。 +// 注:会话级窗口(panel/toolbar/mask)销毁仍由捕获线程清理段调用, +// 本函数只负责 DIB 资源的归口释放。 +void DestroyLongCaptureContext(LongCaptureContext* lc); diff --git a/src/screenshot/internal.h b/src/screenshot/internal.h deleted file mode 100644 index 70fc9d7..0000000 --- a/src/screenshot/internal.h +++ /dev/null @@ -1,1162 +0,0 @@ -// 截图模块共享内部头:类型、常量、全局变量与跨文件函数声明(由 screenshot_windows.cpp 拆分而来) -#pragma once - -#include -#include -#include -#include // For GET_X_LPARAM, GET_Y_LPARAM -#include -// 冷门头 // 不在此集中包含(本头类型声明均不依赖其符号), -// 仅由唯一使用方 .cpp 自行引入:imm.h → overlay_input_windows.cpp(IME 输入法)、 -// commdlg.h → output_windows.cpp(GetSaveFileNameW 保存对话框)、 -// shlobj.h → output_windows.cpp(SHGetKnownFolderPath 已知文件夹路径)。 -#include -#include -#include // For std::min, std::max -#include -#include -#include -#include // For std::sqrt, std::fabs -#include -#include -#include // For INT64_MIN / INT64_MAX(长截图裁剪内容坐标哨兵) -#include // For std::move(最近帧历史的匹配数据转移) - -// DWMWA_CLOAKED 在较新的 Windows SDK 中定义,为了兼容性手动定义 -#ifndef DWMWA_CLOAKED -#define DWMWA_CLOAKED 14 -#endif - -// GDI+ 需要 min/max -namespace Gdiplus { - using std::min; - using std::max; -} -#include - - -#include "screenshot_windows.h" - -static const auto SC_PRIMED_FRAME_TTL = std::chrono::seconds(2); - -struct PrimedScreenshotFrame { - HBITMAP bitmap = NULL; - int vx = 0; - int vy = 0; - int vw = 0; - int vh = 0; - double dpiScale = 1.0; // 单一 scale 模型的已知限制见 CaptureContext.dpiScale 注释(CR-023) - std::chrono::steady_clock::time_point capturedAt{}; - bool valid = false; -}; - -// 截图常量 - -// ==================== 区域截图功能(预截屏 + 双缓冲架构) ==================== - -static const int SC_PANEL_WIDTH = 140; - -static const int SC_PANEL_HEIGHT = 140; - -static const int SC_MAGNIFIER_HEIGHT = 74; - -static const int SC_PANEL_MARGIN = 15; - -static const int SC_PANEL_CORNER_RADIUS = 8; - -static const int SC_ZOOM_FACTOR = 4; - -// 取值 0~255,数值越大越暗(0 = 无遮罩,255 = 全黑) - -// 选区外遮罩:微信风格,选区内部保持清晰,外部覆盖半透明黑色 - -static const BYTE SC_MASK_ALPHA = 120; - -// 截图状态枚举 - -enum CaptureState { - CS_Idle, // 等待选择(hover 窗口/拖拽开始) - CS_Selecting, // 正在拖拽框选 - CS_Confirmed, // 已确认选区,可调整/拖动/打开工具栏 - CS_Resizing, // 正在拖拽手柄调整选区 - CS_Moving, // 正在整体拖动选区 - CS_Drawing, // 正在绘制标注(矩形/圆/箭头/画笔) - CS_TextEditing, // 正在输入文字 - CS_LongCapturing, // 长截图滚动捕获进行中(独立于编辑态,由 RunLongCapture 驱动) - CS_Done, - CS_Cancelled -}; - -// 选区调整手柄(8 个方向) - -enum ResizeHandle { - RH_None = -1, - RH_Left = 0, - RH_Right = 1, - RH_Top = 2, - RH_Bottom = 3, - RH_TopLeft = 4, - RH_TopRight = 5, - RH_BottomLeft = 6, - RH_BottomRight = 7, - RH_ArrowStart = 8, // 箭头起点端点手柄(仅箭头用,拖动改起点) - RH_ArrowEnd = 9, // 箭头终点端点手柄(仅箭头用,拖动改终点) - RH_CornerRadiusTL = 10, // 选区左上角内倒角手柄(拖动改选区圆角半径,不改变选区矩形) - RH_CornerRadiusTR = 11, // 选区右上角内倒角手柄 - RH_CornerRadiusBL = 12, // 选区左下角内倒角手柄 - RH_CornerRadiusBR = 13 // 选区右下角内倒角手柄 -}; - -// 工具栏最左「6 点拖拽把手」单元格的命中返回值:取负值与按钮索引区分, -// 既有调用方以 >=0 判定按钮,天然排除把手(把手按住 = 拖动工具栏,非工具按钮)。 -static const int SC_TB_GRIP = -2; - -// 工具栏按钮 - -enum ToolButton { - TB_Drag = 0, // 拖拽 - TB_Rect, // 矩形 - TB_Circle, // 圆形(含椭圆) - TB_Arrow, // 箭头 - TB_Brush, // 画笔 - TB_Mosaic, // 马赛克 - TB_Text, // 文字 - TB_Translate, // 翻译 - TB_LongCapture, // 长截图(滚动捕获入口,复用当前选区) - TB_Separator1, // 分隔线 - TB_Undo, // 撤销 - TB_Redo, // 重做 - TB_Separator2, // 分隔线 - TB_Save, // 保存到本地 - TB_Cancel, // 取消 - TB_Confirm, // 确定 - TB_Count -}; - -// ==================== 标注绘制(矩形/圆/箭头/画笔) ==================== - -// 所有标注统一用「绝对虚拟屏幕坐标」存储(与 ctx->mouseX/selection 同坐标系): -// - 用绝对坐标而非选区相对,保证选区缩放/移动时标注位置固定不动 -// - 实时渲染时:backDC 局部坐标 = 绝对坐标 + ox/oy,ox/oy = -virtualX/-virtualY -// - 合成进 PNG 时:finalDC 局部坐标 = 绝对坐标 + ox/oy,ox/oy = -rect.left/-rect.top - -enum AnnotationType { - AT_Rect, - AT_Circle, - AT_Arrow, - AT_Brush, - AT_Text, - AT_Mosaic // 马赛克(框选区域 或 鼠标涂抹) -}; - -struct Annotation { - AnnotationType type; - COLORREF color; - int thickness; // 逻辑像素(矢量=线宽;文字=字号) - // 绝对虚拟屏幕坐标(与 ctx->mouseX/selection 同坐标系)。 - // 用绝对坐标而非选区相对,保证选区缩放/移动时标注位置固定不动。 - int x1, y1, x2, y2; // Rect / Circle / Arrow 的起止(绝对坐标);AT_Text 的 x1/y1 为文字锚点; - // AT_Mosaic 框选模式的矩形起止(绝对坐标) - std::vector pts; // Brush 自由路径(绝对坐标);AT_Mosaic 涂抹模式的路径(绝对坐标) - std::wstring text; // AT_Text 的文字内容 - // ---- AT_Mosaic 专用 ---- - bool mosaicRect; // true=框选区域马赛克;false=鼠标涂抹马赛克 - int mosaicSize; // 马赛克块大小(逻辑像素) - int brushRadius; // 涂抹半径(逻辑像素,仅涂抹模式有效) - - // ---- 文字测量缓存(仅 AT_Text 有效)---- - // 缓存"相对锚点的字形偏移与尺寸"(与 GDI+ MeasureString 同源)。 - // 有效性条件 = (text, fontPx) 未变;锚点(x1,y1)变化不影响缓存值(外部加偏移即可), - // 故 TransformAnnotationByBox 的 AT_Text 分支(仅平移锚点)无需失效缓存。 - // textCacheValid=false 表示未计算或已失效,下次 MeasureTextAnnotation 会重算并回填。 - bool textCacheValid; - int textCacheFontPx; // 生成缓存时的 fontPx(= thickness),用于校验 - float textCacheOffX, textCacheOffY; // 字形左上角相对锚点的偏移 - float textCacheW, textCacheH; // 字形紧凑宽高 -}; - -// 粗细预设(逻辑像素,实际绘制粗细,渲染时乘 dpiScale) -// inline constexpr 数组:跨 TU 唯一实例(替代 static const 每 TU 一份的拷贝),值与原一致。 - -inline constexpr int SC_THICK_PRESETS[] = { 1, 2, 4 }; - -inline constexpr int SC_THICK_COUNT = sizeof(SC_THICK_PRESETS) / sizeof(SC_THICK_PRESETS[0]); - -static const int SC_DEFAULT_THICK_IDX = 1; // 默认中粗 - -// 子菜单圆点预览直径(逻辑像素,仅用于界面显示,与实际绘制粗细解耦) - -inline constexpr int SC_THICK_DOT_SIZES[] = { 5, 10, 16 }; - -inline constexpr int SC_THICK_DOT_COUNT = sizeof(SC_THICK_DOT_SIZES) / sizeof(SC_THICK_DOT_SIZES[0]); - -// 文字字号预设(逻辑像素),文字工具激活时子菜单第一组显示 - -inline constexpr int SC_FONT_SIZES[] = { 16, 24, 36 }; - -inline constexpr int SC_FONT_COUNT = sizeof(SC_FONT_SIZES) / sizeof(SC_FONT_SIZES[0]); - -// 粗细档数与字号档数必须一致:子菜单第一组共用单元格,绘制/命中按 isTextTool 在两套预设 -// 间二选一(overlay_ui/overlay_input 均以 SC_THICK_COUNT/SC_FONT_COUNT 为循环上界)。 -static_assert(SC_THICK_COUNT == SC_FONT_COUNT, - "SC_THICK_COUNT must match SC_FONT_COUNT: submenu 第一组共享单元格"); - -static const int SC_DEFAULT_FONT_IDX = 1; // 默认中号 - -static const wchar_t* SC_FONT_FACE = L"微软雅黑"; - -// 马赛克块大小预设(逻辑像素),马赛克工具子菜单显示 - -inline constexpr int SC_MOSAIC_SIZES[] = { 6, 10, 16 }; - -inline constexpr int SC_MOSAIC_COUNT = sizeof(SC_MOSAIC_SIZES) / sizeof(SC_MOSAIC_SIZES[0]); - -static const int SC_DEFAULT_MOSAIC_IDX = 1; // 默认中等块 - -// 这里单独定义便于扩展。半径越大涂抹范围越宽。 - -// 涂抹半径预设(逻辑像素),马赛克涂抹模式使用,与画笔粗细预设共用同一组子菜单第二组无效, - -inline constexpr int SC_MOSAIC_RADIUS[] = { 12, 22, 36 }; - -inline constexpr int SC_MOSAIC_RADIUS_COUNT = sizeof(SC_MOSAIC_RADIUS) / sizeof(SC_MOSAIC_RADIUS[0]); - -static const int SC_DEFAULT_MOSAIC_RADIUS_IDX = 1; // 默认中等半径 - -// 颜色预设 - -inline constexpr COLORREF SC_COLOR_PRESETS[] = { - RGB(0xE5, 0x39, 0x35), // 红 - RGB(0xFB, 0x8C, 0x00), // 橙 - RGB(0xFD, 0xD8, 0x35), // 黄 - RGB(0x43, 0xA0, 0x47), // 绿 - RGB(0x00, 0xAC, 0xC1), // 青 - RGB(0x1E, 0x88, 0xE5), // 蓝 - RGB(0xFF, 0xFF, 0xFF), // 白 - RGB(0x33, 0x33, 0x33), // 黑 -}; - -inline constexpr int SC_COLOR_COUNT = sizeof(SC_COLOR_PRESETS) / sizeof(SC_COLOR_PRESETS[0]); - -static const int SC_DEFAULT_COLOR_IDX = 0; // 默认红 - -// ==================== 主题色常量 ==================== -// 多个绘制文件共用的固定色值集中于此(此前以 RGB 字面量散落 overlay_ui / icons / -// long_capture 各处,易各自漂移);各值与抽常量前逐字节一致。 -// GDI 侧直接传 COLORREF;GDI+ 侧经下方 ScOpaqueColor 展开通道(透明度各异的 -// 使用点自行以 GetR/G/BValue 构造)。 - -static const COLORREF SC_THEME_ACCENT_BLUE = RGB(0x00, 0x88, 0xFF); // 强调蓝:选区/标注边框、resize 手柄、放大镜准星 -static const COLORREF SC_THEME_TOOLBAR_BLUE = RGB(0x3B, 0x8B, 0xF2); // 工具栏选中态图标蓝 #3B8BF2(配浅蓝高亮底) -static const COLORREF SC_THEME_ICON_DARK = RGB(0x33, 0x33, 0x33); // 子菜单图标默认深灰 #333333(与预设「黑」同值) -static const COLORREF SC_THEME_SEL_BG = RGB(225, 237, 253); // 选中态浅蓝高亮底(工具栏主题蓝叠白底 ~15% 预混合色) -static const COLORREF SC_THEME_HOVER_BG = RGB(235, 243, 255); // hover 态极浅蓝底 - -// 不透明封装:COLORREF 主题色 -> alpha=255 的 Gdiplus::Color(通道按位展开,值不变)。 - -static inline Gdiplus::Color ScOpaqueColor(COLORREF c) { - return Gdiplus::Color(255, GetRValue(c), GetGValue(c), GetBValue(c)); -} - -// 撤销栈最大深度(快照份数)。撤销历史是整份标注的深拷贝,不限深会随操作数平方级累积内存 -// (每笔操作全量复制一次),超出后由 PushAnnotationHistory 裁掉最老快照。 - -static const int SC_UNDO_MAX_DEPTH = 50; - -// 手柄/工具栏几何常量 - -static const int SC_HANDLE_SIZE = 10; // 调整手柄边长(100% DPI 基准,运行时按 dpiScale 缩放) - -static const int SC_TOOLBAR_BTN = 32; // 按钮尺寸(正方形) - -static const int SC_TOOLBAR_PAD = 6; // 按钮↔工具栏边缘内边距(四边一致) - -static const int SC_TOOLBAR_H = SC_TOOLBAR_BTN + SC_TOOLBAR_PAD * 2; // 工具栏高度 = 按钮 + 上下内边距 - -static const int SC_TOOLBAR_GAP = 1; // 按钮间距 - -static const int SC_TOOLBAR_RADIUS = 8; // 工具栏圆角 - -static const int SC_TOOLBAR_MARGIN = 6; // 选区到工具栏间距 - -static const int SC_TOOLBAR_BORDER = 1; // 工具栏边框 - -static const int SC_MIN_SELECTION = 10; // 最小选区尺寸 - -static const int SC_CORNER_KNOB_INSET = 18; // 圆角拖拽手柄距选区角的内缩距离(100% DPI 基准,运行时按 dpiScale 缩放) - -static const int SC_CORNER_PROXIMITY = 14; // 倒角手柄"靠近"感应余量:在命中框外再扩此距离即显示该角手柄(100% DPI 基准) - -// 手柄几何(DPI 缩放后)。选区/标注 resize 手柄与圆角手柄共用同一套尺寸, -// 保证 1080p → 4K 下手柄与工具栏/图标同步放大,避免高 DPI 下手柄过小。 -struct SCHandleMetrics { - int handleSize; // 手柄边长(绘制 + 命中框半宽基准) - int cornerKnobInset; // 圆角手柄距选区角的内缩距离 - int handleMargin; // 脏区扩张余量 = handleSize/2 + 4(覆盖手柄半径 + 描边/抗锯齿) - int cornerProximity; // 倒角手柄靠近感应余量(命中框外扩展距离,鼠标进入即显示该角手柄) -}; - -// 子菜单几何常量(100% DPI 基准值,运行时按 dpiScale 缩放) -// 单行布局:[粗细圆点×3] | [分隔线] | [颜色圆点×8],无文案。 -// 单元格(点击区 + 选中背景区)大小与工具栏按钮一致,便于视觉对齐。 -static const int SC_POPUP_CELL = SC_TOOLBAR_BTN; // 单元格尺寸(= 工具栏按钮大小) - -static const int SC_POPUP_PAD = 4; // 内边距 - -static const int SC_POPUP_RADIUS = 8; // 圆角 - -static const int SC_POPUP_COLOR_DOT = 18; // 颜色圆点直径(图标本身) - -static const int SC_POPUP_SEP_GAP = 6; // 分隔线两侧间距 - -static const int SC_POPUP_SEP_H = 20; // 分隔线高度 - -static const int SC_POPUP_BORDER = 1; // 边框 - -static const int SC_POPUP_MARGIN = 4; // 工具栏与子菜单间距 - -// 子菜单几何(DPI 缩放后) - -struct SCPopupMetrics { - int pad; - int radius; - int cell; // 单元格尺寸(点击区 + 选中背景区,= 工具栏按钮大小) - int colorDot; // 颜色圆点直径(图标本身) - int sepGap; // 分隔线两侧间距 - int sepH; // 分隔线高度 - int border; - int margin; -}; - -// ---- 信息面板 DPI 缩放几何 ---- - -struct SCPanelMetrics { - int w; - int h; - int magnifierH; - int margin; - int radius; - int fontPx; - int crosshair; - int borderPad; - int labelPad; - int sizeLabelPadX; - int sizeLabelPadY; - int sizeLabelGap; -}; - -// 窗口信息 - -struct SCWindowInfo { - HWND hwnd; - RECT rect; - std::wstring title; -}; - -// 截图结果结构 - -struct ScreenshotResult { - bool success; - int x; - int y; - int x2; - int y2; - int width; - int height; - std::string base64; -}; - -// GDI 资源缓存 - -struct SCGdiResources { - HBRUSH bgBrush = NULL; - HPEN borderPen = NULL; - HPEN crosshairPen = NULL; - HPEN selectionPen = NULL; - HPEN highlightPen = NULL; - HFONT smallFont = NULL; - int smallFontPx = 0; - int crosshairWidth = 0; - // 选区外遮罩缓冲(虚拟屏幕大小,纯黑 + 常量 alpha),用于 AlphaBlend - HDC maskDC = NULL; - HBITMAP maskBitmap = NULL; - // ---- P2 性能优化:固定样式 Pen/Brush 会话级缓存,避免每帧 Create/Delete ---- - // 工具栏分隔线笔(DrawToolbar)。 - HPEN toolbarSepPen = NULL; // PS_SOLID, 1, RGB(230,230,230) - // 文字选择高亮画刷(AlphaBlend 半透明选区底色)。 - HBRUSH textSelBrush = NULL; // RGB(51,153,255) - // 悬停/选中标注边框:蓝色虚线笔(悬停文字/非文字标注 + 选中非文字标注共用)。 - HPEN annHoverPen = NULL; // PS_DASH, 1, RGB(0,136,255) - // 选中文字标注边框:蓝色实线粗笔(与 selectionPen 的宽度 1 区别)。 - HPEN annTextSelPen = NULL; // PS_SOLID, 2, RGB(0,136,255) - - // 创建/释放方法体下沉到 session_windows.cpp(CR-022:唯一使用方在会话层, - // 从头文件内联定义改为外部定义,调用方签名/语义不变)。 - void Init(int fontPx = 12, int crosshairPx = 1); - // 创建遮罩缓冲(纯黑位图,配合常量 alpha 实现 40%+ 半透明遮罩) - // 须在 CaptureContext 虚拟屏幕尺寸确定后调用 - void InitMask(int virtualW, int virtualH); - void Cleanup(); -}; - -// ---- 工具栏 DPI 缩放几何 ---- -// 基础逻辑尺寸(100% DPI)按 dpiScale 放大,保证 1080p → 4K 下工具栏尺寸与图标同步。 -// 基础值与原 SC_TOOLBAR_* 常量保持一致,便于回归。 -struct SCToolbarMetrics { - int btn; // 按钮边长 - int h; // 工具栏高度 - int gap; // 按钮间距 - int pad; // 按钮↔工具栏边缘内边距(四边一致) - int radius; // 圆角半径 - int margin; // 选区到工具栏间距 - int border; // 工具栏边框宽度 - int iconSize; // 图标光栅化尺寸(物理像素) -}; - -// 工具栏图标位图缓存:按当前 DPI 渲染一次,dark/white 两色版本。 -// dark = normal/hover 图标色,white = active(蓝底)图标色。 -struct SCIconCache { - bool inited; - int iconSize; - HBITMAP dark[TB_Count]; // 普通态:深灰图标 - HBITMAP active[TB_Count]; // 选中态:主题蓝图标(搭配浅蓝高亮底) - - SCIconCache() : inited(false), iconSize(0) { - for (int i = 0; i < TB_Count; i++) { dark[i] = NULL; active[i] = NULL; } - } - - // Init/Cleanup 依赖 nanosvg 光栅化(kIconSvgs/RenderSvgToBitmap),实现在 icons_windows.cpp - void Init(int physicalIconSize); - void Cleanup(); - - // 取按钮位图:isActive 时用主题蓝版本,其余用深灰 - HBITMAP Get(int btn, bool isActive) const { - if (btn < 0 || btn >= TB_Count) return NULL; - return isActive ? active[btn] : dark[btn]; - } -}; - -// 截图上下文 - -struct CaptureContext { - CaptureState state = CS_Idle; - // 自动确认模式:选区确定后直接提取并完成截图,不进入编辑态(工具栏/标注)。 - // 仅在 WM_LBUTTONUP 的 CS_Selecting 分支生效。 - bool autoConfirm = false; - int virtualX = 0, virtualY = 0, virtualW = 0, virtualH = 0; - int startX = 0, startY = 0, endX = 0, endY = 0; - int mouseX = 0, mouseY = 0; - COLORREF currentColor = 0; - std::vector windows; - int hoveredWindow = -1; // -1 = none - // 预截屏 - HBITMAP screenBitmap = NULL; - HDC memDC = NULL; - // 双缓冲 - HDC backDC = NULL; - HBITMAP backBitmap = NULL; - // 脏区域追踪 - RECT lastPanelRect = {}; - RECT lastSelectionRect = {}; - RECT lastLabelRect = {}; - RECT lastHighlightRect = {}; - RECT lastToolbarRect = {}; - RECT lastPopupRect = {}; - // P1 局部刷新用:上帧光标/被操作标注/正在绘制标注的包围盒(供 InvalidateRect 计算旧位置) - RECT lastCaretRect = {}; // 上帧文字光标矩形(backDC 坐标),hasLastCaret=false 表示无效 - bool hasLastCaret = false; - RECT lastAnnotationBox = {}; // 上帧被拖拽/缩放标注的包围盒(绝对虚拟屏幕坐标) - bool hasLastAnnotationBox = false; - RECT lastDrawingBox = {}; // 上帧 curDrawing 包围盒(绝对虚拟屏幕坐标) - bool hasLastDrawingBox = false; - bool needFullRedraw = false; - // DPI 缩放因子(逻辑像素 → 物理像素 = 乘以 dpiScale;物理 → 逻辑 = 除以)。 - // 【已知限制 —— 单一 scale 模型】CaptureVirtualScreen 把所有显示器的物理并集 - // BitBlt 进一张连续物理位图,再令 dpiScale = physVw / vw(物理并集宽 / 逻辑并集宽)。 - // 单显示器(含系统级统一 DPI 缩放)下该值精确;混合 DPI 多显示器下它是各屏 scale - // 的加权混合值,无法还原为任一具体显示器——选区跨越不同 DPI 的屏幕时,按此单一 - // scale 做逻辑↔物理换算会产生系统性像素偏移(越界采样、坐标错位)。 - // 正确修复需升级为 per-monitor 模型:每个逻辑↔物理换算点按鼠标/选区所在显示器 - // 用 MonitorFromPoint+GetMonitorInfo+GetDpiForMonitor 取各自 scale,并改造 - // CaptureVirtualScreen 的整屏 BitBlt 与位图布局以保留各屏原始 DPI 采样(而非混合 - // 进单一连续网格)。该改动触及捕获/存储/渲染全链路且必须经真实多屏异 DPI 环境验证, - // 当前任务禁运行时测试约束下无法安全实施,故仅标注已知限制 + 统一各换算点的写法。 - // 见 docs/CODE-REVIEW-ROADMAP.md CR-023。 - double dpiScale = 1.0; - // GDI 资源 - SCGdiResources gdi; - SCPanelMetrics panelMetrics; - - // ---- 确认态:可调整选区 ---- - // 已确认的选区(绝对屏幕坐标) - RECT selection = {}; - // 当前正在拖拽的手柄(CS_Resizing 时有效),CS_Confirmed 下表示 hover 手柄 - int resizeHandle = RH_None; - // 整体拖动/调整起点(绝对屏幕坐标) - int dragStartX = 0, dragStartY = 0; - RECT dragStartSelection = {}; - // 选区圆角半径(0=直角;上限=min(w,h)/2,由 ClampCornerRadius 保证) - int selectionCornerRadius = 0; - // 圆角手柄拖拽起始半径(RH_CornerRadiusTL/TR/BL/BR 拖拽用,增量映射) - int dragStartRadius = 0; - // 当前"靠近/拖拽"的倒角手柄角(RH_CornerRadiusTL/TR/BL/BR 之一;RH_None=未靠近)。 - // 仅鼠标靠近某角或正拖拽某角时显示该角一个倒角手柄,其余时刻隐藏。 - int hoveredCornerHandle = RH_None; - // 键盘方向键微调累计位移(CS_Resizing 时叠加到鼠标位移上,松开时一并固化) - int kbDX = 0; - int kbDY = 0; - - // ---- 悬浮工具栏 ---- - // 工具栏矩形(相对虚拟屏幕坐标,绘制用) - RECT toolbarRect = {}; - // 用户按住最左「6 点把手」拖动过后置位:此后 OnPaint 直接沿用 toolbarRect, - // 不再随选区自动重算(会话内拖动/缩放选区时工具栏保持用户放置的位置) - bool toolbarPlaced = false; - // 正在拖动工具栏(把手左键按下未松开):MOUSEMOVE 平移 toolbarRect 并局部刷新 - bool toolbarDragging = false; - int toolbarDragStartX = 0, toolbarDragStartY = 0; // 按下时鼠标位置(绝对屏幕坐标) - RECT toolbarDragStartRect = {}; // 按下时的工具栏矩形(相对坐标) - // 工具栏 hover 按钮,-1 = none(SC_TB_GRIP = 悬停在拖拽把手上) - int hoverToolbarBtn = -1; - // ---- 工具栏 title 式 tooltip(网页 title 同款:悬停停顿出现深色圆角气泡)---- - // 由会话空闲循环轮询维护(TickToolbarTooltip),气泡画进 backDC(DrawToolbarTooltip) - int tipBtn = -1; // 当前停顿目标按钮(-1 = 无;分隔线无 tooltip) - DWORD tipDwellSince = 0; // 光标进入目标按钮的起始时刻(毫秒) - bool tipShown = false; // 气泡当前是否在屏(负责自身矩形的失效重绘) - RECT tipBubbleRect = {}; // 气泡矩形(backDC 相对坐标) - std::wstring tipText; // 气泡文本 - // 当前激活的工具(高亮显示,仅界面) - int activeTool = -1; - // 当前子菜单/参数面板对应的工具来源;拖拽工具下选中覆盖物时可继续回显其参数。 - int popupTool = -1; - // 工具栏图标位图缓存(按 DPI 预渲染,dark/white 双色) - SCIconCache iconCache; - // 当前 DPI 下的工具栏几何(缓存,避免每次绘制重算) - SCToolbarMetrics toolbarMetrics; - // 当前 DPI 下的手柄几何(缓存:选区/标注 resize 手柄 + 圆角手柄) - SCHandleMetrics handleMetrics; - - // ---- 标注绘制 ---- - std::vector annotations; // 已提交标注 - // 撤销/重做快照栈(队首=最老)。undoStack 由 PushAnnotationHistory 写入并限深 SC_UNDO_MAX_DEPTH; - // 重做路径(UndoAnnotations→redoStack / RedoAnnotations→undoStack)每次入栈前必有一次对应的 - // 出栈,数学上不会超过同一上限。 - std::deque> undoStack; - std::deque> redoStack; - Annotation curDrawing; // CS_Drawing 中正在绘制的标注 - bool hasCurDrawing = false; // curDrawing 是否有效 - int drawColorIdx = 0; // 当前选中颜色索引 - int drawThickIdx = 0; // 当前选中粗细索引(矢量工具) - int fontSizeIdx = 0; // 当前选中字号索引(文字工具) - // 马赛克工具属性 - int mosaicSizeIdx = 0; // 当前选中马赛克块大小索引 - int mosaicRadiusIdx = 0; // 当前选中涂抹半径索引 - bool mosaicRectMode = false; // true=框选区域模式;false=涂抹模式 - // 涂抹模式光标:用系统光标机制(SetCursor)显示半径圆,由 OS 跟随鼠标, - // 无 WM_PAINT 重绘延迟(之前的 overlay 圆走 MOUSEMOVE→InvalidateRect→WM_PAINT 链路, - // 全屏重绘开销大导致不跟手)。按半径预设预生成彩色光标并缓存。 - HCURSOR mosaicBrushCursors[SC_MOSAIC_RADIUS_COUNT] = {}; // 对应 SC_MOSAIC_RADIUS_COUNT 个半径预设的光标 - bool mosaicBrushCursorsInited = false; - // ---- 马赛克渲染(reveal-mask 模型,消除不连续感)---- - // 预先把整张截图按当前块大小马赛克化得到 mosaicBase(逻辑像素,与 backDC 同尺寸)。 - // 马赛克标注只是「蒙版」:涂抹=路径圆形区域、框选=矩形区域,揭示其背后的 mosaicBase。 - // 这样任意区域、任意顺序叠加都连续无缝;切换块大小时只需重建 base,已揭示区域自动更新。 - // mosaicBase 覆盖整虚拟屏幕(绝对坐标),与选区无关,resize/move 无需重建。 - HDC mosaicBaseDC = NULL; - HBITMAP mosaicBaseBitmap = NULL; - int mosaicBaseW = 0, mosaicBaseH = 0; // base 尺寸(= 虚拟屏幕逻辑尺寸) - int mosaicBaseBlockPx = 0; // 生成 base 时的块大小(检测变更触发重建) - // 涂抹模式增量绘制:记录上一帧最后绘制的路径点索引(reveal 模型下未使用,保留扩展)。 - int mosaicDrawLastIdx = 0; - // 粗细/颜色子菜单 - bool popupOpen = false; - RECT popupRect = {}; - SCPopupMetrics popupMetrics; - - // ---- 文字输入(CS_TextEditing)---- - std::wstring textBuf; // 正在输入的文字缓冲 - int textAnchorX = 0, textAnchorY = 0; // 文字锚点(绝对虚拟屏幕坐标) - int textCaretPos = 0; // 插入符在 textBuf 中的 wchar 位置 - bool textCaretVisible = false; // 光标是否可见(闪烁控制) - DWORD textCaretLastBlink = 0; // 上次光标闪烁时间(毫秒) - int textSelStart = -1; // 文字选择起始位置(-1 表示无选择) - int textSelEnd = -1; // 文字选择结束位置 - bool textDraggingSelection = false; // 是否正在拖动选择文字 - int hoveredTextAnnotation = -1; // 悬浮命中的文字标注索引(-1 表示无,仅用于光标/即时反馈) - int selectedTextAnnotation = -1; // 已选中的文字标注索引(-1 表示无,持久保持直到点空白) - int draggingTextAnnotation = -1; // 正在拖动的文字标注索引(-1 表示无) - int textDragStartX = 0, textDragStartY = 0; // 文字拖动起始位置 - // ---- 非文字标注的选中/拖拽/缩放(与文字机制互斥:选中非文字时清文字选中,反之亦然)---- - int hoveredAnnotation = -1; // 悬浮命中的非文字标注索引(-1=无,用于虚线框/光标即时反馈) - int selectedAnnotation = -1; // 已选中的非文字标注索引(-1=无,持久保持直到点空白/进入其他操作) - int draggingAnnotation = -1; // 正在拖拽的非文字标注索引(-1=无) - int resizingAnnotation = -1; // 正在缩放的非文字标注索引(-1=无) - int annotationResizeHandle = RH_None; // 当前缩放手柄(RH_None=无;CS_Resizing 时为四角之一) - int annotationDragStartX = 0, annotationDragStartY = 0; // 鼠标按下位置(绝对坐标,拖拽/缩放共用) - Annotation dragStartAnnotation; // 按下时标注快照(拖拽时还原+平移) - bool annotationOpHistoryPushed = false; - RECT annotationResizeStartBox = {}; // 按下时包围盒(缩放时基准) - - // ---- GDI+ 会话级资源(性能优化:会话内单次 Startup/Shutdown)---- - // 原实现每个绘制/测量函数各自 GdiplusStartup/Shutdown,每帧 WM_PAINT 触发 6~10 次昂贵的 - // GDI+ 初始化,是拖拽卡顿的主因。由于所有 GDI+ 调用均在 ScreenshotCaptureThread 单线程内, - // 改为会话开始 Startup 一次、结束 Shutdown 一次。FontFamily(SC_FONT_FACE) 与 - // StringFormat(总是 Near/Near) 为常量;Font 仅依赖 fontPx(文字字号仅 SC_FONT_SIZES 三档), - // 均缓存复用。Graphics 仍每次按 hdc 新建(必须,因为绑定不同 DC)。 - ULONG_PTR gdipToken = 0; // GDI+ 启动令牌(0 = 未初始化) - Gdiplus::GdiplusStartupInput gdipStartupInput; - bool gdipInited = false; // GDI+ 是否已 Startup - Gdiplus::FontFamily* gdipFontFamily = nullptr; // SC_FONT_FACE,会话内唯一 - Gdiplus::StringFormat* gdipStrFmt = nullptr; // Near/Near,会话内唯一 - Gdiplus::Font* gdipFonts[3] = {}; // 按 SC_FONT_SIZES 预建的 Font(索引对齐 SC_FONT_COUNT) -}; - -// ==================== 长截图(手动滚动捕获) ==================== - -// 进入方式:在编辑态(CS_Confirmed)点工具栏「长截图」按钮 → BeginLongCapture -// 隐式切换到 CS_LongCapturing,通过 PostMessage(WM_LONGCAPTURE_RUN) 在覆盖层 -// 窗口过程中执行 RunLongCapture。 -// -// 快照选区 → 隐藏全屏覆盖层 → 创建侧边预览面板(缩略小地图 + 完成/取消)→ -// 滚轮停稳后采样一帧并增量拼接到小地图;向下滚追加到底部,向上滚前插到头部。 -// -// 交互形态为手动模式:用户自己在选区上滚动鼠标滚轮(Raw Input 被动观察,不拦截输入), -// -// 独立于 CaptureContext 编辑态状态机的轻量上下文。复用截图会话的线程与 -// threadsafe 回调(g_screenshotTsfn),但滚动/拼接逻辑全部在此处实现, -// 不触碰 CaptureContext 的标注/工具栏机制。 - -#define WM_LONGCAPTURE_RUN (WM_APP + 200) - -// 行细节量 = 行内相邻采样列的灰度跳变数——空白/均匀区细节为 0,其上的“匹配”不可信 -// (在任何位移都能“匹配”,只会污染评分),以行权重(细节量截断到 0~4)参与评分, -// 空白行天然不计分;整帧细节总量过低则无对齐依据。 -// -// 匹配用帧数据:量化灰度行(h × cols)+ 每行匹配权重。 - -// 多 ROI 独立验证的列带数量(左/中/右)。列带把采样列均分为三段, -// 每段独立产出 Top-N 候选后做跨 ROI 加权聚类,防单个区域误匹配决定拼接结果。 -static const int LC_ROI_BANDS = 3; - -// 多尺度垂直结构 profile 的列桶数:每行按采样列均分为若干桶取平均量化灰度, -// 再做 4/8 行滑动聚合,供候选位移的结构一致性辅助验证(LongCaptureProfileCorrelation)。 -static const int LC_PROFILE_BUCKETS = 8; - -struct LongMatchData { - std::vector gray; // 4bit 量化灰度,行距 cols - std::vector weight; // 每行匹配权重 0~4(= min(4, 行细节量)) - std::vector bandWeight[LC_ROI_BANDS]; // 每行各列 ROI(左/中/右)权重 0~4 - std::vector edge; // 每行一维垂直结构强度:相邻采样列量化灰度差绝对值之和 - //(Weak 档匹配的边缘结构特征,见 LongCaptureEdgeCorrelation) - std::vector profile4; // 4 行聚合垂直结构 profile:h 行 × LC_PROFILE_BUCKETS 列桶, - // 桶值 = 窗口内行桶均值的平均(多尺度结构一致性验证用) - std::vector profile8; // 8 行聚合 profile(同上,更粗尺度) - int cols = 0; - int bandStart[LC_ROI_BANDS] = {0, 0, 0}; // 各 ROI 起始采样列 - int bandCols[LC_ROI_BANDS] = {0, 0, 0}; // 各 ROI 采样列数 - int h = 0; - int detailSum = 0; // 全帧行细节量总和(整帧近乎均匀时对齐不可信) -}; - -// 匹配模式(双模式识别):按候选位移的重叠量分档,overlap 越小要求证据越强。 -enum class LongCaptureMatchMode { - Normal, // 常规匹配:overlap >= max(24, 15% 视口高),沿用现有阈值与多数派 ROI 聚合 - WeakOverlap // 弱重叠/大跳变匹配:overlap >= max(32, 5% 视口高),以显著更严格的 - // 交叉验证(3/3 ROI 全票 + 边缘结构相关 + 动态高置信度 + 延迟确认)换取可识别性 -}; - -// 匹配状态(三值结论):只有 SUCCESS 允许进入拼接提交。 -// LOW_CONFIDENCE / FAILED 一律按“本帧被拒绝”处理(重试/计失败), -// 绝不允许用兜底 offset、上一帧推导 offset 或整帧插入的方式“继续流程”。 -enum LongMatchStatus { - LC_MATCH_FAILED = 0, // 无可信对齐(无候选峰 / ROI 相互矛盾) - LC_MATCH_LOW_CONFIDENCE, // 有候选但置信度不足(宽松档/单 ROI/置信度低于阈值) - LC_MATCH_SUCCESS // 可信对齐(严格档 + 多 ROI 一致 + 置信度达标) -}; - -// 识别失败的细分类别(可观测性增强:失败不再统一记为 FAILED,归类见 DetectPass / -// TryStitch 各拒绝点)。只用于失败归因、调试日志与重试节奏选择(瞬态 vs 结构性), -// 不参与任何验收阈值判定;成功路径一律保持 None。 -enum class LCFailReason { - None = 0, - NoCandidate, // NO_CANDIDATE:全部 ROI 无候选 / 整帧无对齐依据 - CandidateWeak, // CANDIDATE_WEAK:有候选但整体匹配或综合置信度不足 - PeakAmbiguous, // PEAK_AMBIGUOUS:峰值分离度不足(周期性/重复内容歧义) - GlobalMismatch, // GLOBAL_MISMATCH:全宽富验证不可评(重叠区近乎全空白) - SeamMismatch, // SEAM_MISMATCH:接缝窗证据不足 - SpatialMismatch, // SPATIAL_MISMATCH:top/middle/bottom 空间一致性崩塌 - ContinuityMismatch, // CONTINUITY_MISMATCH:匹配分布碎片化(连续性不足) - ProfileMismatch, // PROFILE_MISMATCH:多尺度垂直结构不一致 - RoiInconsistent, // ROI_INCONSISTENT:跨 ROI 候选冲突 / 聚类支持不足 - OffsetImplausible, // OFFSET_IMPLAUSIBLE:offset 被历史合理性校验拒绝 - DirectionConflict, // DIRECTION_CONFLICT:最佳候选与最近滚动方向相反 - FrameUnstable // FRAME_UNSTABLE:稳定性检测未过(本帧未进入正式匹配) -}; - -// 识别阶段输出(纯检测结果):未经 offset 合理性校验前不得用于提交。 -struct LongMatchOutcome { - LongMatchStatus status = LC_MATCH_FAILED; - LongCaptureMatchMode mode = LongCaptureMatchMode::Normal; // 本次候选的匹配档位 - int offset = 0; // 候选位移 d(>0 向下滚,<0 向上滚;|d|≤1 视为未滚动) - int overlap = 0; // 候选位移对应的重叠行数(= h − |d|) - float overall = 0.0f; // 全宽重叠区加权匹配率 - float seam = 0.0f; // 接缝窗加权匹配率 - float top = 0.0f; // 重叠区上 1/3 加权匹配率(该段无有效行时记 0) - float middle = 0.0f; // 重叠区中 1/3 加权匹配率 - float bottom = 0.0f; // 重叠区下 1/3 加权匹配率 - float spatial = 0.0f; // top/middle/bottom 空间一致性综合分(局部假匹配在此崩塌) - float continuity = 0.0f; // 匹配分布连续性(最长连续段占比 + 匹配率 − 断点罚) - float profileScore = 0.0f; // 4/8 行聚合 profile 多尺度垂直结构一致度 - float edgeCorrelation = 0.0f; // 重叠区行边缘结构强度的归一化相关度(Weak 档强证据) - float peakGap = 0.0f; // 与次优候选的综合分差(归一化;1 = 无竞争峰) - float roiWeighted = 0.0f; // 跨 ROI 加权证据(按 ROI 信息量加权融合的支持度) - float confidence = 0.0f; // 综合置信度(全宽匹配 + 空间/连续性 + 多尺度结构 + - // ROI 加权证据 + 峰值分离度的加权组合,见 DetectPass) - LCFailReason reason = LCFailReason::None; // 拒绝原因分类(成功时保持 None;仅归因/日志/重试节奏用) - float textureRatio = 0.0f; // 重叠区有效纹理行占比(证据量计价,进入综合置信度) - int bandOffsets[LC_ROI_BANDS] = {0, 0, 0}; // 各 ROI 独立求得的位移 - bool bandValid[LC_ROI_BANDS] = {false, false, false}; // 该 ROI 是否产出可信候选 - int validBandCount = 0; // 产出候选的 ROI 数 - int agreeCount = 0; // 最终候选所在聚类的支持 ROI 数(加权融合后) -}; - -// 单帧采样结论(识别→校验→提交管线的对外语义)。 -enum class LCSampleOutcome { - Stitched, // 已安全提交拼接且新增了拼接行(唯一扩展累计拼接内容的结局) - Repositioned,// 匹配成功但新视口完全落在已捕获内容范围内(反向回滚未越过捕获边界): - // 只推进当前视口基准(lastFrame/committedContentTop)并移动小地图当前 - // 区域标注,不新增行、不计帧数——已捕获内容的重复帧绝不再次拼接 - NoChange, // 匹配成功但内容未滚动(d=0);与“匹配失败”“滚动到底”均独立 - WeakPending, // Weak 候选首次成立:只登记待复核候选(pendingMatch),不提交、不改任何累计状态 - WeakRejected,// Weak 候选被复核否决(第二次候选不一致/置信度跌破门槛/offset 异常): - // 与“完全无候选”的硬失败不同,不计入普通失败计数,走独立重试预算 - Unstable, // 稳定性检测未过(页面仍处滚动/重绘/懒加载过渡):本帧不进入正式匹配, - // 不提交、不修改任何累计状态(含跟踪/历史/Weak 候选),由采样主循环 - // 短延迟后重新采样,等待页面稳定 - Failed // 硬失败(无候选/验证崩塌/Normal offset 异常);累计状态保持原样 -}; - -// Weak Match 的“延迟确认提交”候选(防污染核心机制):首次弱重叠可信候选只登记、 -// 不提交;下一次稳定采样必须独立复现一致候选(|Δoffset| ≤ LC_WEAK_CONFIRM_OFFSET_TOL -// 且置信度不跌破动态门槛)才允许 Commit。候选被否决/放弃时只清除本结构, -// 绝不触碰累计拼接状态(body/headRev/lastFrame/lastMatch/offsetHistory)。 -struct LongCapturePendingMatch { - bool valid = false; - int offset = 0; - float confidence = 0.0f; - LongCaptureMatchMode mode = LongCaptureMatchMode::Normal; -}; - -// 最近帧历史条目(多跳匹配恢复的基准池)。内容坐标 = 以首帧视口顶为原点、不随头部 -// 前插平移的滚动空间坐标(拼接图内位置 = contentY + headRows)。 -// 只保存匹配所需数据(量化灰度/行权重/ROI 权重/profile 等,约 1MB 级/帧), -// 不保存完整位图;环形容量见 LC_HISTORY_FRAMES(long_capture_windows.cpp)。 -// contentY 语义:已提交帧精确(可作跨帧提交链锚点);未提交帧为 tentative 估计。 -struct LongCaptureFrameHistory { - int frameId = -1; // 采样序号(识别 lastFrame 直连条目 / 日志关联) - LongMatchData match; // 该帧匹配数据(LongCaptureBuildMatchData 全量产物) - int64_t contentY = 0; // 视口顶内容坐标(已提交=精确;未提交=估计) - bool committed = false; // 已提交帧:位置精确、可作提交链锚点 - bool validForMatching = true; // 近乎空白的帧不可作多跳匹配基准 -}; - -// 长截图工具栏二级菜单类型:方向与裁剪均为图标 popover(同一套展开/绘制/命中机制, -// long_capture_windows.cpp),popover 悬停各 cell 有 title 式 tooltip -enum LCMenuKind { - LCM_None = 0, // 无展开菜单 - LCM_Direction, // 方向 popover:纵向/横向图标 cell,悬停/点击方向按钮展开(已拼接多帧后锁定) - LCM_Crop // 裁剪 popover:悬停/点击裁剪按钮展开的图标浮层(丢弃上方/下方内容,随方向切换左右变体;已裁剪时含重置) -}; - -struct LongCaptureContext { - // 选项(由 start() 的 options.longCapture 注入,会话开始时拷贝) - int maxFrames = 100; // 最大拼接帧数(防无限增长,默认与 CaptureContext.lcMaxFrames 一致) - int interval = 250; // 滚轮停止后等待内容稳定的毫秒数(采样防抖) - - // 虚拟屏幕(逻辑坐标)与 DPI - int vx = 0, vy = 0, vw = 0, vh = 0; - double dpiScale = 1.0; // 单一 scale 模型的已知限制见 CaptureContext.dpiScale 注释(CR-023) - - // 选区(逻辑虚拟屏幕坐标,已规范化) - RECT selection = {}; - - // 采样裁剪矩形(逻辑虚拟屏幕坐标):选区每边内缩 LC_CROP_INSET_LOGI, - // 避开选区框描边与边缘抗锯齿像素污染拼接结果;滚轮过滤与结果 rect 均以此为准。 - RECT cropRect = {}; - - // 选区物理像素裁剪参数(相对虚拟屏幕原点的物理偏移 + 尺寸)。 - // 纵向模式 physW/physH 即帧尺寸;横向模式(horizontal=true)帧缓冲为屏幕采样的 - // 转置(physW=capH、physH=capW),下游匹配/拼接/缩略图管线与纵向完全同构。 - int physX = 0, physY = 0, physW = 0, physH = 0; - // 屏幕采样 DIB 尺寸(未转置的物理宽高;方向切换只交换 physW/physH,DIB 不变) - int capW = 0, capH = 0; - // 虚拟屏幕物理原点(屏幕 DC 坐标,DPI 感知下为物理像素),直接区域采样时作 BitBlt 源偏移 - int physOriginX = 0, physOriginY = 0; - // 专用 DIB 段:每帧采样的可复用目标(避免反复新建位图 + GetDIBits 回读) - HDC dibDC = NULL; - HBITMAP dibBmp = NULL; - void* dibBits = nullptr; - int dibW = 0, dibH = 0; - - // 预览面板放置在选区上方(空间兜底时的退化形态):向下生长受选区顶边约束, - // 避免面板长大后重新覆盖选区导致自身入画 - bool panelAbove = false; - - // 拼接结果:物理像素 BGRA,宽度恒为 physW;高度 stitchH 增长。 - // 分两段存储以让拼接保持 O(新增行):向上滚新增行存 headRev(倒序,reverse 后为显示顺序), - // 向下滚新增行存 body(正序);实际图像 = reverse(headRev) + body。 - std::vector headRev; - std::vector body; - int headRows = 0, bodyRows = 0; - int stitchH = 0; - // 最近一次采样的视口帧(相邻帧重叠检测基准,缓冲随拼接迭代复用) - std::vector lastFrame; - // lastFrame 的模糊对齐数据(两帧采样之间 lastFrame 不变,重叠检测直接复用, - // 只在拼接成功后随 lastFrame 旋转更新) - LongMatchData lastMatch; - - // 面板两级缩略图:先按固定列宽(面板预览宽)增量缩列,绘制时再合并缩行。 - // 分段与拼接缓冲同构(thumbHeadRev 倒序 / thumbBody 正序),thumbMerged 为绘制用临时合并缓冲。 - std::vector thumbHeadRev; - std::vector thumbBody; - std::vector thumbMerged; - int thumbW = 0; // 缩略图列宽(≤ physW) - int thumbHeadH = 0; // 头部段行数(合并后显示于顶部) - int thumbH = 0; // 缩略图总行数 - bool thumbDirty = false; - - // 滚轮观察状态(面板 WM_INPUT 经本线程消息泵分发,与主循环同线程,无需原子) - bool wheelPending = false; - DWORD lastWheelTick = 0; - DWORD lastSampleTick = 0; // 最近一次采样轮触发时刻(滚动中主动采样节拍用;首帧后初始化) - int lastDir = 0; // +1 最近一次向下滚(追加底部);-1 向上滚(前插头部) - - // —— 拼接失败恢复与 offset 合理性校验状态 —— - // 这些字段只在「成功提交拼接」后更新;识别失败/被拒绝的帧绝不写入, - // 保证单帧误识别无法通过污染匹配基准或位移历史把错误扩散到后续帧。 - std::vector offsetHistory; // 最近若干次成功拼接的 |d|(px,长度上限见 LC_OFFSET_HISTORY_LEN) - int noChangeCount = 0; // 连续「内容未变化」采样数(仅匹配成功且 d=0 时递增;与失败完全独立) - bool reachedBottom = false; // 滚动到底(由连续多次内容未变化确认;匹配失败绝不置位) - - // —— Weak(低重叠大跳变)延迟确认与独立重试状态 —— - // pendingMatch 只在本结构内暂存候选,任何未确认路径都不修改累计拼接状态; - // weakTries 计连续未决的 Weak 采样轮数,达 LC_WEAK_MAX_TRIES 即放弃当前候选链重新观察。 - LongCapturePendingMatch pendingMatch; // 待复核的 Weak 候选(有效时驱动无滚轮的继续采样) - int weakTries = 0; // 连续未决的 Weak 候选采样轮数 - int sampleIndex = 0; // 采样序号(LC_DEBUG_LOG 调试日志用) - - // —— Tentative 视觉跟踪状态(与正式拼接状态完全解耦的"预计当前位置"层)—— - // 正式拼接位置(committed)= committedContentTop,仅 LongCaptureCommitStitch 推进; - // 预计当前位置(tentative)= tentativeContentTop,由视觉验证(多跳匹配/零位移对齐/ - // 全同帧)直接设定或轻量预测推进。FAILED / LOW_CONFIDENCE 帧只允许触碰本组字段, - // 绝不修改 body/headRev/stitchH、lastFrame/lastMatch、offsetHistory 与拼接缓冲。 - // 防漂移约束:相对 committed 漂移上限(2×视口高)+ 连续无视觉依据冻结 - //(见 LongCaptureTrackingSetVisual / LongCaptureTrackingAdvancePredicted)。 - int64_t committedContentTop = 0; // lastFrame 视口顶(内容坐标;CommitStitch 内 += d) - int64_t tentativeContentTop = 0; // 预计当前视口顶(内容坐标;SUCCESS 后与 committed 对齐) - bool tentativeValid = false; // 是否已建立可信跟踪(小地图据此显示虚线预计框) - float tentativeConfidence = 0.0f; // tentative 置信度(视觉验证来源高,预测链逐帧衰减) - int trackUnreliableStreak = 0; // 连续无视觉依据采样数(达阈值冻结预测推进) - int trackingRevision = 0; // 跟踪状态变更计数(RunLongCapture 据此刷新小地图) - int lastCommittedFrameId = 0; // 最新已提交帧序号(多跳回溯时跳过 hop1 直连基准) - std::vector frameHistory; // 最近帧环形历史(容量 LC_HISTORY_FRAMES) - std::vector weakCandidateOffsets; // 最近弱重叠候选位移(Weak 时间一致性样本) - - // —— 帧稳定性检测(进入正式 DetectMatch 前的准入闸门,纯诊断层)—— - // stableRef* = 上一次抓帧的 4bit 灰度稀疏采样,供与本次抓帧对比判断页面是否仍在 - // 快速变化(滚动动画/重绘/懒加载过渡)。每次抓帧后滚动更新;只在稳定性闸门启用 - // 且参考帧新鲜(间隔 ≤ LC_STABLE_REF_MAX_GAP)时参与判定。绝不参与 offset 评分、 - // 不触碰任何累计拼接状态(含跟踪/历史/Weak 候选)。 - std::vector stableRefGray; - int stableRefCols = 0; - int stableRefH = 0; - DWORD stableRefTick = 0; - bool stableRefValid = false; - - // —— 失败可观测性:最近一次拒绝的原因分类与最佳候选证据快照 —— - // 仅调试日志与采样主循环的重试节奏选择(瞬态 vs 结构性)使用,不参与任何判定。 - LCFailReason lastFailReason = LCFailReason::None; - LongMatchOutcome lastReject; - - // —— 滚轮 delta 软先验(仅用于候选排序加分,绝不作为期望区间硬约束) —— - // wheelAccumDelta 在面板 WM_INPUT 累计,成功提交时折算 px/notch 并 EMA 平滑; - // 失败采样绝不更新估计。Windows/浏览器/平滑滚动/触控板都使位移与 notch 非线性相关, - // 因此先验只轻微影响同档候选的排序先后(LC_WHEEL_PRIOR_BONUS),不改变任何阈值。 - int wheelAccumDelta = 0; // 自上次成功提交以来累计的滚轮增量(带符号,WHEEL_DELTA=120) - float pixelsPerWheelNotch = 0.0f; // 「成功像素位移 ↔ 滚轮 notch」在线估计(0 = 尚无估计) - - // 控制信号(跨线程:abortLongCapture 由 JS 线程设置) - std::atomic abortFlag{false}; - std::atomic finishFlag{false}; // 用户点「完成并复制」 - std::atomic frameCount{0}; - - // —— 选区底部工具栏(宽高标签 / 方向 / 自动滚动 / 裁剪 / 保存 / 取消 / 完成并复制)—— - bool horizontal = false; // 长截图方向:false=纵向(默认);true=横向(帧缓冲转置复用纵向管线) - bool autoScroll = false; // 自动滚动(默认关闭):开启时光标一次性移到选区中心,此后定时器只注入滚轮 - //(SendInput 按光标下方窗口路由;单拍不动鼠标,见 LongCaptureAutoScrollTick) - std::atomic saveFlag{false}; // 用户点「保存到本地」:主循环内弹保存对话框并直接落盘 - // —— 裁剪状态(全部用「内容坐标」表达:拼接图行 = 内容坐标 + headRows)—— - // 内容坐标不随头部前插/主体追加平移,裁剪线因此永远锚定在页面的同一位置, - // 不会因继续滚动导致窗口错位(旧的绝对拼接行号在前插后会整体漂移)。 - // 裁剪动作只收紧输出行窗口并登记「待剔除区间」,绝不立即修改拼接缓冲与匹配基准; - // 待剔除区间在第一次朝该方向的成功提交时物理删除(见 CommitStitch 入口的延迟剔除), - // 删除后该侧边界重新开放——继续滚动的新增内容直接续接在裁剪线之后继续拼图。 - int64_t cropTopY = INT64_MIN; // 输出窗口上界(内容坐标);INT64_MIN = 该侧开放(未裁剪) - int64_t cropBottomY = INT64_MAX; // 输出窗口下界(内容坐标);INT64_MAX = 该侧开放(未裁剪) - bool cropPendTop = false; // 待剔除「上方已捕获内容」(丢弃上方登记):下一次 d<0 提交时删除 - int64_t cropPendTopLo = 0, cropPendTopHi = 0; // 待删内容区间 [lo, hi) - bool cropPendBottom = false; // 待剔除「下方已捕获内容」(丢弃下方登记):下一次 d>0 提交时删除 - int64_t cropPendBottomLo = 0, cropPendBottomHi = 0; - bool cropped = false; // 是否存在任何生效的裁剪约束(重置菜单项/图标徽标显示用; - // 含尚未触发的待剔除区间,触发或重置后自动回收) - int tbHover = -1; // 工具栏 hover 项(LongToolbarItem,-1=无) - int tbPressItem = -1; // 工具栏按下项(WM_LBUTTONDOWN 命中,-1=无):UP 必须命中与 - // DOWN 相同的目标才触发点击——编辑工具栏「长截图」按钮在按下 - // 瞬间创建本工具栏,残留的松开事件绝不能误触恰好同位的按钮 - int tbPressMenuRow = -1; // 二级菜单 popover 按下的 cell(-1=无;语义同 tbPressItem) - bool tbDragging = false; // 正在拖动工具栏窗口(按住最左 6 点把手,鼠标捕获中) - int tbDragGrabDX = 0, tbDragGrabDY = 0; // 按下点相对工具栏窗口左上角的偏移(物理像素) - LCMenuKind menuKind = LCM_None; // 展开中的二级菜单类型(方向/裁剪均为图标 popover) - int menuHover = -1; // 二级菜单 popover hover 的图标 cell(-1=无) - bool menuBelow = false; // 二级菜单绘制在工具栏下方(朝避让选区的一侧展开,见 LongCaptureSetMenu) - LCMenuKind popHoverDisarm = LCM_None; // 「悬停展开」被解除武装的菜单锚点(LCM_None=全部武装): - // 点击收起其 popover 后置为该菜单,光标移出锚点前不再 - // 因悬停重开,防止点击收起与悬停展开互相打架(方向/裁剪通用) - std::vector thumbDisplay; // 横向模式显示用回转缩略图(纵向直接用 thumbMerged) - int thumbDisplayW = 0, thumbDisplayH = 0; - bool thumbDisplayDirty = false; - int autoFailStreak = 0; // 自动滚动中连续硬失败采样轮数(达上限自动停止,防丢内容) - - // 输出结果(物理像素) - int outWidth = 0, outHeight = 0; - std::string base64; - bool success = false; -}; - -// 显示器枚举回调数据 - -struct MonitorEnumData { - LONG minLeft, minTop, maxRight, maxBottom; - double totalDpiScale; - int monitorCount; - HMODULE shcore; // 外层一次 LoadLibraryW("shcore.dll") 的句柄,回调内复用 GetDpiForMonitor(避免每显示器重复 Load) -}; - -// 采样裁剪相对选区每边内缩(逻辑像素):避开选区框描边与边缘抗锯齿,防止污染拼接内容。 - -static const int LC_CROP_INSET_LOGI = 2; - -// ---- 可变全局变量(定义见各归属 .cpp)---- -// 访问规则(CR-022:此前线程归属散落在各字段注释,现集中声明): -// 线程模型——本模块仅两条线程访问这些全局:JS 线程(NAPI 导出 start/abort 等)与 -// 截图捕获线程(ScreenshotCaptureThread,串行创建所有覆盖层窗口并派发其消息, -// 各窗口 wndproc 与绘制函数均在该线程内执行,互斥天然串行)。 - -// 覆盖层主窗口句柄:捕获线程独占。start() 时在捕获线程创建,消息循环内读写, -// WM_DESTROY 末尾由捕获线程置 NULL(wndproc_windows.cpp)。JS 线程不直接访问。 -extern HWND g_screenshotOverlayWindow; - -// 截图进行中标志(atomic):跨线程。捕获线程 start 入口置 true、各退出路径置 false; -// JS 线程 start() 入口读它拒绝重入(已进行中则直接返回)。无锁,依赖 atomic 可见性。 -extern std::atomic g_isCapturing; - -// 截图结果回传 JS 的 threadsafe function:跨线程但写时序确定。JS 线程在 -// StartRegionCaptureWithPrimedFrame 创建并初始化引用计数;此后由捕获线程独占 -// 调用/释放(EmitScreenshotResult 唯一调用点 + ReleaseScreenshotTsfn 唯一释放点, -// 均在捕获线程内),JS 线程不再触碰。CR-002 收口后访问点唯一。 -extern napi_threadsafe_function g_screenshotTsfn; - -// 截图捕获线程对象:JS 线程独占。start() 创建后立即 detach,此后不再访问 -// (依赖 g_isCapturing 而非 join 判定会话状态,故无需 join;线程自然退出)。 -extern std::thread g_screenshotThread; - -// 自动确认模式(atomic):跨线程。JS 线程 start() 写入;捕获线程在会话开始时 -// 一次性 load 进 CaptureContext.autoConfirm,会话期内不再读此全局。 -extern std::atomic g_autoConfirm; - -// 预截首帧(及保护其读写的互斥锁):跨线程,受 g_primedScreenshotFrameMutex 保护。 -// JS 线程(PrimeScreenshotFrameNow/PrimeScreenshotFrame)与捕获线程(会话开始消费) -// 均在锁内读写 bitmap 与 valid 等字段;bitmap 句柄所有权在锁内转移(写方创建、 -// 读方消费后置 invalid)。锁外不得解引用 bitmap。 -extern PrimedScreenshotFrame g_primedScreenshotFrame; -extern std::mutex g_primedScreenshotFrameMutex; - -// 截图上下文指针:捕获线程独占。会话开始置 &ctx(栈上局部变量)、结束置 nullptr; -// 窗口过程/绘制函数读它取 GDI+ 会话级资源(仅捕获线程内执行)。会话期外恒为 nullptr, -// 各读点均带 nullptr 守卫。JS 线程不访问。 -extern CaptureContext* g_captureCtx; - -// 长截图上下文指针(atomic):跨线程,受 g_longCtxMutex 保护指针生命周期。 -extern std::atomic g_longCtx; -// 保护 g_longCtx 指针的生命周期:JS 线程 LongCaptureAbort 的 load/abortFlag 写, -// 与捕获线程 WM_LONGCAPTURE_RUN 清理段的 store(nullptr)/delete 互斥(定义见 -// session_windows.cpp)。锁内只做指针读写与原子标志写,禁止 SendMessage 等 -// 可能死锁的调用;abortFlag 本身保持 atomic,由锁保证写入时对象仍存活。 -extern std::mutex g_longCtxMutex; - -// 长截图会话窗口句柄(控制面板/工具栏/蒙版):捕获线程独占。BeginLongCapture -// 时由捕获线程创建,wndproc 清理段(捕获线程)销毁并置 NULL。JS 线程不访问。 -extern HWND g_longControlWindow; -extern HWND g_longToolbarWindow; -extern HWND g_longMaskWindow; - -// 长截图参数:跨线程,但时序天然串行——JS 线程 start() 写入(先按默认重置再按 JS -// 覆盖,消除跨会话粘滞),捕获线程 BeginLongCapture 一次性读入 LongCaptureContext。 -// start() 写入发生在 g_screenshotThread 创建之前,捕获线程读在创建之后,happens-before -// 由线程创建建立,无需额外同步。 -extern int g_lcMaxFrames; -extern int g_lcInterval; - -// ---- 跨文件函数声明 ---- -bool IsCornerRadiusHandle(int h); -bool IsVectorTool(int btn); -bool IsDragTool(int btn); -bool CanShowStylePopupTool(int btn); -AnnotationType ToolToAnnotationType(int btn); -int AnnotationTypeToTool(AnnotationType t); -SCPanelMetrics CalcPanelMetrics(double dpiScale); -SCToolbarMetrics CalcToolbarMetrics(double dpiScale); -SCHandleMetrics CalcHandleMetrics(double dpiScale); -HBITMAP RenderSvgToBitmap(const char* svgText, COLORREF color, int px); -int CalcToolbarWidth(const SCToolbarMetrics& m); -SCPopupMetrics CalcPopupMetrics(double dpiScale); -// 多屏异分辨率布局基准:取包含参考矩形(绝对坐标)的显示器物理边界。 -// 工具栏/子菜单的上下翻转判定必须用所在显示器自身的边界——整个虚拟屏幕包围盒 -// 会被高分屏拉大,低分屏上选区已触本屏底边仍会被误判为"下方放得下"而不翻转。 -bool GetMonitorBoundsForRect(const RECT& refAbs, RECT& out); -void CalcPopupPlacement(const RECT& toolbarRect, int virtualX, int virtualY, - int virtualW, int virtualH, const SCPopupMetrics& m, int pw, int ph, RECT& out); -void CalcPopupPosition(const RECT& toolbarRect, int virtualX, int virtualY, - int virtualW, int virtualH, const SCPopupMetrics& m, RECT& out); -void CalcMosaicPopupSize(const SCPopupMetrics& m, int& outW, int& outH); -int HitTestMosaicPopup(int x, int y, const RECT& popupRect, const SCPopupMetrics& m); -void DrawMosaicPopup(HDC hdc, const RECT& popupRect, int modeIdx, int sizeIdx, int radiusIdx, const SCPopupMetrics& m); -int HitTestPopup(int x, int y, const RECT& popupRect, const SCPopupMetrics& m); -void DrawPopup(HDC hdc, const RECT& popupRect, int colorIdx, int firstIdx, bool isTextTool, const SCPopupMetrics& m); -void PushAnnotationHistory(CaptureContext* ctx); -bool UndoAnnotations(CaptureContext* ctx); -bool RedoAnnotations(CaptureContext* ctx); -bool InitGdipResources(CaptureContext* ctx); -Gdiplus::Font* GetGdipFont(CaptureContext* ctx, int fontPx); -double GetDpiScaleFactor(); -BOOL CALLBACK MonitorEnumProc(HMONITOR hMonitor, HDC hdcMonitor, LPRECT lprcMonitor, LPARAM dwData); -bool CreateBackBuffer(HDC& outDC, HBITMAP& outBmp, int w, int h); -bool PrimeScreenshotFrameNow(); -bool AcquireScreenshotBase(HDC& outMemDC, HBITMAP& outBitmap, int& vx, int& vy, int& vw, int& vh, double& dpiScale); -COLORREF GetPixelColorFromBitmap(HDC memDC, int x, int y, int vx, int vy, double dpiScale); -void ColorrefToStrings(COLORREF color, char* hexBuf, char* rgbBuf); -std::vector EnumWindowsForCapture(); -int FindWindowAtPoint(const std::vector& windows, int x, int y); -void CalcPanelPosition(int mx, int my, int vx, int vy, int vw, int vh, const SCPanelMetrics& m, int& px, int& py); -void CalcResizePanelPosition(int handle, const RECT& sel, int vx, int vy, int vw, int vh, const SCPanelMetrics& m, int& px, int& py); -void RestoreDirtyRegion(HDC backDC, HDC memDC, const RECT& dirty, double dpiScale); -RECT InflateRectBy(const RECT& r, int margin); -bool IsValidRect(const RECT& r); -RECT UnionRectSafe(const RECT& a, const RECT& b); -void DrawInfoPanel(HDC hdc, int panelX, int panelY, COLORREF color, HDC memDC, int vx, int vy, int mx, int my, double dpiScale, const SCGdiResources& gdi, const SCPanelMetrics& m, int virtualW, int virtualH); -RECT DrawSizeLabel(HDC hdc, int width, int height, int refLeft, int refTop, int refRight, int refBottom, int virtualW, int virtualH, const SCGdiResources& gdi, const SCPanelMetrics& m); -RECT DrawSelection(HDC hdc, int x1, int y1, int x2, int y2, int vx, int vy, int vw, int vh, const SCGdiResources& gdi, const SCPanelMetrics& m); -void DrawWindowHighlight(HDC hdc, const RECT& rect, int vx, int vy, const SCGdiResources& gdi); -void DrawDimMask(HDC backDC, const SCGdiResources& gdi, int selLeft, int selTop, int selRight, int selBottom, int virtualW, int virtualH, int radius); -RECT NormalizeRect(const RECT& r); -bool PointInRect(int x, int y, const RECT& r); -int HitTestCornerRadiusHandle(int x, int y, const RECT& sel, int handleSize, int inset, int radius); -int FindNearestCornerRadiusHandle(int x, int y, const RECT& sel, int handleSize, int inset, int radius, int proximityMargin); -RECT CornerHandleDirtyRect(const CaptureContext* ctx, int corner); -int HitTestHandle(int x, int y, const RECT& sel, int handleSize); -LPCWSTR HandleCursor(int handle); -void CalcToolbarPosition(const RECT& selRel, int virtualX, int virtualY, - int virtualW, int virtualH, const SCToolbarMetrics& m, RECT& out); -int HitTestToolbar(int x, int y, const RECT& toolbarRect, const SCToolbarMetrics& m); -void DrawResizeHandles(HDC hdc, const RECT& selRel, int handleSize); -void DrawCornerRadiusHandle(HDC hdc, const RECT& selRel, int handleSize, int inset, int radius, int corner); -void DrawConfirmedBorder(HDC hdc, const RECT& selRel, const SCGdiResources& gdi, int radius); -void AddRoundedRect(Gdiplus::GraphicsPath& outPath, int x, int y, int w, int h, int radius); -void DrawToolbar(HDC hdc, const RECT& toolbarRect, int hoverBtn, int activeTool, const SCGdiResources& gdi, const SCToolbarMetrics& m, const SCIconCache& icons); -// 工具栏 title 式 tooltip:Tick 由会话空闲循环轮询(维护停顿/显示状态并失效气泡矩形), -// Draw 在 OnPaint 工具栏之后调用(气泡画进 backDC,盖在工具栏/子菜单之上)。 -void TickToolbarTooltip(CaptureContext* ctx, HWND overlayWnd); -void DrawToolbarTooltip(HDC hdc, CaptureContext* ctx); -bool MosaicBlitRect(HDC targetDC, HDC srcDC, int dstX0, int dstY0, int dstW, int dstH, int srcAbsX0, int srcAbsY0, int blockPx, int virtualX, int virtualY, double dpiScale); -void FreeMosaicBase(CaptureContext* ctx); -void InitMosaicBrushCursors(CaptureContext* ctx); -void FreeMosaicBrushCursors(CaptureContext* ctx); -bool RebuildMosaicBase(CaptureContext* ctx); -bool MosaicBaseNeedsRebuild(const CaptureContext* ctx); -bool HasMosaicToRender(const std::vector& annotations, const Annotation* curDrawing); -void RevealMosaicToTarget(HDC targetDC, HDC mosaicBase, const std::vector& annotations, const Annotation* curDrawing, const RECT& contentBounds, float ox, float oy); -void DrawAnnotations(HDC hdc, const RECT& selRel, int virtualX, int virtualY, const std::vector& annotations, const Annotation* curDrawing); -void CompositeAnnotations(HDC finalDC, HDC srcDC, const std::vector& annotations, const RECT& rect, int virtualX, int virtualY, double dpiScale, int mosaicBlockPx); -std::string BitmapToBase64Png(HBITMAP hBitmap); -bool SaveBitmapToClipboard(HBITMAP hBitmap); -// 将 HBITMAP 编码为 PNG:可选产出 base64 / 原始字节,可选直接写入文件(单次编码)。 -bool EncodeHBitmapPng(HBITMAP hBitmap, std::string* base64Out, std::string* rawOut, - const wchar_t* filePath); -// 将 HALFTONE 缩放模式设置到目标 DC(output_windows.cpp / long_capture_windows.cpp 共用)。 -// HALFTONE 在做下采样缩放时比默认 COLORONCOLOR 质量更好,但 BrushOrg 会被 StretchBlt 用到, -// 因此同时把画刷原点复位到 (0,0) 避免抖动(MSDN 推荐配套调用)。 -void SetHalftoneStretchMode(HDC dc); - -ScreenshotResult* ExtractRegionResult(HDC memDC, const RECT& rect, int vx, int vy, - double dpiScale, const std::vector& anns, int radius, int mosaicSizeIdx); -// 统一的 ScreenshotResult 发射口(CR-017):字段参数化构造结果并经截图会话 TSFN 回传 JS。 -// 内部统一处理守卫:TSFN 未就绪或 napi_tsfn_nonblocking 因队列满返回非 napi_ok 时, -// 自行 delete 分配的 result 防泄漏(CallScreenshotJs 只在成功入队时才 delete)。 -// 仅允许在截图线程内调用。各发射点(确认/取消/保存/ESC/长截图完成等)统一走此函数。 -// success=false 时坐标/尺寸/base64 全置 0/空(与取消路径语义一致)。 -void EmitScreenshotResult(bool success, int x = 0, int y = 0, int x2 = 0, int y2 = 0, - int width = 0, int height = 0, const std::string& base64 = ""); -// 会话初始化失败快速回传:构造 {success:false} 结果并经 EmitScreenshotResult 回传 JS, -// 唤醒 await 方避免永久挂起(CR-002 早退路径统一收口点)。 -void FailFast(); -void BeginLongCapture(CaptureContext* ctx, HWND overlayHwnd); -bool RunLongCapture(LongCaptureContext* c); -// 单帧「识别→offset 校验→(Weak 档)延迟确认→提交」管线(RunLongCapture 采样流程与单元测试共用入口): -// Normal 档识别 SUCCESS 且 offset 通过历史合理性校验即提交;Weak 档(低重叠大跳变)首次可信候选 -// 只登记 pendingMatch(WeakPending),下一次稳定采样独立复现一致候选才提交(Stitched)。 -// 任何拒绝(Failed/WeakPending/WeakRejected/Unstable)都不修改累计拼接状态。 -// allowStabilityGate:进入正式 DetectMatch 前启用轻量帧稳定性闸门(采样主循环传入 -// true;默认 false 保持单元测试/合成帧直连注入的旧行为完全不变)。闸门未过时返回 -// Unstable——本帧不匹配、不提交、不改任何状态,调用方短延迟后重新采样。 -LCSampleOutcome LongCaptureTryStitch(LongCaptureContext* c, std::vector& curr, int dir, - bool allowStabilityGate = false); -void LongCaptureAbort(); -std::wstring PromptSaveFilePath(HWND hwndOwner); -bool SaveRegionToPngFile(HDC memDC, const RECT& rect, int vx, int vy, double dpiScale, - const std::vector& anns, const std::wstring& filePath, int radius, int mosaicSizeIdx); -void ClampCornerRadius(CaptureContext* ctx); -bool CalcAnnotationsBounds(std::vector& anns, RECT& out, HDC hdc); -RECT MeasureTextAnnotation(HDC hdc, Annotation& a); -int HitTestTextAnnotations(std::vector& anns, int x, int y, HDC hdc); -RECT MeasureAnnotationBounds(Annotation& a, HDC hdc); -int HitTestAnnotation(std::vector& anns, int x, int y, HDC hdc); -int HitTestAnnotationResizeHandle(const Annotation& a, int x, int y, HDC hdc, int handleSize); -void TransformAnnotationByBox(Annotation& a, const RECT& oldBox, const RECT& newBox); -void MeasureTextGdip(HDC hdc, const std::wstring& text, int fontPx, float& outOffsetX, float& outOffsetY, float& outW, float& outH); -int CalcCaretPosFromMouse(HDC hdc, const std::wstring& text, int fontPx, int textX, int mouseX); -void InvalidateAnnotationOp(HWND hwnd, CaptureContext* ctx, const RECT& curBox); -RECT CalcSelectionDirty(CaptureContext* ctx, bool includeToolbar); -void InvalidateTextLine(HWND hwnd, CaptureContext* ctx); - -// ---- 选区状态机辅助(实现在 wndproc_windows.cpp,消息处理共用)---- -void EnterConfirmed(CaptureContext* ctx, const RECT& sel); -RECT ResizeSelectionFromHandle(const RECT& startSelection, int handle, int dx, int dy, - const RECT& virtualBounds, - bool hasContent, const RECT& contentBounds, - bool enforceMinSize); -void GetResizeHandleAnchor(int handle, const RECT& sel, int& ax, int& ay); -void ApplyResizeSelection(HWND hwnd, CaptureContext* ctx); -bool HandleSelectionNudgeKey(HWND hwnd, CaptureContext* ctx, WPARAM vk); - -// ---- 覆盖层消息处理(实现在 overlay_paint/overlay_input,由 WndProc 分发)---- -LRESULT OnPaint(HWND hwnd, CaptureContext* ctx); -LRESULT OnLButtonDown(HWND hwnd, CaptureContext* ctx); -LRESULT OnMouseMove(HWND hwnd, CaptureContext* ctx); -LRESULT OnLButtonUp(HWND hwnd, CaptureContext* ctx); -LRESULT OnKeyDown(HWND hwnd, WPARAM wParam, CaptureContext* ctx); -LRESULT OnImeComposition(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam, CaptureContext* ctx); -LRESULT OnChar(HWND hwnd, WPARAM wParam, CaptureContext* ctx); -LRESULT OnSetCursor(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam, CaptureContext* ctx); - -LRESULT CALLBACK ScreenshotOverlayWndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam); -void ScreenshotCaptureThread(); diff --git a/src/screenshot/long_capture_internal.h b/src/screenshot/long_capture_internal.h deleted file mode 100644 index 562cfeb..0000000 --- a/src/screenshot/long_capture_internal.h +++ /dev/null @@ -1,312 +0,0 @@ -// 长截图子系统内部头:LongCaptureContext 及其子结构、子系统内共享的常量与 -// 跨文件函数声明(供 lc_match_core / lc_stitch_state / lc_frame_io / lc_panel_ui / -// lc_toolbar_ui / lc_session 六个拆分块共用,不对外暴露)。 -// -// 本头是 long_capture_windows.cpp 二次拆分(CR-021)的产物:原 LongCaptureContext -// ~160 字段 God-struct 按「拼接累计状态 / 裁剪状态 / 视觉跟踪状态 / 工具栏 UI 状态」 -// 四职责拆为 StitchState / CropState / TrackState / LcUiState 子结构;会话级配置、 -// 屏幕采样参数、控制信号与输出结果保留为 LongCaptureContext 的直接成员。 -// 拆分为纯移动不改逻辑:子结构仅按字段原用途分组,所有字段类型/默认值/语义完全不变, -// 跨文件函数原 static 需跨块共享者改为非 static 并在此声明。 -// -// 包含约定:本头依赖 internal.h 已定义的 LongMatchData / LongMatchOutcome / -// LongCapturePendingMatch / LongCaptureFrameHistory / LCMenuKind / CaptureContext 等 -// 类型与全局声明,故各拆分块应先包含 internal.h 再包含本头。 - -#pragma once - -#include "internal.h" - -// ==================== 长截图子系统内共享类型(原 .cpp 文件内 static struct,跨块共享者)==================== - -// 轻量位移先验(滚轮累计增量 + 在线 px/notch 估计,仅用于候选排序加分与 tentative 预测幅度)。 -struct LongCaptureOffsetPrior { - bool valid = false; - int expectedAbsOffset = 0; // 期望 |d|(px);仅用于排序加分与 tentative 预测幅度 -}; - -// Weak 时间一致性上下文(短时序列共识,只给弱档置信度加分,不改任何验收门槛)。 -struct LCWeakTemporal { - bool active = false; - int refOffset = 0; // 最近弱候选簇中位数(带符号) - float bonus = 0.0f; // 共识加分(≤ LC_WEAK_TEMPORAL_BONUS_MAX) -}; - -// 全宽重叠区富验证证据:overall/seam/三段分布/连续段/profile/edge/纹理占比/动态屏蔽行数。 -struct LCOverlapEvidence { - bool valid = false; - float overall = 0.0f; // 全区加权匹配率 - float seam = 0.0f; // 接缝窗加权匹配率 - float part[3] = {0.0f, 0.0f, 0.0f}; // top/middle/bottom 匹配率(-1 = 该段无有效行) - float spatial = 0.0f; // 空间一致性:多段有证据取「最差段与均值折中」,单段打折 - float continuity = 0.0f; // 连续性 = 0.6×最长连续段 + 0.4×匹配率 − 断点罚 - float longestRunRatio = 0.0f; // 最长连续匹配段占有效行比例 - int gapCount = 0; // 匹配→失配跳变次数(匹配分布的碎片度) - float profileScore = 0.0f; // 4/8 行聚合 profile 相关度均值 - float edgeScore = 0.0f; // 行边缘结构强度相关度 - float textureRatio = 0.0f; // 重叠区有效纹理行占比(未屏蔽有效行 / 重叠行数): - // 大面积纯色重叠的证据量计价,进入综合置信度 - int dynamicMaskedRows = 0; // 被识别为动态变化区而从统计中剔除的行数 - //(受单段行数与总量占比双重硬上限,见常量块) -}; - -// 单 ROI 候选(Top-N 证据之一):只携带该列区域在此位移处的整段匹配率。 -struct LCBandCandidate { - int d = 0; // 候选位移(含符号) - float overall = 0.0f; // 该 ROI 限定列区间的整段加权匹配率 - bool strict = false; // 是否达到严格档验收(否则为宽松档) -}; - -// 多跳匹配恢复结果:matched 为真时 offset 是基准帧 H → 当前帧的已验证位移。 -struct LCMultihopResult { - bool matched = false; - bool hopCommitted = false; // 基准为已提交帧(位置精确,可推导提交位移) - LongCaptureMatchMode mode = LongCaptureMatchMode::Normal; - int offset = 0; // 已验证的 H→curr 位移(带符号) - float confidence = 0.0f; // 该匹配的综合置信度 - int64_t hopContentY = 0; // 基准帧视口顶(内容坐标) - int hopFrameId = -1; // 基准帧采样序号(日志关联) -}; - -// 失败帧的 TrackingEstimate(只服务 tentative 跟踪与下一帧先验,绝不进入提交)。 -struct LongCaptureTrackingEstimate { - bool valid = false; - int direction = 0; // +1 向下滚 / -1 向上滚 - double predictedOffset = 0.0; // 预计 |位移|(px) - double confidence = 0.0; // 低置信度(纯预测无视觉验证) -}; - -// ==================== 长截图子系统内共享常量(原 long_capture_windows.cpp 文件级 static)==================== -// 这些常量原本为单文件 static,拆分后需跨 lc_* 块共享,改为非 static 在此集中声明, -// 定义保留在对应块(算法调参 → lc_match_core.cpp)。 - -// 全位移搜索的采样列上限与单列容忍差(量化灰度容差行匹配)。 -extern const int LONG_MATCH_MAX_COLS; -extern const int LONG_MATCH_TOL; - -// 全位移扫描(探针粗筛→候选峰收集)参数。 -extern const int LC_SCAN_PROBES; -extern const int LC_SCAN_MIN_WEIGHT; -extern const int LC_PEAK_WIN; -extern const int LC_MAX_CANDIDATES; - -// 最小可信重叠(绝对下限 + 视口高比例)。 -extern const int LC_MIN_OVERLAP; -extern const float LC_MIN_OVERLAP_RATIO; - -// 跨 ROI 位移一致性容差。 -extern const int LC_ROI_OFFSET_TOLERANCE; - -// 综合置信度下限与 offset 跳变比率校验参数。 -extern const float LC_MIN_CONFIDENCE; -extern const float LC_OFFSET_JUMP_RATIO; -extern const int LC_OFFSET_HISTORY_LEN; -extern const int LC_OFFSET_HISTORY_MIN; - -// 采样尝试总数与重试间隔档。 -extern const int LC_SAMPLE_ATTEMPTS; -extern const int LC_RETRY_DELAY_NORMAL[]; // 长度 = LC_SAMPLE_ATTEMPTS - 1 -extern const int LC_RETRY_DELAY_WEAK[]; // 长度 = LC_WEAK_RETRY_ATTEMPTS - -// 帧稳定性检测参数。 -extern const DWORD LC_STABLE_REF_MAX_GAP; -extern const float LC_STABLE_CHANGED_ROW_FRAC; -extern const int LC_STABLE_MIN_WEIGHT; -extern const int LC_STABLE_MAX_WAITS; -extern const int LC_STABLE_RETRY_DELAY[]; // 长度 = LC_STABLE_MAX_WAITS - -// 瞬态快重采样参数。 -extern const int LC_QUICK_RESAMPLES; -extern const int LC_RESAMPLE_DELAY_QUICK[]; // 长度 = LC_QUICK_RESAMPLES - -// 滚动中主动采样最大间隔与到底确认采样数。 -extern const int LC_SCROLL_SAMPLE_MAX_GAP; -extern const int LC_BOTTOM_CONFIRM_SAMPLES; - -// Weak(低重叠大跳变)匹配参数。 -extern const int LC_WEAK_MIN_OVERLAP; -extern const float LC_WEAK_MIN_OVERLAP_RATIO; -extern const int LC_WEAK_ROI_TOLERANCE; -extern const float LC_WEAK_ACCEPT_OVERALL; -extern const float LC_WEAK_ACCEPT_SEAM; -extern const float LC_WEAK_ACCEPT_EDGE; -extern const float LC_WEAK_MIN_CONFIDENCE; -extern const float LC_WEAK_CONF_EXTRA_PENALTY; -extern const float LC_WEAK_OVERLAP_RATIO_LIMIT; -extern const int LC_WEAK_CONFIRM_OFFSET_TOL; -extern const int LC_WEAK_RETRY_ATTEMPTS; -extern const int LC_WEAK_MAX_TRIES; - -// 滚轮 delta 软先验加分。 -extern const float LC_WHEEL_PRIOR_BONUS; - -// Tentative 视觉跟踪 / 多跳恢复 / Weak 时间一致性参数。 -extern const int LC_HISTORY_FRAMES; -extern const int LC_HISTORY_HOPS; -extern const int LC_TRACK_FREEZE_FRAMES; -extern const int LC_TRACK_MIN_STEP; -extern const float LC_TRACK_PREDICT_CONFIDENCE; -extern const float LC_TRACK_CONFIDENCE_DECAY; -extern const int LC_WEAK_TEMPORAL_TOL; -extern const int LC_WEAK_TEMPORAL_MIN_SAMPLES; -extern const int LC_WEAK_TEMPORAL_MAX_HISTORY; -extern const float LC_WEAK_TEMPORAL_BONUS_MAX; - -// 接缝窗行数与全量验证有效权重下限。 -extern const int LC_SEAM_ROWS; -extern const int LC_VERIFY_MIN_WEIGHT; - -// 纹理置信度折扣参数。 -extern const float LC_TEXTURE_CONF_FLOOR; -extern const float LC_TEXTURE_CONF_SCALE; - -// 验收阈值(严格档 / 宽松档)。 -extern const float LC_ACCEPT_STRICT_OVERALL; -extern const float LC_ACCEPT_STRICT_SEAM; -extern const float LC_ACCEPT_LOOSE_OVERALL; -extern const float LC_ACCEPT_LOOSE_SEAM; - -// 方向罚与歧义裕度。 -extern const float LC_DIR_PENALTY; -extern const float LC_AMBIGUITY_MARGIN; - -// ROI 证据融合参数。 -extern const int LC_BAND_TOP_N; -extern const int LC_MAX_VERIFY_CANDIDATES; -extern const float LC_ROI_MIN_INFO_WEIGHT; -extern const int LC_BAND_MIN_COLS; -extern const float LC_PEAK_SEP_FULL; -extern const float LC_AMBIGUITY_CONF_PENALTY; -extern const float LC_CONTINUITY_GAP_MAX; - -// 动态变化区局部降权参数。 -extern const int LC_DYNAMIC_MIN_ROWS; -extern const int LC_DYNAMIC_MAX_ROWS; -extern const int LC_DYNAMIC_BRIDGE; -extern const float LC_DYNAMIC_MAX_FRACTION; - -// offset basin 合并半径。 -extern const int LC_BASIN_RADIUS; - -// Weak 档额外证据下限。 -extern const float LC_WEAK_ACCEPT_SPATIAL; -extern const float LC_WEAK_ACCEPT_CONTINUITY; -extern const float LC_WEAK_ACCEPT_PROFILE; -extern const float LC_WEAK_MIN_PEAK_SEP; - -// 跨文件的常量:原定义归属不同块但被另一块使用,故在此声明为 extern。 -// LC_TRACK_MIN_STEP 定义于 lc_stitch_state.cpp(跟踪逻辑用),panel UI 也读它判定 tentative 框显示。 -extern const int LC_TRACK_MIN_STEP; -// LC_AUTOSCROLL_STOP_FAILS 定义于 lc_toolbar_ui.cpp(自动滚动参数),session 主循环读它决定自动停止。 -extern const int LC_AUTOSCROLL_STOP_FAILS; - -// ==================== 二级常量(原文件级 static,归各使用块)==================== - -// 蒙版样式(预乘 ARGB):整屏半透明灰,采样裁剪区整透明透出实况桌面。 -// 定义于 lc_panel_ui.cpp(蒙版绘制唯一使用方)。 -extern const int LONG_MASK_GRAY; -extern const BYTE LONG_MASK_ALPHA; - -// 面板布局常量(逻辑像素):面板总宽与内边距被 toolbar(缩略图列宽)与 session(缩略图列宽) -// 共用,故在此声明为 extern;定义于 lc_panel_ui.cpp。 -extern const int LC_PANEL_W; -extern const int LC_PANEL_PAD; - -// 拼接总像素上限(超过即自动完成,防内存雪崩)。定义于 lc_session.cpp。 -extern const long long LONG_CAPTURE_MAX_PIXELS; - -// ==================== 长截图子系统内跨文件函数声明 ==================== - -// 失败分类名(LCFailReason → 日志字符串,仅可观测性用)。定义于 lc_match_core.cpp。 -const char* LcFailReasonName(LCFailReason r); - -// —— 纯算法(lc_match_core.cpp)—— -void LongCaptureBuildMatchData(const std::vector& frame, int w, int h, LongMatchData& m); -bool LongCaptureRowMatchesRange(const uint8_t* a, const uint8_t* b, int c0, int c1); -bool LongCaptureRowMatches(const uint8_t* a, const uint8_t* b, int cols); -LongMatchOutcome LongCaptureDetectMatch(const LongMatchData& prevM, const LongMatchData& currM, - int dir, const LongCaptureOffsetPrior& prior, - const LCWeakTemporal& wt, int logId); -LongMatchOutcome LongCaptureDetectPass(const LongMatchData& prevM, const LongMatchData& currM, - int dir, const LongCaptureOffsetPrior& prior, bool weak, - const LCWeakTemporal& wt, int logId); -bool LongCaptureVerifyCandidate(const LongMatchData& prevM, const LongMatchData& currM, - int d, LCOverlapEvidence& ev); -float LongCaptureWeakRequiredConfidence(int viewportH, int overlap); - -// —— 可写累计状态层(lc_stitch_state.cpp)—— -LongCaptureOffsetPrior LongCaptureBuildOffsetPrior(const LongCaptureContext* c); -LCWeakTemporal LongCaptureWeakTemporalContext(const LongCaptureContext* c); -void LongCapturePushWeakCandidate(LongCaptureContext* c, int offset); -bool LongCaptureOffsetPlausible(const LongCaptureContext* c, int d, - LongCaptureMatchMode mode = LongCaptureMatchMode::Normal); -LCMultihopResult LongCaptureMultihopDetect(const LongCaptureContext* c, - const LongMatchData& currMatch, int dir, - const LongCaptureOffsetPrior& prior); -LongCaptureTrackingEstimate LongCaptureBuildTrackingEstimate(const LongCaptureContext* c, int dir, - const LongCaptureOffsetPrior& prior); -int64_t LongCaptureTrackingDriftLimit(const LongCaptureContext* c); -void LongCaptureTrackingSetVisual(LongCaptureContext* c, int64_t contentY, float confidence); -void LongCaptureTrackingResetToCommitted(LongCaptureContext* c); -void LongCaptureTrackingAdvancePredicted(LongCaptureContext* c, int direction, double magnitude); -void LongCaptureHistoryPush(LongCaptureContext* c, int frameId, LongMatchData&& match, - int64_t contentY, bool committed); -void LongCaptureAfterCommit(LongCaptureContext* c, int frameId); -int LongCaptureCommitStitch(LongCaptureContext* c, std::vector& curr, - LongMatchData& currMatch, int d); -void LongCaptureUpdateWheelEstimate(LongCaptureContext* c, int d); -bool LongCaptureFrameUnstable(LongCaptureContext* c, const LongMatchData& curr); -void LongCaptureUpdateStabilityRef(LongCaptureContext* c, const LongMatchData& curr); -bool LongCaptureHasCropConstraint(const LongCaptureContext* c); -void LongCaptureEraseDisplayRows(LongCaptureContext* c, int64_t r0, int64_t r1); -void LongCaptureExecuteCropPurge(LongCaptureContext* c, bool below); -// LongCaptureTryStitch 已在 internal.h 声明(供单元测试共用),此处不重复。 - -// —— 抓帧 / DIB / 缩略图 / 位图构建 / 消息泵(lc_frame_io.cpp)—— -void LongCaptureDownscaleRow(const uint32_t* src, uint32_t* dst, int srcW, int dstW); -void LongCaptureOutputRows(const LongCaptureContext* c, int& outTop, int& outBottom); -bool LongCaptureRegisterWheelObserver(HWND target); -void LongCaptureUnregisterWheelObserver(); -bool LongCaptureEnsureDib(LongCaptureContext* c, HDC screenDC); -bool LongCaptureCaptureFrameBuf(LongCaptureContext* c, std::vector& out); -void LongCaptureRebuildThumb(LongCaptureContext* c); -void LongCaptureRebuildThumbDisplay(LongCaptureContext* c); -HBITMAP LongCaptureBuildResultBitmap(LongCaptureContext* c); -void LongCapturePumpMessages(LongCaptureContext* c); -bool LongCaptureInitFirstFrame(LongCaptureContext* c, std::vector& frameBuf); -void LongCaptureInitBaseline(LongCaptureContext* c, std::vector& frame); -HBITMAP LongCaptureBuildFinalBitmap(LongCaptureContext* c); - -// —— 面板 / 蒙版 UI(lc_panel_ui.cpp)—— -bool EnsureArgbSurface(HDC& dc, HBITMAP& bmp, void*& bits, int& w, int& h, int wantW, int wantH); -void FreeArgbSurface(HDC& dc, HBITMAP& bmp, void*& bits, int& w, int& h); -void LongCapturePanelRender(HWND panel, LongCaptureContext* c); -LRESULT CALLBACK LongCapturePanelWndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp); -void LongCapturePanelUpdate(LongCaptureContext* c); -HWND LongCaptureCreatePanel(CaptureContext* ctx, LongCaptureContext* c); -LRESULT CALLBACK LongCaptureMaskWndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp); -RECT CalcSampleCrop(const CaptureContext* ctx); -void EnterLongCaptureMask(const CaptureContext* ctx); - -// —— 工具栏 UI(lc_toolbar_ui.cpp)—— -void LongCaptureToolbarRender(LongCaptureContext* c, int dstX, int dstY, int w, int h); -void LongCaptureToolbarRepaint(); -LRESULT CALLBACK LongCaptureToolbarWndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp); -HWND LongCaptureCreateToolbar(CaptureContext* ctx, LongCaptureContext* c); -void LongCaptureSetMenu(LongCaptureContext* c, LCMenuKind kind); -void LongCaptureResetSession(LongCaptureContext* c); -void LongCaptureSwitchDirection(LongCaptureContext* c); -void LongCaptureSetAutoScroll(LongCaptureContext* c, bool on); -void LongCaptureApplyCrop(LongCaptureContext* c, int row); -// 临时摘除/恢复长截图窗口组的置顶(保存对话框等系统弹窗需要真正置顶)。 -// 定义于 lc_toolbar_ui.cpp,session 主循环弹保存对话框前后调用。 -void LongCaptureSetTopmost(bool topmost); - -// —— 会话主循环 / 生命周期(lc_session.cpp)—— -void LongCaptureEmitFailure(); -void LongCaptureWaitMessages(LongCaptureContext* c, DWORD ms); -// 释放采样 DIB 段:把原 wndproc_windows.cpp 里手动释放 lc->dibDC/dibBmp 的跨界所有权 -// 收进此处,由 DestroyLongCaptureContext 统一释放(CR-021)。 -// 注:会话级窗口(panel/toolbar/mask)销毁仍由捕获线程清理段调用, -// 本函数只负责 DIB 资源的归口释放。 -void DestroyLongCaptureContext(LongCaptureContext* lc); diff --git a/src/screenshot/macos/LCBridgeMac.swift b/src/screenshot/macos/LCBridgeMac.swift new file mode 100644 index 0000000..42bc3b8 --- /dev/null +++ b/src/screenshot/macos/LCBridgeMac.swift @@ -0,0 +1,679 @@ +import Foundation + +// MARK: - 长截图算法层 Swift 封装 +// +// 职责:对 src/screenshot/lc_bridge_mac.h 的 C ABI 提供 Swift 侧声明(@_silgen_name)与 +// 安全封装——C 布局结构体镜像、BGRA 帧数据拷贝与尺寸校验、七值结局/证据结构转换、 +// handle 生命周期守护(deinit 统一销毁)。算法层本体为 lc_match_core.cpp / +// lc_stitch_state.cpp(构建期 clang++ 编出 .o,随 swiftc -emit-library 链入 +// libZToolsNative.dylib,见 scripts/build-swift.sh)。 +// +// 设计约束:本文件仅依赖 Foundation、不引用模块内其他 Swift 文件—— +// scripts/test-lc-mac.sh 会把本文件与 tests/lc_algorithm_test.swift 一起编译成 +// 独立测试可执行(与 dylib 共用同一批算法层 .o,对象文件分目录避免重复符号)。 +// C 结构体镜像与 lc_bridge_mac.h 逐字段同序同型(全部固定宽度标量,自然对齐无隐藏 +// padding 差异);lc_bridge_mac.h 布局变更时必须同步本文件(各镜像处有标注)。 + +// MARK: - C ABI 枚举镜像(与 lc_bridge_mac.h 宏逐值对齐) + +/// 单帧采样七值结局(镜像 LC_OUTCOME_*,对齐 internal.h 的 LCSampleOutcome)。 +enum LCSampleOutcome: Int32 { + case stitched = 0 // 已提交且新增拼接行(唯一扩展累计内容的结局) + case repositioned = 1 // 已提交但仅视口重定位(反向回滚,无新增行) + case noChange = 2 // 内容未滚动(全同帧 / 匹配成功 d=0) + case weakPending = 3 // Weak 候选首次成立:只登记待复核,未提交 + case weakRejected = 4 // Weak 候选被复核否决 + case unstable = 5 // 稳定性闸门未过(仅启用闸门时可能出现) + case failed = 6 // 硬失败;已提交状态未被触碰(状态层铁律) +} + +/// 失败原因分类(镜像 LC_FAIL_*,对齐 internal.h 的 LCFailReason)。 +enum LCFailReason: Int32 { + case none = 0 + case noCandidate = 1 + case candidateWeak = 2 + case peakAmbiguous = 3 + case globalMismatch = 4 + case seamMismatch = 5 + case spatialMismatch = 6 + case continuityMismatch = 7 + case profileMismatch = 8 + case roiInconsistent = 9 + case offsetImplausible = 10 + case directionConflict = 11 + case frameUnstable = 12 +} + +/// 匹配档位(镜像 LC_MODE_*,对齐 LongCaptureMatchMode)。 +enum LCMatchMode: Int32 { + case normal = 0 + case weakOverlap = 1 +} + +/// 匹配三值结论(镜像 LC_MATCH_STATUS_*,对齐 LongMatchStatus)。 +enum LCMatchStatus: Int32 { + case failed = 0 + case lowConfidence = 1 + case success = 2 +} + +// MARK: - C 布局结构体镜像(字段顺序/类型与 lc_bridge_mac.h 严格一致) + +/// 会话创建配置(镜像 LCSessionConfig)。 +struct LCSessionConfigC { + var interval: Int32 = 0 + var physW: Int32 = 0 + var physH: Int32 = 0 + var thumbW: Int32 = 0 + var horizontal: Int32 = 0 +} + +/// 匹配证据快照(镜像 LCMatchEvidence,对齐 LongMatchOutcome 字段)。 +struct LCMatchEvidenceC { + var status: Int32 = 0 + var mode: Int32 = 0 + var failReason: Int32 = 0 + var offset: Int32 = 0 + var overlap: Int32 = 0 + var overall: Float = 0 + var seam: Float = 0 + var top: Float = 0 + var middle: Float = 0 + var bottom: Float = 0 + var spatial: Float = 0 + var continuity: Float = 0 + var profileScore: Float = 0 + var edgeCorrelation: Float = 0 + var peakGap: Float = 0 + var roiWeighted: Float = 0 + var confidence: Float = 0 + var textureRatio: Float = 0 + var validBandCount: Int32 = 0 + var agreeCount: Int32 = 0 + var bandOffsets: (Int32, Int32, Int32) = (0, 0, 0) + var bandValid: (Int32, Int32, Int32) = (0, 0, 0) +} + +/// 单帧喂入结果(镜像 LCTryStitchResult)。 +struct LCTryStitchResultC { + var outcome: Int32 = 0 + var failReason: Int32 = 0 + var addedRows: Int32 = 0 + var committedDelta: Int32 = 0 + var stitchH: Int32 = 0 + var sampleIndex: Int32 = 0 + var hasRejectEvidence: Int32 = 0 + var evidence: LCMatchEvidenceC = LCMatchEvidenceC() + var pendingValid: Int32 = 0 + var pendingOffset: Int32 = 0 + var pendingConfidence: Float = 0 +} + +/// 独立识别结果(镜像 LCDetectResult,字段与 LCMatchEvidence 同构)。 +struct LCDetectResultC { + var status: Int32 = 0 + var mode: Int32 = 0 + var failReason: Int32 = 0 + var offset: Int32 = 0 + var overlap: Int32 = 0 + var overall: Float = 0 + var seam: Float = 0 + var top: Float = 0 + var middle: Float = 0 + var bottom: Float = 0 + var spatial: Float = 0 + var continuity: Float = 0 + var profileScore: Float = 0 + var edgeCorrelation: Float = 0 + var peakGap: Float = 0 + var roiWeighted: Float = 0 + var confidence: Float = 0 + var textureRatio: Float = 0 + var validBandCount: Int32 = 0 + var agreeCount: Int32 = 0 + var bandOffsets: (Int32, Int32, Int32) = (0, 0, 0) + var bandValid: (Int32, Int32, Int32) = (0, 0, 0) +} + +/// 失败帧跟踪估计(镜像 LCTrackingEstimate,对齐 LongCaptureTrackingEstimate)。 +struct LCTrackingEstimateC { + var valid: Int32 = 0 + var direction: Int32 = 0 + var predictedOffset: Double = 0 + var confidence: Double = 0 +} + +/// 状态快照(镜像 LCStateSnapshot;字段顺序与 lc_bridge_mac.h 严格一致)。 +struct LCStateSnapshotC { + var physW: Int32 = 0 + var physH: Int32 = 0 + var horizontal: Int32 = 0 + var stitchH: Int32 = 0 + var headRows: Int32 = 0 + var bodyRows: Int32 = 0 + var committedContentTop: Int64 = 0 + var tentativeContentTop: Int64 = 0 + var tentativeValid: Int32 = 0 + var tentativeConfidence: Float = 0 + var trackUnreliableStreak: Int32 = 0 + var trackingRevision: Int32 = 0 + var sampleIndex: Int32 = 0 + var pendingValid: Int32 = 0 + var pendingOffset: Int32 = 0 + var pendingConfidence: Float = 0 + var pendingMode: Int32 = 0 + var lastFailReason: Int32 = 0 + var offsetHistoryLen: Int32 = 0 + var wheelAccumDelta: Int32 = 0 + var pixelsPerWheelNotch: Float = 0 + var thumbW: Int32 = 0 + var thumbHeadH: Int32 = 0 + var thumbH: Int32 = 0 + var cropTopY: Int64 = 0 + var cropBottomY: Int64 = 0 + var cropPendTop: Int32 = 0 + var cropPendBottom: Int32 = 0 + var cropPendTopLo: Int64 = 0 + var cropPendTopHi: Int64 = 0 + var cropPendBottomLo: Int64 = 0 + var cropPendBottomHi: Int64 = 0 + var cropped: Int32 = 0 + var interval: Int32 = 0 +} + +/// 算法层常量(镜像 LCAlgoConsts:重试梯/节拍/到底确认参数)。 +struct LCAlgoConstsC { + var sampleAttempts: Int32 = 0 + var retryDelayNormal: (Int32, Int32, Int32, Int32, Int32) = (0, 0, 0, 0, 0) + var weakRetryAttempts: Int32 = 0 + var retryDelayWeak: (Int32, Int32, Int32, Int32, Int32, Int32) = (0, 0, 0, 0, 0, 0) + var stableMaxWaits: Int32 = 0 + var stableRetryDelay: (Int32, Int32, Int32) = (0, 0, 0) + var quickResamples: Int32 = 0 + var resampleDelayQuick: (Int32, Int32) = (0, 0) + var scrollSampleMaxGap: Int32 = 0 + var bottomConfirmSamples: Int32 = 0 + var weakMaxTries: Int32 = 0 + var stableRefMaxGapMs: Int32 = 0 + var cropInsetLogical: Int32 = 0 + var trackMinStep: Int32 = 0 +} + +// MARK: - C 符号声明(实现为 lc_bridge_mac.cpp,随算法层 .o 链入 dylib/测试可执行) + +@_silgen_name("lc_abi_version") func lc_abi_version() -> Int32 +@_silgen_name("lc_session_create") func lc_session_create(_ config: UnsafePointer) -> UnsafeMutableRawPointer? +@_silgen_name("lc_session_destroy") func lc_session_destroy(_ h: UnsafeMutableRawPointer?) +@_silgen_name("lc_init_baseline") func lc_init_baseline(_ h: UnsafeMutableRawPointer?, _ bgra: UnsafePointer?, _ w: Int32, _ height: Int32) -> Int32 +@_silgen_name("lc_try_stitch") func lc_try_stitch(_ h: UnsafeMutableRawPointer?, _ bgra: UnsafePointer?, _ dir: Int32, _ allowStabilityGate: Int32, _ out: UnsafeMutablePointer?) -> Int32 +@_silgen_name("lc_detect_match") func lc_detect_match(_ prevBgra: UnsafePointer?, _ currBgra: UnsafePointer?, _ w: Int32, _ height: Int32, _ dir: Int32, _ priorValid: Int32, _ priorExpectedAbsOffset: Int32, _ out: UnsafeMutablePointer?) -> Int32 +@_silgen_name("lc_weak_required_confidence") func lc_weak_required_confidence(_ viewportH: Int32, _ overlap: Int32) -> Float +@_silgen_name("lc_fail_reason_name") func lc_fail_reason_name(_ failReason: Int32) -> UnsafePointer? +@_silgen_name("lc_get_state") func lc_get_state(_ h: UnsafeMutableRawPointer?, _ out: UnsafeMutablePointer?) -> Int32 +@_silgen_name("lc_get_offset_history") func lc_get_offset_history(_ h: UnsafeMutableRawPointer?, _ out: UnsafeMutablePointer?, _ cap: Int32) -> Int32 +@_silgen_name("lc_get_output_rows") func lc_get_output_rows(_ h: UnsafeMutableRawPointer?, _ outTop: UnsafeMutablePointer?, _ outBottom: UnsafeMutablePointer?) -> Int32 +@_silgen_name("lc_read_rows") func lc_read_rows(_ h: UnsafeMutableRawPointer?, _ rowStart: Int64, _ rowCount: Int64, _ outBuf: UnsafeMutablePointer?, _ outBufRows: Int64) -> Int64 +@_silgen_name("lc_read_thumb_rows") func lc_read_thumb_rows(_ h: UnsafeMutableRawPointer?, _ rowStart: Int64, _ rowCount: Int64, _ outBuf: UnsafeMutablePointer?, _ outBufRows: Int64) -> Int64 +@_silgen_name("lc_apply_crop") func lc_apply_crop(_ h: UnsafeMutableRawPointer?, _ row: Int32) -> Int32 +@_silgen_name("lc_has_crop_constraint") func lc_has_crop_constraint(_ h: UnsafeMutableRawPointer?) -> Int32 +@_silgen_name("lc_update_wheel_estimate") func lc_update_wheel_estimate(_ h: UnsafeMutableRawPointer?, _ d: Int32) +@_silgen_name("lc_accumulate_wheel_delta") func lc_accumulate_wheel_delta(_ h: UnsafeMutableRawPointer?, _ delta: Int32) +@_silgen_name("lc_push_weak_candidate") func lc_push_weak_candidate(_ h: UnsafeMutableRawPointer?, _ offset: Int32) +@_silgen_name("lc_abandon_weak_chain") func lc_abandon_weak_chain(_ h: UnsafeMutableRawPointer?) +@_silgen_name("lc_offset_plausible") func lc_offset_plausible(_ h: UnsafeMutableRawPointer?, _ d: Int32, _ mode: Int32) -> Int32 +@_silgen_name("lc_tracking_set_visual") func lc_tracking_set_visual(_ h: UnsafeMutableRawPointer?, _ contentY: Int64, _ confidence: Float) +@_silgen_name("lc_tracking_reset_to_committed") func lc_tracking_reset_to_committed(_ h: UnsafeMutableRawPointer?) +@_silgen_name("lc_tracking_advance_predicted") func lc_tracking_advance_predicted(_ h: UnsafeMutableRawPointer?, _ direction: Int32, _ magnitude: Double) +@_silgen_name("lc_build_tracking_estimate") func lc_build_tracking_estimate(_ h: UnsafeMutableRawPointer?, _ dir: Int32, _ out: UnsafeMutablePointer?) -> Int32 +@_silgen_name("lc_get_algo_consts") func lc_get_algo_consts(_ out: UnsafeMutablePointer?) + +// MARK: - Swift 侧友好类型(结果结构转换) + +/// 匹配证据(LongMatchOutcome 的 Swift 形态;字段语义见 internal.h)。 +struct LCMatchEvidence { + let status: LCMatchStatus + let mode: LCMatchMode + let failReason: LCFailReason + let offset: Int + let overlap: Int + let overall: Float + let seam: Float + let top: Float + let middle: Float + let bottom: Float + let spatial: Float + let continuity: Float + let profileScore: Float + let edgeCorrelation: Float + let peakGap: Float + let roiWeighted: Float + let confidence: Float + let textureRatio: Float + let validBandCount: Int + let agreeCount: Int + let bandOffsets: [Int] + let bandValid: [Bool] + + init(_ c: LCMatchEvidenceC) { + status = LCMatchStatus(rawValue: c.status) ?? .failed + mode = LCMatchMode(rawValue: c.mode) ?? .normal + failReason = LCFailReason(rawValue: c.failReason) ?? .none + offset = Int(c.offset) + overlap = Int(c.overlap) + overall = c.overall + seam = c.seam + top = c.top + middle = c.middle + bottom = c.bottom + spatial = c.spatial + continuity = c.continuity + profileScore = c.profileScore + edgeCorrelation = c.edgeCorrelation + peakGap = c.peakGap + roiWeighted = c.roiWeighted + confidence = c.confidence + textureRatio = c.textureRatio + validBandCount = Int(c.validBandCount) + agreeCount = Int(c.agreeCount) + bandOffsets = [Int(c.bandOffsets.0), Int(c.bandOffsets.1), Int(c.bandOffsets.2)] + bandValid = [c.bandValid.0 != 0, c.bandValid.1 != 0, c.bandValid.2 != 0] + } +} + +/// 单帧喂入结果(lc_try_stitch 的 Swift 形态)。 +struct LCTryStitchResult { + let outcome: LCSampleOutcome + let failReason: LCFailReason + let addedRows: Int // 仅 stitched 时 > 0 + let committedDelta: Int // committedContentTop 推进量(stitched/repositioned = 本帧位移) + let stitchH: Int // 调用后的拼接总行数 + let sampleIndex: Int + let hasRejectEvidence: Bool + let evidence: LCMatchEvidence // 拒绝证据快照(仅 FAILED/WEAK_REJECTED 有意义) + let pendingValid: Bool + let pendingOffset: Int + let pendingConfidence: Float + + init(_ c: LCTryStitchResultC) { + outcome = LCSampleOutcome(rawValue: c.outcome) ?? .failed + failReason = LCFailReason(rawValue: c.failReason) ?? .none + addedRows = Int(c.addedRows) + committedDelta = Int(c.committedDelta) + stitchH = Int(c.stitchH) + sampleIndex = Int(c.sampleIndex) + hasRejectEvidence = c.hasRejectEvidence != 0 + evidence = LCMatchEvidence(c.evidence) + pendingValid = c.pendingValid != 0 + pendingOffset = Int(c.pendingOffset) + pendingConfidence = c.pendingConfidence + } +} + +/// 独立识别结果(lc_detect_match 的 Swift 形态)。 +struct LCDetectResult { + let status: LCMatchStatus + let mode: LCMatchMode + let failReason: LCFailReason + let offset: Int + let overlap: Int + let overall: Float + let seam: Float + let spatial: Float + let continuity: Float + let profileScore: Float + let edgeCorrelation: Float + let peakGap: Float + let roiWeighted: Float + let confidence: Float + + init(_ c: LCDetectResultC) { + status = LCMatchStatus(rawValue: c.status) ?? .failed + mode = LCMatchMode(rawValue: c.mode) ?? .normal + failReason = LCFailReason(rawValue: c.failReason) ?? .none + offset = Int(c.offset) + overlap = Int(c.overlap) + overall = c.overall + seam = c.seam + spatial = c.spatial + continuity = c.continuity + profileScore = c.profileScore + edgeCorrelation = c.edgeCorrelation + peakGap = c.peakGap + roiWeighted = c.roiWeighted + confidence = c.confidence + } +} + +/// 算法层状态快照(LCStateSnapshot 的 Swift 形态;会话侧簿记字段不在此, +/// 见 lc_bridge_mac.h 的 LCStateSnapshot 注释)。 +struct LCState { + let physW: Int + let physH: Int + let horizontal: Bool + let stitchH: Int + let headRows: Int + let bodyRows: Int + let committedContentTop: Int64 + let tentativeContentTop: Int64 + let tentativeValid: Bool + let tentativeConfidence: Float + let trackUnreliableStreak: Int + let trackingRevision: Int + let sampleIndex: Int + let pendingValid: Bool + let pendingOffset: Int + let pendingConfidence: Float + let pendingMode: LCMatchMode + let lastFailReason: LCFailReason + let offsetHistoryLen: Int + let wheelAccumDelta: Int + let pixelsPerWheelNotch: Float + let thumbW: Int + let thumbHeadH: Int + let thumbH: Int + let cropTopY: Int64 + let cropBottomY: Int64 + let cropPendTop: Bool + let cropPendBottom: Bool + let cropPendTopLo: Int64 + let cropPendTopHi: Int64 + let cropPendBottomLo: Int64 + let cropPendBottomHi: Int64 + let cropped: Bool + let interval: Int + + init(_ c: LCStateSnapshotC) { + physW = Int(c.physW) + physH = Int(c.physH) + horizontal = c.horizontal != 0 + stitchH = Int(c.stitchH) + headRows = Int(c.headRows) + bodyRows = Int(c.bodyRows) + committedContentTop = c.committedContentTop + tentativeContentTop = c.tentativeContentTop + tentativeValid = c.tentativeValid != 0 + tentativeConfidence = c.tentativeConfidence + trackUnreliableStreak = Int(c.trackUnreliableStreak) + trackingRevision = Int(c.trackingRevision) + sampleIndex = Int(c.sampleIndex) + pendingValid = c.pendingValid != 0 + pendingOffset = Int(c.pendingOffset) + pendingConfidence = c.pendingConfidence + pendingMode = LCMatchMode(rawValue: c.pendingMode) ?? .normal + lastFailReason = LCFailReason(rawValue: c.lastFailReason) ?? .none + offsetHistoryLen = Int(c.offsetHistoryLen) + wheelAccumDelta = Int(c.wheelAccumDelta) + pixelsPerWheelNotch = c.pixelsPerWheelNotch + thumbW = Int(c.thumbW) + thumbHeadH = Int(c.thumbHeadH) + thumbH = Int(c.thumbH) + cropTopY = c.cropTopY + cropBottomY = c.cropBottomY + cropPendTop = c.cropPendTop != 0 + cropPendBottom = c.cropPendBottom != 0 + cropPendTopLo = c.cropPendTopLo + cropPendTopHi = c.cropPendTopHi + cropPendBottomLo = c.cropPendBottomLo + cropPendBottomHi = c.cropPendBottomHi + cropped = c.cropped != 0 + interval = Int(c.interval) + } +} + +/// 算法层常量(重试梯/节拍/到底确认;macOS 会话主循环按 lc_session_windows.cpp 同式取用)。 +struct LCAlgoConsts { + let sampleAttempts: Int + let retryDelayNormal: [Int] // 长度 = sampleAttempts - 1 + let weakRetryAttempts: Int + let retryDelayWeak: [Int] + let stableMaxWaits: Int + let stableRetryDelay: [Int] + let quickResamples: Int + let resampleDelayQuick: [Int] + let scrollSampleMaxGap: Int + let bottomConfirmSamples: Int + let weakMaxTries: Int + let stableRefMaxGapMs: Int + let cropInsetLogical: Int + let trackMinStep: Int + + /// 进程内只取一次(常量为编译期定值)。 + static let shared: LCAlgoConsts = { + var c = LCAlgoConstsC() + lc_get_algo_consts(&c) + return LCAlgoConsts( + sampleAttempts: Int(c.sampleAttempts), + retryDelayNormal: [Int(c.retryDelayNormal.0), Int(c.retryDelayNormal.1), + Int(c.retryDelayNormal.2), Int(c.retryDelayNormal.3), + Int(c.retryDelayNormal.4)], + weakRetryAttempts: Int(c.weakRetryAttempts), + retryDelayWeak: [Int(c.retryDelayWeak.0), Int(c.retryDelayWeak.1), + Int(c.retryDelayWeak.2), Int(c.retryDelayWeak.3), + Int(c.retryDelayWeak.4), Int(c.retryDelayWeak.5)], + stableMaxWaits: Int(c.stableMaxWaits), + stableRetryDelay: [Int(c.stableRetryDelay.0), Int(c.stableRetryDelay.1), + Int(c.stableRetryDelay.2)], + quickResamples: Int(c.quickResamples), + resampleDelayQuick: [Int(c.resampleDelayQuick.0), Int(c.resampleDelayQuick.1)], + scrollSampleMaxGap: Int(c.scrollSampleMaxGap), + bottomConfirmSamples: Int(c.bottomConfirmSamples), + weakMaxTries: Int(c.weakMaxTries), + stableRefMaxGapMs: Int(c.stableRefMaxGapMs), + cropInsetLogical: Int(c.cropInsetLogical), + trackMinStep: Int(c.trackMinStep)) + }() +} + +/// 封装层错误(参数校验失败时绝不把非法输入透传给 C ABI)。 +enum LCAlgorithmError: Error, Equatable { + case abiVersionMismatch(actual: Int32) // dylib 与 Swift 封装版本不一致 + case invalidConfiguration // 创建参数非法(宽高 < 1 等) + case frameSizeMismatch(expected: Int, actual: Int) + case callFailed(String) // C 函数返回失败(句柄/参数被拒) +} + +// MARK: - 算法层会话封装(handle 生命周期守护) + +/// 长截图算法层会话:守护 lc_handle_t 生命周期(init 创建 / deinit 销毁,杜绝泄漏), +/// 帧数据按 [UInt32] 拷贝传递并在跨界前做尺寸校验。对应 lc_stitch_state 的 +/// LongCaptureContext 状态对象;纯 Swift 值语义出口,不暴露任何指针。 +/// 线程约定:对同一实例的调用须串行(对齐 Windows 侧捕获线程单线程驱动算法层)。 +final class LCAlgorithmSession { + /// 帧缓冲宽(物理像素)。 + let physWidth: Int + /// 帧缓冲高(物理像素)。 + let physHeight: Int + private let handle: UnsafeMutableRawPointer + + /// 会话配置(对应 LCSessionConfig;语义见 lc_bridge_mac.h)。 + struct Config { + var interval: Int = 250 + var thumbW: Int = 0 // 缩略图列宽(0 = 关闭;> physW 时由 bridge 钳到 physW) + var horizontal: Bool = false + } + + /// 创建算法层会话(对齐 lc_session_windows.cpp BeginLongCapture 的上下文构造段)。 + init(width: Int, height: Int, config: Config = Config()) throws { + guard width >= 1, height >= 1 else { throw LCAlgorithmError.invalidConfiguration } + let abi = lc_abi_version() + guard abi == 2 else { throw LCAlgorithmError.abiVersionMismatch(actual: abi) } + var cfg = LCSessionConfigC( + interval: Int32(config.interval), + physW: Int32(width), + physH: Int32(height), + thumbW: Int32(config.thumbW), + horizontal: config.horizontal ? 1 : 0) + guard let h = lc_session_create(&cfg) else { throw LCAlgorithmError.invalidConfiguration } + handle = h + physWidth = width + physHeight = height + } + + deinit { + lc_session_destroy(handle) + } + + // —— 帧管线 —— + + /// 以首帧初始化基准(对齐 lc_frame_io_windows.cpp LongCaptureInitBaseline:纯数据初始化, + /// 不抓屏)。`bgra.count` 必须等于 physWidth * physHeight。 + func initBaseline(bgra: [UInt32]) throws { + try validateFrame(bgra) + guard lc_init_baseline(handle, bgra, Int32(physWidth), Int32(physHeight)) == 1 else { + throw LCAlgorithmError.callFailed("lc_init_baseline") + } + } + + /// 单帧「识别 → offset 校验 →(Weak 档)延迟确认 → 提交」管线(转发 + /// LongCaptureTryStitch)。direction:+1 向下滚 / -1 向上滚 / 0 未知; + /// allowStabilityGate 对齐 RunLongCapture 的传参(采样主循环启用,合成帧注入默认关)。 + func tryStitch(bgra: [UInt32], direction: Int32, allowStabilityGate: Bool = false) throws -> LCTryStitchResult { + try validateFrame(bgra) + var out = LCTryStitchResultC() + guard lc_try_stitch(handle, bgra, direction, allowStabilityGate ? 1 : 0, &out) == 1 else { + throw LCAlgorithmError.callFailed("lc_try_stitch") + } + return LCTryStitchResult(out) + } + + /// 独立识别查询(转发 LongCaptureDetectMatch,纯函数不触碰累计状态;诊断/单测用)。 + /// priorExpectedOffset:位移先验(对齐 LongCaptureBuildOffsetPrior 的产物语义)。 + func detectMatch(prev: [UInt32], curr: [UInt32], direction: Int32, + priorExpectedOffset: Int? = nil) throws -> LCDetectResult { + try validateFrame(prev) + try validateFrame(curr) + var out = LCDetectResultC() + guard lc_detect_match(prev, curr, Int32(physWidth), Int32(physHeight), + direction, + priorExpectedOffset != nil ? 1 : 0, + Int32(priorExpectedOffset ?? 0), + &out) == 1 else { + throw LCAlgorithmError.callFailed("lc_detect_match") + } + return LCDetectResult(out) + } + + // —— 状态读取 —— + + /// 会话状态快照(LongCaptureContext 会话可读字段的只读打包)。 + var state: LCState { + var out = LCStateSnapshotC() + guard lc_get_state(handle, &out) == 1 else { + // 句柄恒非空(生命周期守护),此路径仅防御性兜底 + return LCState(LCStateSnapshotC()) + } + return LCState(out) + } + + /// 成功位移历史(|d| 列表,对齐 lc->offsetHistory)。 + func offsetHistory() -> [Int] { + let n = state.offsetHistoryLen + guard n > 0 else { return [] } + var buf = [Int32](repeating: 0, count: n) + let written = lc_get_offset_history(handle, &buf, Int32(n)) + return buf.prefix(Int(written)).map { Int($0) } + } + + /// 当前输出行窗口(裁剪后的 [top, bottom);未裁剪 = [0, stitchH))。 + func outputRows() throws -> (top: Int64, bottom: Int64) { + var top: Int64 = 0, bottom: Int64 = 0 + guard lc_get_output_rows(handle, &top, &bottom) == 1 else { + throw LCAlgorithmError.callFailed("lc_get_output_rows") + } + return (top, bottom) + } + + /// 读取拼接缓冲的显示行区间 [start, start+count)(headRev 倒序头部段 + body 正序 + /// 主体段的双段映射已在此解出;完整合并与横向回转由输出层组装)。 + func readRows(start: Int, count: Int) throws -> [UInt32] { + try readSegment(start: start, count: count, thumb: false) + } + + /// 读取缩略图双段的显示行区间(供小地图面板两级缩略列绘制)。 + func readThumbRows(start: Int, count: Int) throws -> [UInt32] { + try readSegment(start: start, count: count, thumb: true) + } + + private func readSegment(start: Int, count: Int, thumb: Bool) throws -> [UInt32] { + let rowW = thumb ? state.thumbW : physWidth + guard count > 0, rowW > 0 else { return [] } + var buf = [UInt32](repeating: 0, count: count * rowW) + let written = thumb + ? lc_read_thumb_rows(handle, Int64(start), Int64(count), &buf, Int64(count)) + : lc_read_rows(handle, Int64(start), Int64(count), &buf, Int64(count)) + guard written == count else { + throw LCAlgorithmError.callFailed(thumb ? "lc_read_thumb_rows" : "lc_read_rows") + } + return buf + } + + // —— 裁剪(#44 延迟剔除语义) —— + + /// 裁剪登记(镜像 lc_toolbar_ui_windows.cpp LongCaptureApplyCrop 的纯状态段)。 + /// row:0 = 丢弃上方(横向=左侧)、1 = 丢弃下方(横向=右侧)、2 = 重置(仅已裁剪时)。 + /// 返回是否已应用;物理删行由下一次朝该方向的成功提交自动触发(CommitStitch 入口)。 + @discardableResult + func applyCrop(row: Int32) throws -> Bool { + guard lc_apply_crop(handle, row) == 1 else { return false } + return true + } + + /// 是否存在任何生效的裁剪约束(转发 LongCaptureHasCropConstraint)。 + var hasCropConstraint: Bool { lc_has_crop_constraint(handle) != 0 } + + // —— 滚轮先验 / Weak 时间一致性 —— + + /// 成功提交后折算 px/notch 先验(转发 LongCaptureUpdateWheelEstimate)。 + func updateWheelEstimate(d: Int32) { lc_update_wheel_estimate(handle, d) } + + /// 滚轮增量累计(对齐 lc_session_windows.cpp 面板 WM_INPUT 的 wheelAccumDelta 累计)。 + func accumulateWheelDelta(delta: Int32) { lc_accumulate_wheel_delta(handle, delta) } + + /// 登记弱候选时间一致性样本(转发 LongCapturePushWeakCandidate)。 + func pushWeakCandidate(offset: Int32) { lc_push_weak_candidate(handle, offset) } + + /// 放弃当前 Weak 候选链(镜像 lc_session_windows.cpp 的 weakTries 耗尽状态段; + /// weakTries 计数本身归会话层自持)。 + func abandonWeakChain() { lc_abandon_weak_chain(handle) } + + /// 历史跳变合理性校验(转发 LongCaptureOffsetPlausible;mode 见 LCMatchMode)。 + func offsetPlausible(d: Int32, mode: LCMatchMode) -> Bool { + lc_offset_plausible(handle, d, mode.rawValue) == 1 + } + + // —— Tentative 视觉跟踪 —— + + /// 视觉依据直接设定 tentative 位置(转发 LongCaptureTrackingSetVisual,可解冻)。 + func trackingSetVisual(contentY: Int64, confidence: Float) { + lc_tracking_set_visual(handle, contentY, confidence) + } + + /// tentative 回退到 committed 基准(转发 LongCaptureTrackingResetToCommitted)。 + func trackingResetToCommitted() { lc_tracking_reset_to_committed(handle) } + + /// 纯预测推进(转发 LongCaptureTrackingAdvancePredicted;冻结/漂移上限由算法层保证)。 + func trackingAdvancePredicted(direction: Int32, magnitude: Double) { + lc_tracking_advance_predicted(handle, direction, magnitude) + } + + /// 失败帧的跟踪估计(转发 LongCaptureBuildTrackingEstimate;只服务 tentative, + /// 绝不进入提交)。 + func buildTrackingEstimate(direction: Int32) throws -> (valid: Bool, direction: Int, + predictedOffset: Double, confidence: Double) { + var out = LCTrackingEstimateC() + guard lc_build_tracking_estimate(handle, direction, &out) == 1 else { + throw LCAlgorithmError.callFailed("lc_build_tracking_estimate") + } + return (out.valid != 0, Int(out.direction), out.predictedOffset, out.confidence) + } + + // —— 私有 —— + + /// 帧尺寸校验(跨界前拦截,避免把错误长度的缓冲交给算法层)。 + private func validateFrame(_ bgra: [UInt32]) throws { + let expected = physWidth * physHeight + guard bgra.count == expected else { + throw LCAlgorithmError.frameSizeMismatch(expected: expected, actual: bgra.count) + } + } +} diff --git a/src/screenshot/macos/ScreenshotAnnotationsMac.swift b/src/screenshot/macos/ScreenshotAnnotationsMac.swift new file mode 100644 index 0000000..5a6dc6d --- /dev/null +++ b/src/screenshot/macos/ScreenshotAnnotationsMac.swift @@ -0,0 +1,835 @@ +import Foundation +import AppKit +import CoreGraphics + +// MARK: - 矢量标注(macOS;Windows annotations_windows.cpp 移植) +// +// 覆盖矩形/椭圆/箭头/画笔四类矢量标注的数据模型、绘制、命中判定、选中手柄、 +// 拖动/缩放变换与撤销/重做快照栈。文字与马赛克的数据字段已落在 +// ScreenshotAnnotation 的对应字段组,实现见 ScreenshotTextMac/ScreenshotMosaicMac.swift。 +// +// 坐标系约定:所有标注统一用「绝对 CG 全局坐标」(逻辑点、左上原点,与 +// session.selection / session.mouse 同坐标系),对齐 Windows internal.h Annotation 的 +// 「绝对虚拟屏幕坐标」语义——选区移动/缩放时标注位置固定不动。 + +// MARK: - 常量(Windows 出处集中标注) + +extension SC { + /// 撤销/重做快照栈最大深度(internal.h: SC_UNDO_MAX_DEPTH = 50;超限裁掉最老快照) + static let undoMaxDepth = 50 + /// 粗细预设,逻辑像素线宽(internal.h: SC_THICK_PRESETS = { 1, 2, 4 }) + static let thickPresets: [Int] = [1, 2, 4] + /// 子菜单粗细圆点预览直径(internal.h: SC_THICK_DOT_SIZES = { 5, 10, 16 },仅界面显示) + static let thickDotSizes: [CGFloat] = [5, 10, 16] + /// 默认粗细档:中粗(internal.h: SC_DEFAULT_THICK_IDX = 1) + static let defaultThickIdx = 1 + /// 颜色预设八色(internal.h: SC_COLOR_PRESETS) + static let colorPresets: [ScreenshotRGB] = [ + ScreenshotRGB(r: 0xE5, g: 0x39, b: 0x35), // 红 + ScreenshotRGB(r: 0xFB, g: 0x8C, b: 0x00), // 橙 + ScreenshotRGB(r: 0xFD, g: 0xD8, b: 0x35), // 黄 + ScreenshotRGB(r: 0x43, g: 0xA0, b: 0x47), // 绿 + ScreenshotRGB(r: 0x00, g: 0xAC, b: 0xC1), // 青 + ScreenshotRGB(r: 0x1E, g: 0x88, b: 0xE5), // 蓝 + ScreenshotRGB(r: 0xFF, g: 0xFF, b: 0xFF), // 白 + ScreenshotRGB(r: 0x33, g: 0x33, b: 0x33), // 黑 + ] + /// 默认颜色档:红(internal.h: SC_DEFAULT_COLOR_IDX = 0) + static let defaultColorIdx = 0 + /// 选中标注手柄描边红(overlay_paint_windows.cpp:白色圆手柄 + 红 1px 描边 RGB(229,57,53)) + static let annotationHandleStroke = NSColor(srgbRed: 229.0 / 255.0, green: 57.0 / 255.0, + blue: 53.0 / 255.0, alpha: 1.0) +} + +// MARK: - 数据模型(对齐 Windows internal.h Annotation) + +/// 标注类型(对齐 Windows internal.h AnnotationType)。 +enum ScreenshotAnnotationType: Int { + case rect // AT_Rect 矩形(空心描边) + case circle // AT_Circle 圆形/椭圆(空心描边) + case arrow // AT_Arrow 箭头(机翼状锥形多边形填充) + case brush // AT_Brush 画笔(自由路径,圆头圆接) + case text // AT_Text 文字(thickness = 字号,text 为内容,x1/y1 为锚点) + case mosaic // AT_Mosaic 马赛克(reveal-mask 蒙版标注,不可选中/不可拖动) +} + +/// 折线采样点(整数点,对齐 Windows POINT 的 int 语义)。 +struct ScreenshotAnnotationPoint { + var x: Int + var y: Int +} + +/// 标注数据模型(对齐 Windows internal.h 的 Annotation 字段;绝对 CG 全局坐标, +/// 选区移动/缩放标注不动)。 +struct ScreenshotAnnotation { + var type: ScreenshotAnnotationType + var color: ScreenshotRGB + var thickness: Int = 1 // 逻辑像素线宽(文字标注时为字号) + /// Rect/Circle/Arrow 的起止端点(绝对坐标);Brush 仅用 pts + var x1: Int = 0 + var y1: Int = 0 + var x2: Int = 0 + var y2: Int = 0 + /// Brush 自由路径(绝对坐标,逐点追加);Mosaic 涂抹模式的路径(绝对坐标) + var pts: [ScreenshotAnnotationPoint] = [] + // ---- 文字/马赛克字段(对齐 Windows internal.h Annotation 的对应成员)---- + var text: String = "" // AT_Text 的文字内容 + var mosaicRect: Bool = false // 马赛克 true=框选区域 / false=涂抹轨迹 + var mosaicSize: Int = 0 // 马赛克块大小(逻辑像素;提交时固化,导出仍用会话当前档) + var brushRadius: Int = 0 // 马赛克涂抹半径(逻辑像素,仅涂抹模式有效) + + /// 空白标注(撤销快照/初始化占位用)。 + static let empty = ScreenshotAnnotation(type: .rect, color: ScreenshotRGB(r: 0, g: 0, b: 0)) +} + +// MARK: - 几何辅助(对齐 annotations_windows.cpp 的距离/包围盒函数族) + +/// 点 (px,py) 到线段 (ax,ay)-(bx,by) 的最短距离(对齐 PointToSegmentDist;画笔/箭头命中用)。 +func scPointToSegmentDist(_ px: Double, _ py: Double, + _ ax: Double, _ ay: Double, _ bx: Double, _ by: Double) -> Double { + let dx = bx - ax + let dy = by - ay + let lenSq = dx * dx + dy * dy + var t = 0.0 + if lenSq > 1e-9 { + t = ((px - ax) * dx + (py - ay) * dy) / lenSq + t = max(0, min(1, t)) + } + let cx = ax + t * dx + let cy = ay + t * dy + let ex = px - cx + let ey = py - cy + return (ex * ex + ey * ey).squareRoot() +} + +/// 点 (px,py) 到折线 pts 的最短距离(对齐 PointToPolylineDist;单点退化为点距)。 +func scPointToPolylineDist(_ px: Double, _ py: Double, _ pts: [ScreenshotAnnotationPoint]) -> Double { + if pts.isEmpty { return 1e18 } + if pts.count == 1 { + let ex = px - Double(pts[0].x) + let ey = py - Double(pts[0].y) + return (ex * ex + ey * ey).squareRoot() + } + var best = 1e18 + for i in 0..<(pts.count - 1) { + let d = scPointToSegmentDist(px, py, + Double(pts[i].x), Double(pts[i].y), + Double(pts[i + 1].x), Double(pts[i + 1].y)) + if d < best { best = d } + } + return best +} + +/// 箭头多边形几何(对齐 DrawOneAnnotation AT_Arrow 分支的机翼状参数): +/// 箭身 = 起点→终点的锥形四边形(终点延伸至内凹点并略超出 overlap,由箭头覆盖重叠区 +/// 避免抗锯齿细缝);箭头 = 尖→右翼→内凹点→左翼(底边内凹而非平直三角)。 +/// 参数照搬:headLen = thick*4+8、headHalfW = thick*2.4+5、notch = headLen*0.4、 +/// endHalfW = headHalfW*0.55、startHalfW = max(thick*0.5, 0.75)、overlap = 1.5。 +/// - Parameters: +/// - a: 箭头标注 +/// - ox/oy: 绘制偏移(把绝对坐标换算到目标上下文局部坐标) +/// - Returns: (箭身四边形, 箭头四边形);箭头长度 < 1px 返回 nil(与 Windows len<1 break 一致) +func scArrowGeometry(_ a: ScreenshotAnnotation, ox: CGFloat, oy: CGFloat) -> (body: [CGPoint], head: [CGPoint])? { + let sx = CGFloat(a.x1) + ox + let sy = CGFloat(a.y1) + oy + let ex = CGFloat(a.x2) + ox + let ey = CGFloat(a.y2) + oy + let dx = ex - sx + let dy = ey - sy + let len = (dx * dx + dy * dy).squareRoot() + if len < 1.0 { return nil } + + let thick = max(CGFloat(a.thickness), 1) + var headLen = thick * 4.0 + 8.0 + let headHalfW = thick * 2.4 + 5.0 + let notch = headLen * 0.4 // 内凹深度:底边中点向尖端凹入 + if headLen > len { headLen = len * 0.6 } + let ux = dx / len + let uy = dy / len + let nx = -uy + let ny = ux + // 箭头底部中心(沿箭头方向后退 headLen)与内凹点 + let baseX = ex - ux * headLen + let baseY = ey - uy * headLen + let notchX = baseX + ux * notch + let notchY = baseY + uy * notch + // 箭身:起点细、终点粗的锥形;终点宽 < 两翼宽,两翼从箭身末端明显张开 + let startHalfW = max(thick * 0.5, 0.75) + let endHalfW = headHalfW * 0.55 + let overlap: CGFloat = 1.5 + let bodyEndX = notchX + ux * overlap + let bodyEndY = notchY + uy * overlap + + let body = [ + CGPoint(x: sx + nx * startHalfW, y: sy + ny * startHalfW), + CGPoint(x: sx - nx * startHalfW, y: sy - ny * startHalfW), + CGPoint(x: bodyEndX - nx * endHalfW, y: bodyEndY - ny * endHalfW), + CGPoint(x: bodyEndX + nx * endHalfW, y: bodyEndY + ny * endHalfW), + ] + let head = [ + CGPoint(x: ex, y: ey), + CGPoint(x: baseX + nx * headHalfW, y: baseY + ny * headHalfW), + CGPoint(x: notchX, y: notchY), + CGPoint(x: baseX - nx * headHalfW, y: baseY - ny * headHalfW), + ] + return (body, head) +} + +/// 箭头标注包围盒(对齐 MeasureArrowAnnotationBounds):全部几何控制点外包 + 2px 余量。 +func scMeasureArrowBounds(_ a: ScreenshotAnnotation) -> CGRect { + var minX = Double(min(a.x1, a.x2)) + var minY = Double(min(a.y1, a.y2)) + var maxX = Double(max(a.x1, a.x2)) + var maxY = Double(max(a.y1, a.y2)) + + let sx = Double(a.x1), sy = Double(a.y1), ex = Double(a.x2), ey = Double(a.y2) + let dx = ex - sx, dy = ey - sy + let len = (dx * dx + dy * dy).squareRoot() + if len >= 1.0 { + let thick = Double(max(a.thickness, 1)) + var headLen = thick * 4.0 + 8.0 + let headHalfW = thick * 2.4 + 5.0 + if headLen > len { headLen = len * 0.6 } + let notch = headLen * 0.4 + let ux = dx / len, uy = dy / len + let nx = -uy, ny = ux + let baseX = ex - ux * headLen, baseY = ey - uy * headLen + let notchX = baseX + ux * notch, notchY = baseY + uy * notch + let startHalfW = max(thick * 0.5, 0.75) + let endHalfW = headHalfW * 0.55 + let overlap = 1.5 + let bodyEndX = notchX + ux * overlap, bodyEndY = notchY + uy * overlap + + func expand(_ x: Double, _ y: Double) { + if x < minX { minX = x } + if y < minY { minY = y } + if x > maxX { maxX = x } + if y > maxY { maxY = y } + } + expand(sx + nx * startHalfW, sy + ny * startHalfW) + expand(sx - nx * startHalfW, sy - ny * startHalfW) + expand(bodyEndX - nx * endHalfW, bodyEndY - ny * endHalfW) + expand(bodyEndX + nx * endHalfW, bodyEndY + ny * endHalfW) + expand(baseX + nx * headHalfW, baseY + ny * headHalfW) + expand(notchX, notchY) + expand(baseX - nx * headHalfW, baseY - ny * headHalfW) + } + + let margin = 2.0 + return CGRect(x: (minX - margin).rounded(.down), y: (minY - margin).rounded(.down), + width: (maxX + margin).rounded(.up) - (minX - margin).rounded(.down), + height: (maxY + margin).rounded(.up) - (minY - margin).rounded(.down)) +} + +/// 单个标注包围盒(对齐 MeasureAnnotationBounds;绝对 CG 全局坐标,完整包住可见区域, +/// 供选中框/缩放手柄定位与选区内容约束使用)。 +func scMeasureAnnotationBounds(_ a: ScreenshotAnnotation) -> CGRect { + switch a.type { + case .rect, .circle: + return CGRect(x: min(a.x1, a.x2), y: min(a.y1, a.y2), + width: abs(a.x2 - a.x1), height: abs(a.y2 - a.y1)) + case .arrow: + return scMeasureArrowBounds(a) + case .brush: + guard !a.pts.isEmpty else { return .zero } + var minX = a.pts[0].x, minY = a.pts[0].y, maxX = a.pts[0].x, maxY = a.pts[0].y + for p in a.pts { + minX = min(minX, p.x); minY = min(minY, p.y) + maxX = max(maxX, p.x); maxY = max(maxY, p.y) + } + return CGRect(x: minX, y: minY, width: maxX - minX, height: maxY - minY) + case .text: + // 文字:字形紧凑包围盒 + padding 4(与选中边框/命中区完全一致,对齐 + // MeasureTextAnnotation——复用同一测量保证 resize 约束 = 视觉边框) + return scMeasureTextAnnotationBox(a) + case .mosaic: + // 马赛克:框选 = 两对角点;涂抹 = 路径包围盒 ± 半径(对齐 MeasureAnnotationBounds) + if a.mosaicRect { + return CGRect(x: min(a.x1, a.x2), y: min(a.y1, a.y2), + width: abs(a.x2 - a.x1), height: abs(a.y2 - a.y1)) + } + guard !a.pts.isEmpty else { return .zero } + var minX = a.pts[0].x, minY = a.pts[0].y, maxX = a.pts[0].x, maxY = a.pts[0].y + for p in a.pts { + minX = min(minX, p.x); minY = min(minY, p.y) + maxX = max(maxX, p.x); maxY = max(maxY, p.y) + } + let r = a.brushRadius + return CGRect(x: minX - r, y: minY - r, width: (maxX - minX) + r * 2, height: (maxY - minY) + r * 2) + } +} + +/// 全部标注内容的包围盒(对齐 CalcAnnotationsBounds;绝对 CG 全局坐标)。 +/// 用于限制选区缩放:选区不可缩小到裁掉已添加内容。 +/// - Returns: 包围盒;无标注返回 nil(无内容约束) +func scCalcAnnotationsBounds(_ anns: [ScreenshotAnnotation]) -> CGRect? { + guard !anns.isEmpty else { return nil } + var result: CGRect? + for a in anns { + let box = scMeasureAnnotationBounds(a) + result = scUnionRect(result, box) + } + return result +} + +// MARK: - 命中判定(对齐 HitTestAnnotation / HitTestAnnotationResizeHandle) + +/// 命中测试标注,返回索引(对齐 HitTestAnnotation):从顶层(数组末尾,绘制最上层) +/// 向底层遍历,命中第一个即返回(与视觉 z 序一致)。容差按线宽自适应: +/// 细线给 6px 余量、粗线给半个线宽 + 2px。矩形/椭圆仅命中轮廓(空心语义), +/// 箭头命中主轴线段,画笔命中折线。 +/// - Parameters: +/// - anns: 标注数组 +/// - point: 鼠标 CG 全局坐标 +/// - Returns: 命中索引;未命中 -1 +func scHitTestAnnotation(_ anns: [ScreenshotAnnotation], _ point: CGPoint) -> Int { + for i in stride(from: anns.count - 1, through: 0, by: -1) { + let a = anns[i] + // 马赛克区域不可选中、不可拖拽,直接跳过命中测试(对齐 HitTestAnnotation 顶部 continue) + if a.type == .mosaic { continue } + let tol = max(6.0, Double(a.thickness) / 2.0 + 2.0) + let x = Double(point.x) + let y = Double(point.y) + switch a.type { + case .rect: + // 仅命中矩形四条边轮廓(空心框),内部空白不选中 + let d1 = scPointToSegmentDist(x, y, Double(a.x1), Double(a.y1), Double(a.x2), Double(a.y1)) + let d2 = scPointToSegmentDist(x, y, Double(a.x2), Double(a.y2), Double(a.x1), Double(a.y2)) + let d3 = scPointToSegmentDist(x, y, Double(a.x1), Double(a.y2), Double(a.x1), Double(a.y1)) + let d4 = scPointToSegmentDist(x, y, Double(a.x2), Double(a.y1), Double(a.x2), Double(a.y2)) + if min(min(d1, d2), min(d3, d4)) <= tol { return i } + case .circle: + // 椭圆轮廓命中:归一化径向距离 r≈1,(r-1)*min(a,b) 换算回像素(保守足够) + let cx = (Double(a.x1) + Double(a.x2)) * 0.5 + let cy = (Double(a.y1) + Double(a.y2)) * 0.5 + let aax = abs(Double(a.x2) - Double(a.x1)) * 0.5 + let aay = abs(Double(a.y2) - Double(a.y1)) * 0.5 + if aax < 0.5 && aay < 0.5 { + let ex = x - cx, ey = y - cy + if (ex * ex + ey * ey).squareRoot() <= tol { return i } + } else if aax < 0.5 { + if scPointToSegmentDist(x, y, cx, Double(a.y1), cx, Double(a.y2)) <= tol { return i } + } else if aay < 0.5 { + if scPointToSegmentDist(x, y, Double(a.x1), cy, Double(a.x2), cy) <= tol { return i } + } else { + let r = ((x - cx) / aax) * ((x - cx) / aax) + ((y - cy) / aay) * ((y - cy) / aay) + let minAxis = min(aax, aay) + if abs(r.squareRoot() - 1.0) * minAxis <= tol { return i } + } + case .arrow: + if scPointToSegmentDist(x, y, Double(a.x1), Double(a.y1), Double(a.x2), Double(a.y2)) <= tol { + return i + } + case .brush: + if scPointToPolylineDist(x, y, a.pts) <= tol { return i } + case .text: + // 文字标注:包围盒(含 padding 4)整体命中(对齐 HitTestAnnotation AT_Text 分支) + if scPointInRect(point, scMeasureTextAnnotationBox(a)) { return i } + case .mosaic: + break // 已在循环顶部跳过,不可达(防御性保留分支) + } + } + return -1 +} + +/// 命中测试标注缩放手柄(对齐 HitTestAnnotationResizeHandle,统一入口): +/// 箭头 = 起点/终点 2 端点手柄;矩形/圆 = 包围盒 8 手柄(4 角 + 4 边中点,顺序与 +/// HitTestAnnotationHandle 一致);画笔 = 无手柄(仅可整体拖动)。 +/// 容差沿用选区手柄的 handleSize,保证与选区手柄一致的可点击范围。 +/// - Parameters: +/// - a: 目标标注 +/// - point: 鼠标 CG 全局坐标 +/// - handleSize: 命中半宽(SC.handleSize) +/// - Returns: 命中的手柄;未命中 .none +func scHitTestAnnotationHandle(_ a: ScreenshotAnnotation, _ point: CGPoint, + _ handleSize: CGFloat) -> ScreenshotResizeHandle { + func hitBox(_ hx: Int, _ hy: Int, _ handle: ScreenshotResizeHandle) -> ScreenshotResizeHandle { + let box = CGRect(x: CGFloat(hx) - handleSize, y: CGFloat(hy) - handleSize, + width: handleSize * 2, height: handleSize * 2) + return scPointInRect(point, box) ? handle : .none + } + + switch a.type { + case .arrow: + // 箭头只允许拖拽两个端点(而非四角包围盒缩放),单独命中两端点 + let start = hitBox(a.x1, a.y1, .arrowStart) + if start != .none { return start } + return hitBox(a.x2, a.y2, .arrowEnd) + case .rect, .circle: + let box = scMeasureAnnotationBounds(a) + let cx = box.midX + let cy = box.midY + let tests: [(CGPoint, ScreenshotResizeHandle)] = [ + (CGPoint(x: box.minX, y: box.minY), .topLeft), + (CGPoint(x: box.maxX, y: box.minY), .topRight), + (CGPoint(x: box.minX, y: box.maxY), .bottomLeft), + (CGPoint(x: box.maxX, y: box.maxY), .bottomRight), + (CGPoint(x: cx, y: box.minY), .top), + (CGPoint(x: cx, y: box.maxY), .bottom), + (CGPoint(x: box.minX, y: cy), .left), + (CGPoint(x: box.maxX, y: cy), .right), + ] + for (anchor, handle) in tests { + let hit = CGRect(x: anchor.x - handleSize, y: anchor.y - handleSize, + width: handleSize * 2, height: handleSize * 2) + if scPointInRect(point, hit) { return handle } + } + return .none + case .brush: + return .none // 画笔无缩放手柄(蓝虚线包围盒选中,仅可整体拖动) + case .text, .mosaic: + // 文字/马赛克无缩放手柄(文字仅可整体拖动;马赛克不可选中,对齐 default 分支) + return .none + } +} + +// MARK: - 变换(对齐 TransformAnnotationByBox) + +/// 按包围盒变换映射标注所有坐标(对齐 TransformAnnotationByBox):oldBox → newBox, +/// 标注内每个点 p 映射为 newBox.minX + (p - oldBox.minX) * sx。用于矩形/椭圆的 +/// 8 手柄缩放(含边中点,非等比);画笔无手柄不经过此路径。 +/// sx/sy 防 0:旧宽/高为 0 时退化为平移。 +func scTransformAnnotationByBox(_ a: inout ScreenshotAnnotation, oldBox: CGRect, newBox: CGRect) { + let sx = oldBox.width > 0.5 ? newBox.width / oldBox.width : 1.0 + let sy = oldBox.height > 0.5 ? newBox.height / oldBox.height : 1.0 + func mapX(_ v: Int) -> Int { + return Int((newBox.minX + (CGFloat(v) - oldBox.minX) * sx + 0.5).rounded(.down)) + } + func mapY(_ v: Int) -> Int { + return Int((newBox.minY + (CGFloat(v) - oldBox.minY) * sy + 0.5).rounded(.down)) + } + switch a.type { + case .rect, .circle, .arrow: + a.x1 = mapX(a.x1); a.y1 = mapY(a.y1) + a.x2 = mapX(a.x2); a.y2 = mapY(a.y2) + case .brush, .mosaic: + // 画笔/涂抹马赛克:路径按包围盒整体缩放(涂抹马赛克不可选中,兜底保留) + for i in a.pts.indices { + a.pts[i].x = mapX(a.pts[i].x) + a.pts[i].y = mapY(a.pts[i].y) + } + case .text: + // 文字不经过缩放路径(走锚点平移),此处兜底平移锚点(对齐 AT_Text 分支) + a.x1 = mapX(a.x1); a.y1 = mapY(a.y1) + } +} + +// MARK: - 绘制(对齐 DrawOneAnnotation / OnPaint 选中视觉) + +/// 绘制单条标注(不含裁剪;对齐 DrawOneAnnotation,GDI+ 抗锯齿 → CGContext 抗锯齿)。 +/// 标注坐标为绝对 CG 全局坐标;ox/oy 把绝对坐标换算到目标上下文局部坐标: +/// 覆盖层视图 ox/oy = -view.cgOrigin;导出合成 ox/oy = -选区左上角。 +/// 调用方需保证上下文为左上原点(覆盖层视图天然翻转;raw CGContext 由调用方自行翻转)。 +func scDrawAnnotation(_ ctx: CGContext, _ a: ScreenshotAnnotation, ox: CGFloat, oy: CGFloat) { + let color = NSColor(srgbRed: CGFloat(a.color.r) / 255.0, green: CGFloat(a.color.g) / 255.0, + blue: CGFloat(a.color.b) / 255.0, alpha: 1.0).cgColor + let thick = max(CGFloat(a.thickness), 1) + + switch a.type { + case .rect: + // 矩形:空心描边(w/h 取绝对值,起点取 min 对齐 DrawRectangle) + let rect = CGRect(x: min(CGFloat(a.x1), CGFloat(a.x2)) + ox, + y: min(CGFloat(a.y1), CGFloat(a.y2)) + oy, + width: abs(CGFloat(a.x2 - a.x1)), height: abs(CGFloat(a.y2 - a.y1))) + ctx.setStrokeColor(color) + ctx.setLineWidth(thick) + ctx.setLineJoin(.round) + ctx.stroke(rect) + case .circle: + // 椭圆:空心描边(由包围盒定义) + let rect = CGRect(x: min(CGFloat(a.x1), CGFloat(a.x2)) + ox, + y: min(CGFloat(a.y1), CGFloat(a.y2)) + oy, + width: abs(CGFloat(a.x2 - a.x1)), height: abs(CGFloat(a.y2 - a.y1))) + ctx.setStrokeColor(color) + ctx.setLineWidth(thick) + ctx.strokeEllipse(in: rect) + case .arrow: + // 箭头:锥形箭身 + 机翼状箭头(底边内凹),两段多边形填充 + guard let geo = scArrowGeometry(a, ox: ox, oy: oy) else { return } + ctx.setFillColor(color) + for poly in [geo.body, geo.head] { + ctx.addPath(scPolygonPath(poly)) + ctx.fillPath() + } + case .brush: + // 画笔:路径圆头圆接连线(对齐 pen LineCapRound/LineJoinRound) + guard a.pts.count >= 2 else { return } + let path = CGMutablePath() + path.move(to: CGPoint(x: CGFloat(a.pts[0].x) + ox, y: CGFloat(a.pts[0].y) + oy)) + for p in a.pts.dropFirst() { + path.addLine(to: CGPoint(x: CGFloat(p.x) + ox, y: CGFloat(p.y) + oy)) + } + ctx.setStrokeColor(color) + ctx.setLineWidth(thick) + ctx.setLineCap(.round) + ctx.setLineJoin(.round) + ctx.addPath(path) + ctx.strokePath() + case .text: + // 文字:顶部左对齐到锚点(x1,y1),字号 = thickness(下限 8,对齐 DrawOneAnnotation + // AT_Text 分支);马赛克不做矢量绘制(reveal-mask 单独渲染,对齐 DrawAnnotations 跳过) + if a.text.isEmpty { return } + scDrawTextLine(ctx, text: a.text, fontPx: max(CGFloat(a.thickness), CGFloat(SC.textMinFontSize)), + color: NSColor(srgbRed: CGFloat(a.color.r) / 255.0, green: CGFloat(a.color.g) / 255.0, + blue: CGFloat(a.color.b) / 255.0, alpha: 1.0), + x: CGFloat(a.x1) + ox, y: CGFloat(a.y1) + oy) + case .mosaic: + break // 马赛克由 paintMosaicLayer / 合成马赛克层单独渲染 + } +} + +/// 绘制选中标注的视觉(对齐 overlay_paint_windows.cpp OnPaint 选中分支): +/// 圆形/画笔画蓝色虚线包围盒(矩形/箭头/文字不画虚线框),手柄为白色圆形 + 红 1px 描边 +///(箭头 2 端点 / 矩形·圆 8 个 / 画笔·文字·马赛克无)。文字标注为蓝色实线 2px 边框 +///(对齐 annTextSelPen:PS_SOLID 2,RGB(0,136,255),无手柄);马赛克永不可选中。 +/// - Parameters: +/// - ctx: 覆盖层上下文(左上原点) +/// - view: 当前绘制的覆盖层视图(绝对坐标 → 本地坐标) +/// - a: 选中的标注 +func scPaintAnnotationSelection(_ ctx: CGContext, view: OverlayScreenshotView, _ a: ScreenshotAnnotation) { + let box = scMeasureAnnotationBounds(a) + .offsetBy(dx: -view.cgOrigin.x, dy: -view.cgOrigin.y) + + // 蓝色虚线包围盒:仅圆形/画笔(Windows:矩形/箭头/文字不画虚线框) + if a.type == .circle || a.type == .brush { + ctx.setStrokeColor(SC.accentBlue.cgColor) + ctx.setLineWidth(1) + ctx.setLineDash(phase: 0, lengths: [4, 4]) // 对齐 annHoverPen 的 PS_DASH 视觉 + ctx.stroke(box) + ctx.setLineDash(phase: 0, lengths: []) + } + + // 手柄集合:箭头取 2 端点;矩形/圆取 8 个(4 角 + 4 边中点);画笔/文字无 + var handles: [CGPoint] = [] + switch a.type { + case .arrow: + handles = [ + CGPoint(x: CGFloat(a.x1) - view.cgOrigin.x, y: CGFloat(a.y1) - view.cgOrigin.y), + CGPoint(x: CGFloat(a.x2) - view.cgOrigin.x, y: CGFloat(a.y2) - view.cgOrigin.y), + ] + case .rect, .circle: + handles = [ + CGPoint(x: box.minX, y: box.minY), CGPoint(x: box.maxX, y: box.minY), + CGPoint(x: box.minX, y: box.maxY), CGPoint(x: box.maxX, y: box.maxY), + CGPoint(x: box.midX, y: box.minY), CGPoint(x: box.midX, y: box.maxY), + CGPoint(x: box.minX, y: box.midY), CGPoint(x: box.maxX, y: box.midY), + ] + case .brush, .text: + break + case .mosaic: + return // 马赛克不可选中,防御性兜底 + } + guard !handles.isEmpty else { + // 文字标注:实线蓝色粗边框(对齐 annTextSelPen PS_SOLID 2 RGB(0,136,255)) + if a.type == .text { + ctx.setStrokeColor(SC.accentBlue.cgColor) + ctx.setLineWidth(2) + ctx.stroke(box) + } + return + } + let half = SC.handleSize / 2 + let circlePath = CGMutablePath() + for h in handles { + circlePath.addPath(CGPath(ellipseIn: CGRect(x: h.x - half, y: h.y - half, + width: half * 2, height: half * 2), transform: nil)) + } + ctx.addPath(circlePath) + ctx.setFillColor(NSColor.white.cgColor) + ctx.fillPath() + ctx.addPath(circlePath) + ctx.setStrokeColor(SC.annotationHandleStroke.cgColor) + ctx.setLineWidth(1) + ctx.strokePath() +} + +// MARK: - 会话扩展(历史栈 + 标注交互辅助) + +extension ScreenshotOverlaySession { + // ---- 撤销/重做(对齐 annotations_windows.cpp PushAnnotationHistory / Undo / Redo)---- + + /// 压入一份整份标注数组的撤销快照(调用方在「变更前」调用):Swift 数组为值类型, + /// append 即得深拷贝快照;限深 SC.undoMaxDepth(50),入栈清空 redo 栈。 + func pushAnnotationHistory() { + undoStack.append(annotations) + if undoStack.count > SC.undoMaxDepth { + undoStack.removeFirst() // 裁掉最老快照,最近 50 步撤销不受影响 + } + redoStack.removeAll() + toolbar.refresh() + } + + /// 撤销到上一份快照(对齐 UndoAnnotations):当前状态入 redo 栈并整体替换标注, + /// 随后清除进行中的标注交互态(ResetAnnotationInteraction 等价)。 + /// - Returns: 是否发生撤销(栈空返回 false,工具栏按钮据此保持置灰) + func undoAnnotations() -> Bool { + guard let last = undoStack.popLast() else { return false } + redoStack.append(annotations) + annotations = last + resetAnnotationInteraction() + invalidateAll() + toolbar.refresh() + return true + } + + /// 重做到下一份快照(对齐 RedoAnnotations):当前状态回入撤销栈并整体替换标注, + /// 随后清除进行中的标注交互态。 + /// - Returns: 是否发生重做(栈空返回 false) + func redoAnnotations() -> Bool { + guard let next = redoStack.popLast() else { return false } + undoStack.append(annotations) + annotations = next + resetAnnotationInteraction() + invalidateAll() + toolbar.refresh() + return true + } + + /// 清除进行中的标注交互态(对齐 ResetAnnotationInteraction;含文字字段)。 + func resetAnnotationInteraction() { + selectedAnnotation = -1 + draggingAnnotation = -1 + resizingAnnotation = -1 + annotationResizeHandle = .none + annotationOpHistoryPushed = false + // 文字字段(selectedTextAnnotation / draggingTextAnnotation / hoveredTextAnnotation) + selectedTextAnnotation = -1 + draggingTextAnnotation = -1 + hoveredTextAnnotation = -1 + } + + /// 清除当前标注选中态(点空白/切换工具/执行无关操作时调用;含文字选中,对齐 + /// Windows 同名清理序列)。 + func clearAnnotationSelection() { + selectedAnnotation = -1 + draggingAnnotation = -1 + resizingAnnotation = -1 + annotationResizeHandle = .none + selectedTextAnnotation = -1 + draggingTextAnnotation = -1 + } + + // ---- 标注拖动/缩放(对齐 overlay_input_windows.cpp 的 MOUSEMOVE 分支)---- + + /// 选中标注的缩放拖拽:箭头端点手柄仅平移对应端点;矩形/圆的 8 手柄走 + /// 「更新包围盒 → 防翻转钳制(≥2px)→ 按盒变换」。首次实际位移才入历史。 + /// - Parameter point: 鼠标 CG 全局坐标 + func applyAnnotationResizeDrag(_ point: CGPoint) { + guard resizingAnnotation >= 0 && resizingAnnotation < annotations.count else { return } + let idx = resizingAnnotation + let dx = point.x - annotationDragStartPoint.x + let dy = point.y - annotationDragStartPoint.y + if !annotationOpHistoryPushed && (dx != 0 || dy != 0) { + pushAnnotationHistory() + annotationOpHistoryPushed = true + } + // 从按下时快照还原再变换,避免累积误差(对齐 annotations[idx] = dragStartAnnotation) + annotations[idx] = dragStartAnnotation + if annotationResizeHandle == .arrowStart || annotationResizeHandle == .arrowEnd { + // 箭头端点拖拽:仅移动对应端点,另一端点保持快照值不变 + let sx = Int((point.x - annotationDragStartPoint.x).rounded()) + let sy = Int((point.y - annotationDragStartPoint.y).rounded()) + if annotationResizeHandle == .arrowStart { + annotations[idx].x1 = dragStartAnnotation.x1 + sx + annotations[idx].y1 = dragStartAnnotation.y1 + sy + } else { + annotations[idx].x2 = dragStartAnnotation.x2 + sx + annotations[idx].y2 = dragStartAnnotation.y2 + sy + } + } else { + // 包围盒缩放:按拖拽手柄更新包围盒,防翻转后整体变换(矩形/圆均支持 8 手柄) + let o = annotationResizeStartBox + var n = o + switch annotationResizeHandle { + case .topLeft: n.origin.x = o.minX + dx; n.origin.y = o.minY + dy + n.size.width = o.maxX - n.minX; n.size.height = o.maxY - n.minY + case .topRight: n.size.width = o.width + dx; n.origin.y = o.minY + dy + n.size.height = o.maxY - n.minY + case .bottomLeft: n.origin.x = o.minX + dx; n.size.width = o.maxX - n.minX + n.size.height = o.height + dy + case .bottomRight: n.size.width = o.width + dx; n.size.height = o.height + dy + case .left: n.origin.x = o.minX + dx; n.size.width = o.maxX - n.minX + case .right: n.size.width = o.width + dx + case .top: n.origin.y = o.minY + dy; n.size.height = o.maxY - n.minY + case .bottom: n.size.height = o.height + dy + default: break + } + // 防翻转:规范化后保证宽高至少 2px(对齐 NormalizeRect + min 2px) + n = n.standardized + if n.width < 2 { n.size.width = 2 } + if n.height < 2 { n.size.height = 2 } + scTransformAnnotationByBox(&annotations[idx], oldBox: o, newBox: n) + } + // 局部脏区 = 上帧盒 ∪ 本帧盒,外扩手柄余量(性能审计:对齐 Windows + // InvalidateAnnotationOp 的局部失效;handleMargin 覆盖选中手柄、描边与抗锯齿) + invalidateAnnotationOpLocal(newBox: scMeasureAnnotationBounds(annotations[idx])) + } + + /// 选中标注的整体拖动:对按下时快照做 dx/dy 平移后写回(避免累积误差)。 + /// 首次实际位移才入历史(对齐 annotationOpHistoryPushed 语义)。 + /// - Parameter point: 鼠标 CG 全局坐标 + func applyAnnotationMoveDrag(_ point: CGPoint) { + guard draggingAnnotation >= 0 && draggingAnnotation < annotations.count else { return } + let idx = draggingAnnotation + let dx = Int((point.x - annotationDragStartPoint.x).rounded()) + let dy = Int((point.y - annotationDragStartPoint.y).rounded()) + if !annotationOpHistoryPushed && (dx != 0 || dy != 0) { + pushAnnotationHistory() + annotationOpHistoryPushed = true + } + annotations[idx] = dragStartAnnotation + switch annotations[idx].type { + case .rect, .circle, .arrow: + annotations[idx].x1 += dx; annotations[idx].y1 += dy + annotations[idx].x2 += dx; annotations[idx].y2 += dy + case .brush, .mosaic: + // 画笔/涂抹马赛克:整体平移路径;框选马赛克:平移两对角点 + //(框选马赛克不可拖动,此分支防御性保留,对齐 Windows draggingAnnotation 分支) + if annotations[idx].type == .mosaic && annotations[idx].mosaicRect { + annotations[idx].x1 += dx; annotations[idx].y1 += dy + annotations[idx].x2 += dx; annotations[idx].y2 += dy + } else { + for i in annotations[idx].pts.indices { + annotations[idx].pts[i].x += dx + annotations[idx].pts[i].y += dy + } + } + case .text: + // 文字:平移锚点(对齐 Windows draggingTextAnnotation 分支语义) + annotations[idx].x1 += dx; annotations[idx].y1 += dy + } + // 局部脏区 = 上帧盒 ∪ 本帧盒,外扩手柄余量(性能审计:对齐 Windows + // InvalidateAnnotationOp 的局部失效;逐帧链式覆盖上一位置防拖拽残影) + invalidateAnnotationOpLocal(newBox: scMeasureAnnotationBounds(annotations[idx])) + } + + // ---- 绘制流程(CS_Drawing;对齐 OnLButtonDown/OnMouseMove/OnLButtonUp 绘制分支)---- + + /// 开始绘制新标注(确认态 + 矢量工具激活 + 选区内点空白):清除选中态,以当前 + /// 子菜单粗细/颜色初始化进行中标注,画笔记录路径起点。 + /// - Parameter point: 起点(CG 全局坐标) + func beginAnnotationDrawing(at point: CGPoint) { + guard let tool = activeTool, tool.isVectorTool else { return } + selectedAnnotation = -1 + selectedTextAnnotation = -1 // 绘制态不显示文字选中边框(Windows 该边框仅确认/编辑态绘制) + hasCurDrawing = true + curDrawing = ScreenshotAnnotation( + type: tool.annotationType, + color: SC.colorPresets[drawColorIdx], + thickness: SC.thickPresets[drawThickIdx]) + curDrawing.x1 = Int(point.x) + curDrawing.y1 = Int(point.y) + curDrawing.x2 = curDrawing.x1 + curDrawing.y2 = curDrawing.y1 + if curDrawing.type == .brush { + curDrawing.pts = [ScreenshotAnnotationPoint(x: curDrawing.x1, y: curDrawing.y1)] + } + state = .drawing + invalidateAll() + } + + /// 更新进行中标注的终点/路径:终点钳制到选区内(对齐 CS_Drawing 的 max/min clamp), + /// 画笔与涂抹马赛克逐点追加路径(框选马赛克更新终点)。 + /// - Parameter point: 鼠标 CG 全局坐标 + func updateAnnotationDrawing(_ point: CGPoint) { + guard hasCurDrawing else { return } + let ax = max(selection.minX, min(point.x, selection.maxX)) + let ay = max(selection.minY, min(point.y, selection.maxY)) + if curDrawing.type == .brush || (curDrawing.type == .mosaic && !curDrawing.mosaicRect) { + curDrawing.pts.append(ScreenshotAnnotationPoint(x: Int(ax), y: Int(ay))) + if curDrawing.type == .mosaic { + // 涂抹马赛克局部脏区 = 本帧新圆邻域(揭示蒙版是圆并集只增不减,已揭示的 + // 前序区域不变;性能审计:沿用的整条路径包围盒随轨迹单调增长,长轨迹下 + // 逐帧重绘面积越来越大,揭示边界明显滞后于鼠标——「大圆不跟手」)。 + // 4px 外扩覆盖揭示 clip 边缘与抗锯齿。 + let r = CGFloat(max(1, curDrawing.brushRadius)) + invalidate(scInflate(CGRect(x: ax - r, y: ay - r, width: r * 2, height: r * 2), 4)) + return + } + } else { + curDrawing.x2 = Int(ax) + curDrawing.y2 = Int(ay) + } + // 局部脏区 = 正在绘制标注的当前包围盒外扩 4px(性能审计:对齐 Windows CS_Drawing + // 分支 lastDrawingBox ∪ mouseBox 的局部失效;路径点/端点单调增长,当前包围盒恒 + // ⊇ 全部已画内容,4px 覆盖最大线宽一半与抗锯齿,快笔段亦不漏画) + invalidate(scInflate(scMeasureAnnotationBounds(curDrawing), 4)) + } + + /// 结束绘制并提交(松手):仅有效尺寸/路径入历史(矩形/椭圆/箭头 ≥2px,画笔 ≥2 点, + /// 马赛克框选 ≥2px / 涂抹 ≥1 点——单击也产生一个马赛克圆),随后回到确认态并刷新工具栏 + /// 撤销可用态。 + func finishAnnotationDrawing() { + var valid = false + if hasCurDrawing { + switch curDrawing.type { + case .brush: + valid = curDrawing.pts.count >= 2 + case .rect, .circle, .arrow: + valid = abs(curDrawing.x2 - curDrawing.x1) >= 2 || abs(curDrawing.y2 - curDrawing.y1) >= 2 + case .mosaic: + if curDrawing.mosaicRect { + valid = abs(curDrawing.x2 - curDrawing.x1) >= 2 || abs(curDrawing.y2 - curDrawing.y1) >= 2 + } else { + valid = curDrawing.pts.count >= 1 + } + case .text: + valid = false // 文字不走绘制态(CS_TextEditing 单独提交流程) + } + } + if valid { + pushAnnotationHistory() + annotations.append(curDrawing) + } + hasCurDrawing = false + curDrawing = .empty + state = .confirmed + invalidateAll() + } + + // ---- 绘制接线(paintConfirmedOverlay 调用)---- + + /// 覆盖层绘制已提交标注 + 进行中标注(对齐 DrawAnnotations:绘制范围裁剪到选区内, + /// 标注绝对坐标 → 视图本地偏移 = -cgOrigin)。确认/调整/绘制/文字编辑四态调用(移动态无标注)。 + func paintAnnotationsLayer(ctx: CGContext, view: OverlayScreenshotView) { + guard state == .confirmed || state == .resizing || state == .drawing || state == .textEditing else { return } + let localSel = selection.offsetBy(dx: -view.cgOrigin.x, dy: -view.cgOrigin.y) + let ox = -view.cgOrigin.x + let oy = -view.cgOrigin.y + ctx.saveGState() + // 限制绘制范围在选区内(对齐 graphics.SetClip(clipRect, CombineModeIntersect)) + ctx.addRect(localSel) + ctx.clip() + for a in annotations { + scDrawAnnotation(ctx, a, ox: ox, oy: oy) + } + if hasCurDrawing { + scDrawAnnotation(ctx, curDrawing, ox: ox, oy: oy) + } + ctx.restoreGState() + // 选中标注的视觉(虚线框/手柄)画在裁剪外,保证贴边手柄完整可见 + if selectedAnnotation >= 0 && selectedAnnotation < annotations.count { + scPaintAnnotationSelection(ctx, view: view, annotations[selectedAnnotation]) + } + } + + // ---- 确认输出合成 ---- + //「标注合成 + PNG 编码」已并入 ScreenshotOutputMac.swift 的统一输出管线 + // buildFinalPngOutput——确认输出与保存路径共用、长截图复用;原 + // compositeAnnotationsToPng 的合成逻辑(缩回逻辑尺寸 → 马赛克现场重算 → + // 矢量/文字标注覆盖)已在其中原样保留,行为零变化。) +} + +// MARK: - 基础小工具 + +/// 点集 → 闭合多边形路径(箭头多边形填充用;空点集返回单位空路径)。 +func scPolygonPath(_ points: [CGPoint]) -> CGPath { + let path = CGMutablePath() + guard let first = points.first else { return path } + path.move(to: first) + for p in points.dropFirst() { + path.addLine(to: p) + } + path.closeSubpath() + return path +} diff --git a/src/screenshot/macos/ScreenshotLCPanelMac.swift b/src/screenshot/macos/ScreenshotLCPanelMac.swift new file mode 100644 index 0000000..b495297 --- /dev/null +++ b/src/screenshot/macos/ScreenshotLCPanelMac.swift @@ -0,0 +1,396 @@ +import Foundation +import AppKit +import CoreGraphics + +// MARK: - 长截图小地图面板(macOS; +// Windows 基准 lc_panel_ui_windows.cpp) +// +// 独立 NSWindow 承载只读预览(Windows 为 WS_EX_LAYERED 分层弹窗 + UpdateLayeredWindow +// 原子提交;macOS 为 borderless 透明自绘窗口 + setNeedsDisplay,刷新时机对齐): +// - 深色圆角底 RGB(52,52,53)、1px 描边 RGB(102,102,102)、圆角半径 8(LongCapturePanelRender) +// - 两级增量缩略列:算法层按固定列宽增量维护缩略缓冲(lc_read_thumb_rows 对齐 +// LongCaptureRebuildThumb 的合并序——reverse(headRev) + body;横向模式回转为显示空间, +// 对齐 LongCaptureRebuildThumbDisplay),面板只按裁剪行窗口重采样绘制 +// - 三层视口标注:灰外环 = 已捕获完整范围;蓝实线 = committed(最新提交帧精确位置, +// 60/255 半透明衬底);橙虚线 = tentative(预计视口位置,|tentative−committed| ≥ +// LC_TRACK_MIN_STEP 才显示,36/255 半透明衬底,画4空3 虚线段) +// - 停靠退化:选区右侧 → 左侧 → 下方 → 上方(LongCaptureCreatePanel 退化链) +// - 高度只增不减、上限屏高 45%(LongCapturePanelUpdate),且避让长截图工具栏与选区顶边 +// +// 刷新时机(LongCapturePanelUpdate / InvalidateRect 对齐):拼接成功(contentChanged = +// 尺寸重算 + 重绘)、tentative 跟踪变化(trackingChanged = 仅重绘)、裁剪应用 +//(contentChanged)。全部由泵循环在主线程驱动(CATransaction 隐式提交)。 + +// MARK: - 面板视图 + +/// 小地图自绘视图:把事件隔离(只读预览),绘制委托控制器完成。 +final class ScreenshotLCPanelView: NSView { + unowned let controller: ScreenshotLCPanelController + + init(controller: ScreenshotLCPanelController, frame: NSRect) { + self.controller = controller + super.init(frame: frame) + } + + required init?(coder: NSCoder) { + fatalError("ScreenshotLCPanelView is created programmatically only") + } + + override var isFlipped: Bool { return true } // 本地坐标与 CG 全局坐标同向 + + override func draw(_ dirtyRect: NSRect) { + guard let ctx = NSGraphicsContext.current?.cgContext else { return } + controller.render(ctx) + } +} + +// MARK: - 面板控制器 + +/// 小地图面板控制器:持有面板窗口与停靠/生长状态,渲染几何与视口框换算全部对齐 +/// lc_panel_ui_windows.cpp(LongCapturePanelPreviewRect / LongCaptureViewportRectAt / +/// LongCapturePanelRender / LongCapturePanelUpdate / LongCaptureCreatePanel)。 +final class ScreenshotLCPanelController { + private weak var session: ScreenshotLongCaptureSession? + + /// 面板窗口(长截图浮层族:level 高于蒙版,保证「蒙版之下」抓帧只含用户内容)。 + private(set) var window: ScreenshotPanelWindow? + private var view: ScreenshotLCPanelView? + /// 面板矩形(CG 全局逻辑坐标;顶边锚定、向下生长)。 + var panelRect: CGRect = .null + /// 面板是否停靠在选区上方(水平无空间退化链的产物;生长不得越过选区顶边防入画)。 + var panelAbove = false + + /// 面板窗口内容色(对齐 LongCapturePanelRender:底 RGB(52,52,53)、描边 RGB(102,102,102))。 + private static let bgColor = NSColor(srgbRed: 52 / 255.0, green: 52 / 255.0, blue: 53 / 255.0, alpha: 1) + private static let borderColor = NSColor(srgbRed: 102 / 255.0, green: 102 / 255.0, blue: 102 / 255.0, alpha: 1) + /// 三层视口标注色(kRingC/kBlueC/kOrngC,LongCapturePanelRender)。 + private static let ringColor = NSColor(srgbRed: 190 / 255.0, green: 190 / 255.0, blue: 195 / 255.0, alpha: 1) + private static let committedColor = NSColor(srgbRed: 0x2F / 255.0, green: 0x7E / 255.0, blue: 0xE5 / 255.0, alpha: 1) + private static let tentativeColor = NSColor(srgbRed: 0xE8 / 255.0, green: 0xA3 / 255.0, blue: 0x3C / 255.0, alpha: 1) + + init(session: ScreenshotLongCaptureSession) { + self.session = session + } + + // MARK: 生命周期 + + /// 创建面板窗口:停靠选区右侧(空间不足退左侧,再退化到选区下方/上方),初始高度按 + /// 选区采样裁剪等比(对齐 LongCaptureCreatePanel;45% 屏高上限同步生效)。 + func create() { + guard let session = session, panelRect.isNull, window == nil else { return } + let pad = LC_PANEL_PAD_PX + let margin: CGFloat = 12 + let winW = LC_PANEL_WIDTH + // 首帧即整个选区:按采样裁剪等比计算初始预览高,避免面板先闪空再放大 + let selW = max(1, session.cropRect.width) + let selH = max(1, session.cropRect.height) + let scale0 = (winW - pad * 2) / selW + var prevH0 = (selH * scale0).rounded() + let capH = session.virtualBounds.height * LC_PANEL_MAX_HEIGHT_RATIO + if prevH0 > capH { prevH0 = capH } + let winH = pad * 2 + prevH0 + + let virtual = session.virtualBounds + // 停靠退化链:右 → 左 → 下 → 上 + var x = session.selection.maxX + margin + var y = session.selection.minY + if x + winW > virtual.maxX { + x = session.selection.minX - winW - margin + } + if x < virtual.minX { + // 水平无空间(选区接近全屏宽):退化为选区下方/上方,避免面板覆盖选区入画 + x = min(max(virtual.minX + 4, session.selection.midX - winW / 2), + virtual.maxX - winW - 4) + y = session.selection.maxY + margin + if y + winH > virtual.maxY { + y = session.selection.minY - winH - margin + panelAbove = true + } + } + if y + winH > virtual.maxY { y = virtual.maxY - winH - 4 } + if y < virtual.minY { y = virtual.minY + 4 } + panelRect = CGRect(x: x, y: y, width: winW, height: winH) + + let win = ScreenshotPanelWindow( + contentRect: lcNSRect(fromCG: panelRect), styleMask: .borderless, + backing: .buffered, defer: false) + // 层级 = 蒙版 +1(蒙版 screenSaver+1):面板在蒙版之上、抓帧排除范围之内 + win.level = NSWindow.Level(rawValue: NSWindow.Level.screenSaver.rawValue + 2) + win.isOpaque = false + win.backgroundColor = .clear + win.hasShadow = false + win.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + win.isReleasedWhenClosed = false + let panelView = ScreenshotLCPanelView( + controller: self, frame: NSRect(origin: .zero, size: panelRect.size)) + win.contentView = panelView + window = win + view = panelView + win.orderFrontRegardless() + } + + /// 销毁面板窗口(长截图收束/取消时调用;幂等)。 + func destroy() { + window?.orderOut(nil) + window?.contentView = nil + window = nil + view = nil + panelRect = .null + panelAbove = false + } + + // MARK: 刷新入口 + + /// 拼接内容变化(Stitched / 裁剪应用 / 方向切换):重算面板高度(只增不减)并重绘 + /// (对齐 LongCapturePanelUpdate + InvalidateRect)。 + func contentChanged() { + growHeightIfNeeded() + view?.needsDisplay = true + } + + /// tentative 跟踪状态变化(多跳恢复/预测推进/候选否决回退):仅重绘,不触发尺寸重算 + /// (对齐 lc_session_windows.cpp 主循环的 trackingRevision 变化 → InvalidateRect 分支)。 + func trackingChanged() { + view?.needsDisplay = true + } + + /// 方向切换后完全重置(尺寸随新方向重算)。 + func resetForDirectionChange() { + contentChanged() + } + + /// 面板生长(LongCapturePanelUpdate 逐式移植):按「显示空间」等比换算预览高, + /// 屏幕下沿 / 工具栏顶边 / 选区顶边(上方停靠时)三重约束 + 屏高 45% 上限 + + /// 最小高 48;高度只增不减(向下生长,顶边锚定)。 + private func growHeightIfNeeded() { + guard let session = session, !panelRect.isNull, window != nil, + let st = session.algo?.state, st.stitchH > 0, st.physW > 0 else { return } + let pad = LC_PANEL_PAD_PX + let winW = panelRect.width + let availW = winW - pad * 2 + let win = session.outputRowWindow + let rows = win.bottom - win.top + guard rows > 0, availW >= 1 else { return } + // 显示空间逻辑尺寸统一公式:固定轴取 cropRect 逻辑尺寸(无 /ds 舍入 + // 往返误差),滚动轴取 rows / scale + let cropW = session.cropRect.width + let cropH = session.cropRect.height + let dispWLogical = max(session.horizontal ? CGFloat(rows) / session.scale : cropW, 1) + let dispHLogical = session.horizontal ? cropH : CGFloat(rows) / session.scale + let prevH = (dispHLogical * (availW / dispWLogical)).rounded() + // 屏幕下沿约束:预览不超过面板顶部以下剩余空间与屏高 45% + var roomH = session.virtualBounds.maxY - 4 - panelRect.minY - pad * 2 + // 选区底部工具栏避让:水平范围与面板重叠时,面板生长不得越过工具栏顶边 + if let tb = session.toolbar?.barRect, + tb.minX < panelRect.maxX + 8, tb.maxX > panelRect.minX - 8, tb.minY > panelRect.minY { + let byTb = tb.minY - 8 - panelRect.minY - pad * 2 + if byTb < roomH { roomH = byTb } + } + // 面板在选区上方时:生长不得越过选区顶边(否则面板入画) + if panelAbove { + let bySel = session.selection.minY - 12 - panelRect.minY - pad * 2 + if bySel < roomH { roomH = bySel } + } + var capH = session.virtualBounds.height * LC_PANEL_MAX_HEIGHT_RATIO // 屏高 45% 上限 + if roomH < capH { capH = roomH } + if capH < 48 { capH = 48 } // LC_PANEL_MIN_H + var prevHCapped = prevH + if prevHCapped > capH { prevHCapped = capH } + let newH = pad * 2 + prevHCapped + if newH > panelRect.height { + // 只增不减:顶边锚定向下生长(Windows SetWindowPos(SWP_NOMOVE) 语义) + panelRect = CGRect(x: panelRect.minX, y: panelRect.minY, width: winW, height: newH) + window?.setFrame(lcNSRect(fromCG: panelRect), display: true) + view?.frame = NSRect(origin: .zero, size: panelRect.size) + } + } + + // MARK: 渲染几何(LongCapturePanelPreviewRect / LongCaptureViewportRectAt 移植) + + /// 预览图目标矩形(面板本地坐标):拼接结果等比缩放,水平居中、垂直居中于面板内边距。 + /// 尺寸取「显示空间」:纵向 = physW×行数;横向 = 行数×physW(回转后宽高互换), + /// 行数含裁剪窗口(裁掉的部分不进预览)。 + private func previewRect(in bounds: CGRect) -> CGRect { + guard let session = session, let st = session.algo?.state, + st.stitchH > 0, st.physW > 0 else { return .zero } + let pad = LC_PANEL_PAD_PX + let availW = bounds.width - pad * 2 + let availH = bounds.height - pad * 2 + guard availW >= 1, availH >= 1 else { return .zero } + let win = session.outputRowWindow + let rows = win.bottom - win.top + guard rows > 0 else { return .zero } + let dispW: CGFloat = session.horizontal ? CGFloat(rows) : CGFloat(st.physW) + let dispH: CGFloat = session.horizontal ? CGFloat(st.physW) : CGFloat(rows) + let scale = min(availW / dispW, availH / dispH) + let pw = (dispW * scale).rounded() + let ph = (dispH * scale).rounded() + let left = (bounds.width - pw) / 2 + let top = pad + (availH - ph) / 2 + return CGRect(x: left, y: top, width: pw, height: ph) + } + + /// 内容坐标 → 预览像素的视口框(对齐 LongCaptureViewportRectAt): + /// contentTop + headRows = 拼接图内位置,等比缩放并钳制进裁剪后的预览范围。 + /// 移动轴贴合预览区两端时该侧边界再外扩 1px(蓝框在极值处完整封边,语义 = 当前 + /// 视图之外无已捕获内容);固定轴(纵向=左右、横向=上下)恒向外扩 1px——扩出的 + /// 框线恰落在整体描边环上,整像素压住描边。横向模式的「行位置」映射为水平位置。 + private func viewportRectAt(preview: CGRect, contentTop: Int64) -> CGRect { + guard let session = session, let st = session.algo?.state, + st.stitchH > 0, st.physW > 0, + preview.width > 0, preview.height > 0 else { return .zero } + let win = session.outputRowWindow + let rowStart = win.top + let rowEnd = win.bottom + let rows = rowEnd - rowStart + guard rows > 0 else { return .zero } + if rows <= Int64(st.physH) { + // 未拼接滚动:整体即当前区域(四边均贴端外扩) + return preview.insetBy(dx: -1, dy: -1) + } + var topStitch = contentTop + Int64(st.headRows) // 内容坐标 → 拼接坐标 + if topStitch < rowStart { topStitch = rowStart } + var tailLimit = rowEnd - Int64(st.physH) + if tailLimit < rowStart { tailLimit = rowStart } + if topStitch > tailLimit { topStitch = tailLimit } + let atHead = topStitch <= rowStart // 带抵输出窗口头端 + let atTail = topStitch >= tailLimit // 带抵尾端 + if !session.horizontal { + let scale = preview.height / CGFloat(rows) + var vhPx = (CGFloat(st.physH) * scale).rounded() + if vhPx < 1 { vhPx = 1 } + let topPx = preview.minY + (CGFloat(topStitch - rowStart) * scale).rounded() + return CGRect(x: preview.minX - 1, y: topPx - (atHead ? 1 : 0), + width: preview.width + 2, + height: vhPx + (atHead ? 1 : 0) + (atTail ? 1 : 0)) + } else { + let scale = preview.width / CGFloat(rows) + var vwPx = (CGFloat(st.physH) * scale).rounded() + if vwPx < 1 { vwPx = 1 } + let leftPx = preview.minX + (CGFloat(topStitch - rowStart) * scale).rounded() + return CGRect(x: leftPx - (atHead ? 1 : 0), y: preview.minY - 1, + width: vwPx + (atHead ? 1 : 0) + (atTail ? 1 : 0), + height: preview.height + 2) + } + } + + /// 已确认(committed)视口框:最新一次提交帧的精确位置(LongCaptureViewportRect)。 + private func committedRect(preview: CGRect) -> CGRect { + guard let st = session?.algo?.state else { return .zero } + return viewportRectAt(preview: preview, contentTop: st.committedContentTop) + } + + // MARK: 渲染(LongCapturePanelRender 移植) + + /// 在已翻转(top-left 原点)的上下文中绘制 CGImage:CGImage 行序为 top-down 而 + /// CGContextDrawImage 按未翻转坐标绘制,需翻转 Y 轴避免上下颠倒(ScreenshotPaintMac + /// 的同名会话方法为实例方法,面板侧本地实现同款翻转)。 + private func drawCGImageFlipped(_ ctx: CGContext, _ image: CGImage, in rect: CGRect) { + ctx.saveGState() + ctx.translateBy(x: rect.midX, y: rect.midY) + ctx.scaleBy(x: 1, y: -1) + ctx.draw(image, in: CGRect(x: -rect.width / 2, y: -rect.height / 2, + width: rect.width, height: rect.height)) + ctx.restoreGState() + } + + /// 整幅渲染小地图面板:清透明 → 深色圆角底 + 内缩描边 → 缩略小地图(裁剪行窗口内 + /// 重采样)→ 视口框三层标注(半透明衬底 / 实线蓝框 / 虚线橙框)。 + /// - Parameter ctx: 视图 CG 上下文(isFlipped,本地坐标 = 面板本地) + func render(_ ctx: CGContext) { + guard let session = session else { return } + let bounds = view?.bounds ?? .zero + guard bounds.width >= 1, bounds.height >= 1 else { return } + + // 1) 深色圆角底 + 内缩整像素描边(半径 8;描边路径整体内缩 0.5px,1px 笔画 + // 完整落在边界像素带内——对齐 LongCapturePanelRender 的抗锯齿/圆角外残留修复) + let radius: CGFloat = 8 + let bgPath = CGPath(roundedRect: bounds, cornerWidth: radius, cornerHeight: radius, transform: nil) + ctx.addPath(bgPath) + ctx.setFillColor(Self.bgColor.cgColor) + ctx.fillPath() + let borderRect = bounds.insetBy(dx: 0.5, dy: 0.5) + let borderPath = CGPath(roundedRect: borderRect, + cornerWidth: max(radius - 0.5, 1), cornerHeight: max(radius - 0.5, 1), + transform: nil) + ctx.addPath(borderPath) + ctx.setStrokeColor(Self.borderColor.cgColor) + ctx.setLineWidth(1) + ctx.strokePath() + + // 2) 缩略小地图:先按固定列宽增量缩列的缩略缓冲(算法层维护),按裁剪行窗口 + // 重采样绘制(避免逐帧重读拼接大缓冲;横向模式面板已回转为显示空间) + let preview = previewRect(in: bounds) + if preview.width > 0, preview.height > 0, + let thumb = session.readPreviewThumbImage() { + ctx.saveGState() + ctx.clip(to: preview) // 先裁剪再绘制,防高质量插值渗到描边上 + ctx.interpolationQuality = .high + drawCGImageFlipped(ctx, thumb, in: preview) + ctx.restoreGState() + } + guard preview.width > 0, preview.height > 0, + let st = session.algo?.state, st.stitchH > 0 else { return } + + // 3) 三层视口标注 + let vp = committedRect(preview: preview) + let vpValid = vp.width > 0 && vp.height > 0 + // tentative 框显示判定:tentativeValid 且与 committed 差 ≥ LC_TRACK_MIN_STEP + //(小于该位移为噪声不推进,lc_panel_ui_windows.cpp 同款;常量经 lc_get_algo_consts 取用) + var tp = CGRect.zero + var tpShow = false + if st.tentativeValid { + var tdiff = st.tentativeContentTop - st.committedContentTop + if tdiff < 0 { tdiff = -tdiff } + if tdiff >= Int64(LCAlgoConsts.shared.trackMinStep) { + tp = viewportRectAt(preview: preview, contentTop: st.tentativeContentTop) + tpShow = tp.width > 0 && tp.height > 0 + } + } + // 半透明衬底只铺描边内侧、向内收 1px;先画衬底再叠不透明描边 + if vpValid { + ctx.setFillColor(Self.committedColor.withAlphaComponent(60.0 / 255.0).cgColor) + ctx.fill(vp.insetBy(dx: 1, dy: 1)) + } + if tpShow { + ctx.setFillColor(Self.tentativeColor.withAlphaComponent(36.0 / 255.0).cgColor) + ctx.fill(tp.insetBy(dx: 1, dy: 1)) + } + // 1) 整体环:预览图外沿相邻一像素(左右列 left-1/right、上下行 top-1/bottom) + ctx.setFillColor(Self.ringColor.cgColor) + ctx.fill(CGRect(x: preview.minX - 1, y: preview.minY - 1, width: 1, height: preview.height + 2)) + ctx.fill(CGRect(x: preview.maxX, y: preview.minY - 1, width: 1, height: preview.height + 2)) + ctx.fill(CGRect(x: preview.minX - 1, y: preview.minY - 1, width: preview.width + 2, height: 1)) + ctx.fill(CGRect(x: preview.minX - 1, y: preview.maxY, width: preview.width + 2, height: 1)) + // 2) 视口蓝框:四条边界条各占一整像素行/列——固定轴两条恰压住整体环 + if vpValid { + ctx.setFillColor(Self.committedColor.cgColor) + ctx.fill(CGRect(x: vp.minX, y: vp.minY, width: vp.width, height: 1)) + ctx.fill(CGRect(x: vp.minX, y: vp.maxY - 1, width: vp.width, height: 1)) + ctx.fill(CGRect(x: vp.minX, y: vp.minY + 1, width: 1, height: vp.height - 2)) + ctx.fill(CGRect(x: vp.maxX - 1, y: vp.minY + 1, width: 1, height: vp.height - 2)) + } + // 3) tentative 橙色虚线框:四边按「画4空3」分段(超出已捕获范围时贴边停驻) + if tpShow { + ctx.setFillColor(Self.tentativeColor.cgColor) + func dashH(_ y: CGFloat, _ x0: CGFloat, _ x1: CGFloat) { + var x = x0 + while x < x1 { + ctx.fill(CGRect(x: x, y: y, width: min(4, x1 - x), height: 1)) + x += 7 + } + } + func dashV(_ x: CGFloat, _ y0: CGFloat, _ y1: CGFloat) { + var y = y0 + while y < y1 { + ctx.fill(CGRect(x: x, y: y, width: 1, height: min(4, y1 - y))) + y += 7 + } + } + dashH(tp.minY, tp.minX, tp.maxX) + dashH(tp.maxY - 1, tp.minX, tp.maxX) + dashV(tp.minX, tp.minY + 1, tp.maxY - 1) + dashV(tp.maxX - 1, tp.minY + 1, tp.maxY - 1) + } + } +} diff --git a/src/screenshot/macos/ScreenshotLCToolbarMac.swift b/src/screenshot/macos/ScreenshotLCToolbarMac.swift new file mode 100644 index 0000000..bee422c --- /dev/null +++ b/src/screenshot/macos/ScreenshotLCToolbarMac.swift @@ -0,0 +1,1026 @@ +import Foundation +import AppKit +import CoreGraphics + +// MARK: - 长截图工具栏(macOS; +// Windows 基准 lc_toolbar_ui_windows.cpp) +// +// 选区底部悬浮工具栏(独立 TOPMOST 弹窗,位于灰蒙版之上),从左到右(图标按钮): +// [6点把手] [预览宽×高] | [方向] [自动滚动] [裁剪] | [保存到本地] [取消] [完成并复制] +// Windows 为 WS_EX_LAYERED 单窗口(popover 展开时窗口整体伸缩、ULW 原子提交);macOS +// 改为「底条窗口 + 独立 popover 窗口 + tooltip 窗口」三浮层(编辑工具栏同款架构), +// 视觉与交互逐条对齐: +// - 布局常量(逻辑像素):条高 44、按钮 32、间距 2、尺寸标签 104、内边距 6、分隔线 13、 +// 圆角 8、选区间距 8、popover 高 44 / pad 6 / cell 32 / gap 2(LC_BAR_*/LC_POP_*) +// - 方向/裁剪二级 popover:悬停 300ms 展开(LC_POP_OPEN_DWELL_MS,扫过不误触)、鼠标离开 +// 「锚点按钮∪popover」250ms 宽限收起(LC_POP_CLOSE_GRACE_MS)、点击开合并解除悬停武装 +// (popHoverDisarm 语义);展开方向永远避让选区(LongCaptureMenuOpenBelow)——菜单浮层 +// 绝不进入选区画面,否则会被逐帧采样采进拼接内容、污染重叠识别基准 +// - 方向锁定:已拼接多帧(frameCount > 1)后方向按钮置灰、popover 不再展开(两个方向的 +// 内容坐标系不同,混拼必然错位);自动滚动图标随方向切换 V/H 变体 +// - 裁剪 badge:已裁剪(cropped,含待剔除区间登记)时裁剪按钮 active 高亮 +// - title 式 tooltip:悬停 500ms(LC_TIP_DELAY_MS)深色圆角气泡,锚定目标上方/下方 +// - 按钮按下-抬起同目标校验(防「进入长截图瞬间残留的鼠标抬起」误触自动滚动等按钮) +// +// 事件模型:NOACTIVATE 浮层无焦点,hover/菜单/tooltip 全部由泵循环 100ms UI 节拍轮询 +// (NSEvent.mouseLocation / pressedMouseButtons,对齐 Windows LongCaptureToolbarUiTick 的 +// GetAsyncKeyState + GetCursorPos 轮询);点击由浮层视图 mouseDown/Up 承接。 + +// MARK: - 常量(Windows 出处集中标注) + +/// 工具栏几何(lc_toolbar_ui_windows.cpp LC_BAR_*,逻辑像素)。 +private let LC_BAR_H: CGFloat = 44 // 工具栏高度(图标按钮 32 + 上下内边距 6) +private let LC_BAR_BTN: CGFloat = 32 // 图标按钮宽度(正方形 cell) +private let LC_BAR_GAP: CGFloat = 2 // 相邻图标按钮间距 +private let LC_SIZE_W: CGFloat = 104 // 预览宽×高标签占位宽 +private let LC_BAR_PAD: CGFloat = 6 // 左右内边距 +private let LC_BAR_SEP_W: CGFloat = 13 // 分隔线占位宽(含两侧间距) +private let LC_BAR_RADIUS: CGFloat = 8 // 圆角半径 +private let LC_BAR_MARGIN: CGFloat = 8 // 选区到工具栏间距 +private let LC_MENU_GAP: CGFloat = 6 // popover 与工具栏间距 + +/// 裁剪/方向 popover 几何(lc_toolbar_ui_windows.cpp LC_POP_*)。 +private let LC_POP_H: CGFloat = 44 // popover 面板高(图标 cell 32 + 上下内边距 6) +private let LC_POP_PAD: CGFloat = 6 // popover 面板内边距 +private let LC_POP_CELL: CGFloat = 32 // popover 图标 cell 宽(与工具栏按钮同尺寸) +private let LC_POP_CELL_GAP: CGFloat = 2 // popover 图标 cell 间距 + +/// 二级 popover 悬停展开/宽限收起/tooltip 延时(lc_toolbar_ui_windows.cpp)。 +let LC_POP_OPEN_DWELL_MS: UInt64 = 300 // 锚点按钮悬停多久后展开(悬停意图判定,扫过不误触) +let LC_POP_CLOSE_GRACE_MS: UInt64 = 250 // 鼠标离开「锚点∪popover」多久后收起(跨间隙宽限) +let LC_TIP_DELAY_MS: UInt64 = 500 // 图标悬停多久后显示 tooltip(网页 title 同款节奏) + +/// 图标三态色(lc_toolbar_ui_windows.cpp LCIconCache:dark RGB(60,60,60) / blue RGB(9,105,218) / +/// gray RGB(178,178,178);hover/active 底 = SC_THEME_HOVER_BG/SEL_BG)。 +private let lcIconDark = NSColor(srgbRed: 60 / 255.0, green: 60 / 255.0, blue: 60 / 255.0, alpha: 1) +private let lcIconBlue = NSColor(srgbRed: 9 / 255.0, green: 105 / 255.0, blue: 218 / 255.0, alpha: 1) +private let lcIconGray = NSColor(srgbRed: 178 / 255.0, green: 178 / 255.0, blue: 178 / 255.0, alpha: 1) +private let lcHoverBg = NSColor(srgbRed: 235 / 255.0, green: 243 / 255.0, blue: 255 / 255.0, alpha: 1) +private let lcActiveBg = NSColor(srgbRed: 225 / 255.0, green: 237 / 255.0, blue: 253 / 255.0, alpha: 1) +private let lcBorderCol = NSColor(srgbRed: 210 / 255.0, green: 210 / 255.0, blue: 210 / 255.0, alpha: 1) +private let lcSepCol = NSColor(srgbRed: 230 / 255.0, green: 230 / 255.0, blue: 230 / 255.0, alpha: 1) +private let lcSizeLabelCol = NSColor(srgbRed: 130 / 255.0, green: 130 / 255.0, blue: 130 / 255.0, alpha: 1) +private let lcGripDotCol = NSColor(srgbRed: 165 / 255.0, green: 165 / 255.0, blue: 165 / 255.0, alpha: 1) + +/// 圆角矩形路径(本地辅助;工具栏 roundedRectPath 同款实现)。 +private func lcRoundedRectPath(_ rect: CGRect, _ radius: CGFloat) -> CGPath { + return CGPath(roundedRect: rect, cornerWidth: radius, cornerHeight: radius, transform: nil) +} + +/// 鼠标当前位置(CG 全局逻辑坐标;NSEvent.mouseLocation 为 NS 坐标需 Y 翻转)。 +private func lcCurrentMouseCG() -> CGPoint { + return ScreenshotGeometry.cgPoint(fromNS: NSEvent.mouseLocation) +} + +// MARK: - 工具栏项目与二级菜单 + +/// 工具栏项目(顺序即布局顺序;分隔线不可点击;对齐 lc_toolbar_ui_windows.cpp LongToolbarItem)。 +private enum LCItem: Int, CaseIterable { + case grip = 0 // 拖拽把手(6 点图标) + case size // 预览宽×高标签(纯展示) + case sep1 // 分隔线 + case direction // 方向(悬停/点击展开 popover:纵向/横向;已拼接多帧后锁定) + case autoScroll // 自动滚动开关(开启态高亮;图标随方向切换 V/H 变体) + case crop // 裁剪(悬停/点击展开 popover;badge = 已裁剪高亮) + case sep3 // 分隔线 + case save // 保存到本地 + case cancel // 取消 + case finish // 完成并复制 +} + +/// 二级菜单种类(对齐 LCMenuKind)。 +private enum LCMenuKindEquatable: Equatable { + case none + case direction + case crop +} + +// MARK: - 浮层视图(事件转发) + +/// 工具栏底条自绘视图:绘制委托控制器;点击/拖动换算 CG 坐标转发。 +final class ScreenshotLCToolbarView: NSView { + unowned let controller: ScreenshotLCToolbarController + + init(controller: ScreenshotLCToolbarController, frame: NSRect) { + self.controller = controller + super.init(frame: frame) + } + + required init?(coder: NSCoder) { + fatalError("ScreenshotLCToolbarView is created programmatically only") + } + + override var isFlipped: Bool { return true } + + // 首击穿透:与 OverlayScreenshotView.acceptsFirstMouse 同因——App 未激活(协作式激活 + // 失败)时,非 key 浮层窗口的首次点击会被 AppKit 当"激活点击"吞掉,长截图工具栏 + // 按钮(完成/取消/方向锁定等)第一次点按无响应;覆写后首击直达本视图。 + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { return true } + + override func draw(_ dirtyRect: NSRect) { + guard let ctx = NSGraphicsContext.current?.cgContext else { return } + controller.drawBar(ctx) + } + + /// 本地坐标 → CG 全局坐标(视图 (0,0) = 底条矩形左上角)。 + private func cgPoint(from event: NSEvent) -> CGPoint { + let local = convert(event.locationInWindow, from: nil) + return CGPoint(x: (local.x + controller.barRect.minX).rounded(), + y: (local.y + controller.barRect.minY).rounded()) + } + + override func mouseDown(with event: NSEvent) { + controller.handleBarMouseDown(cgPoint(from: event)) + } + + override func mouseDragged(with event: NSEvent) { + controller.handleBarMouseDragged(cgPoint(from: event)) + } + + override func mouseUp(with event: NSEvent) { + controller.handleBarMouseUp(cgPoint(from: event)) + } +} + +/// popover 自绘视图:绘制委托控制器;点击换算 CG 坐标做 cell 命中。 +final class ScreenshotLCPopoverView: NSView { + unowned let controller: ScreenshotLCToolbarController + + init(controller: ScreenshotLCToolbarController, frame: NSRect) { + self.controller = controller + super.init(frame: frame) + } + + required init?(coder: NSCoder) { + fatalError("ScreenshotLCPopoverView is created programmatically only") + } + + override var isFlipped: Bool { return true } + + // 首击穿透:同 ScreenshotLCToolbarView——App 未激活时 popover 首次点击会被吞掉。 + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { return true } + + override func draw(_ dirtyRect: NSRect) { + guard let ctx = NSGraphicsContext.current?.cgContext else { return } + controller.drawPopover(ctx) + } + + override func mouseDown(with event: NSEvent) { + let local = convert(event.locationInWindow, from: nil) + let cg = CGPoint(x: (local.x + controller.popoverRect.minX).rounded(), + y: (local.y + controller.popoverRect.minY).rounded()) + controller.handlePopoverMouseDown(cg) + } +} + +/// 长截图 tooltip 气泡视图:深色圆角底 + 白色居中文本(TooltipPanelView 同款视觉)。 +final class ScreenshotLCTipView: NSView { + let text: String + + init(text: String, frame: NSRect) { + self.text = text + super.init(frame: frame) + } + + required init?(coder: NSCoder) { + fatalError("ScreenshotLCTipView is created programmatically only") + } + + override var isFlipped: Bool { return true } + + override func draw(_ dirtyRect: NSRect) { + guard let ctx = NSGraphicsContext.current?.cgContext else { return } + let path = lcRoundedRectPath(bounds, 4) // 圆角 4(LongCaptureTooltipRender) + ctx.addPath(path) + ctx.setFillColor(SC.tipBg.cgColor) // RGB(41,41,41) + ctx.fillPath() + let attr = NSAttributedString(string: text, attributes: [ + .font: NSFont.systemFont(ofSize: SC.fontPx), + .foregroundColor: NSColor.white, + ]) + let size = attr.size() + attr.draw(at: NSPoint(x: (bounds.width - ceil(size.width)) / 2, + y: (bounds.height - ceil(size.height)) / 2)) + } +} + +// MARK: - 工具栏控制器 + +/// 长截图工具栏控制器:底条/popover/tooltip 三浮层 + 全部 UI 状态,按钮动作回话给 +/// ScreenshotLongCaptureSession(finish/save/abort 标志 + 自动滚动/方向/裁剪)。 +/// 100ms UI 节拍由长截图会话泵循环驱动(uiTick)。 +final class ScreenshotLCToolbarController { + private weak var session: ScreenshotLongCaptureSession? + + // ---- 三浮层窗口 ---- + private(set) var window: ScreenshotPanelWindow? + private var barView: ScreenshotLCToolbarView? + private(set) var popoverWindow: ScreenshotPanelWindow? + private var popoverView: ScreenshotLCPopoverView? + private(set) var tipWindow: ScreenshotPanelWindow? + private var tipDwelling = false + + /// 底条矩形(CG 全局逻辑坐标;菜单开合不改变底条屏幕位置——独立 popover 架构下 + /// 天然成立;小地图避让与生长约束读取本值)。 + private(set) var barRect: CGRect = .null + /// popover 矩形(CG 全局逻辑坐标;展开时有效)。 + private(set) var popoverRect: CGRect = .null + + // ---- UI 状态(对齐 LcUiState + LongCaptureToolbarUiTick 的文件级 static)---- + private var hoverItem = -1 // 悬停的底条项目(LCItem.rawValue;-1 无) + private var menuHover = -1 // 悬停的 popover cell(-1 无) + private var pressItem = -1 // 按下目标(UP 同目标校验) + private var pressMenuRow = -1 + private var dragging = false // 把手拖拽中 + private var dragGrabDX: CGFloat = 0 // 抓取偏移(收起菜单后计算,防菜单高计入) + private var dragGrabDY: CGFloat = 0 + private var lDown = false // 全局左键状态(UiTick 轮询) + private var menuKind: LCMenuKindEquatable = .none + private var menuBelow = false // popover 展开方向(避让选区) + private var popHoverDisarm: LCMenuKindEquatable = .none // 点击收起后的悬停武装解除 + private var popHoverSinceMs: UInt64 = 0 // 悬停锚点起始时刻(0=不在) + private var popLeaveSinceMs: UInt64 = 0 // 离开「锚点∪popover」起始时刻(0=未离开) + private var tipTarget = -1 // tooltip 停顿目标(底条 item 或 100+kind*10+cell) + private var tipSinceMs: UInt64 = 0 + private var tipShown = false + + init(session: ScreenshotLongCaptureSession) { + self.session = session + } + + // MARK: 布局(LongCaptureToolbarLayout 的底条段移植) + + /// 各项目矩形(底条本地坐标):图标按钮为等宽正方形 cell 垂直居中,分隔线占位宽 + /// 自带两侧间距。 + private func layoutItemRects() -> [CGRect] { + var rects = [CGRect](repeating: .zero, count: LCItem.allCases.count) + var x = LC_BAR_PAD + let btnTop = (LC_BAR_H - LC_BAR_BTN) / 2 + for item in LCItem.allCases { + switch item { + case .grip, .direction, .autoScroll, .crop, .save, .cancel, .finish: + rects[item.rawValue] = CGRect(x: x, y: btnTop, width: LC_BAR_BTN, height: LC_BAR_BTN) + x += LC_BAR_BTN + LC_BAR_GAP + case .size: + rects[item.rawValue] = CGRect(x: x, y: btnTop, width: LC_SIZE_W, height: LC_BAR_BTN) + x += LC_SIZE_W + LC_BAR_GAP + case .sep1, .sep3: + rects[item.rawValue] = CGRect(x: x, y: 10, width: LC_BAR_SEP_W, height: LC_BAR_H - 20) + x += LC_BAR_SEP_W + } + } + return rects + } + + /// 底条总宽(与 layoutItemRects 的横向排布严格一致;对齐 LongCaptureToolbarWindowWidth)。 + private var barWidth: CGFloat { + return layoutItemRects().map { $0.maxX }.max().map { $0 + LC_BAR_PAD } ?? 0 + } + + /// 二级菜单 popover 行数(= 图标 cell 数):方向恒 2(纵向/横向);裁剪 2 +(已裁剪时)重置。 + private func menuRows() -> Int { + guard let session = session else { return 0 } + if menuKind == .direction { return 2 } + if menuKind == .crop { return session.isCropped ? 3 : 2 } + return 0 + } + + /// 二级菜单的锚点项目(popover 水平居中对齐、悬停展开与离开收起均围绕锚点判定)。 + private var menuAnchorItem: Int { + return menuKind == .crop ? LCItem.crop.rawValue : LCItem.direction.rawValue + } + + /// 悬停的底条按钮将展开的二级菜单:裁剪恒可展开;方向在已拼接多帧(frameCount>1)后 + /// 锁定(锁定期间悬停/点击均不展开)。 + private func hoverMenuKind(_ hv: Int) -> LCMenuKindEquatable { + guard let session = session else { return .none } + if hv == LCItem.crop.rawValue { return .crop } + if hv == LCItem.direction.rawValue && session.frameCount <= 1 { return .direction } + return .none + } + + /// popover 第 i 个 cell 矩形(popover 本地坐标:面板内从左到右等宽排布)。 + private func popoverCellRect(_ i: Int) -> CGRect { + let left = LC_POP_PAD + CGFloat(i) * (LC_POP_CELL + LC_POP_CELL_GAP) + return CGRect(x: left, y: LC_POP_PAD, width: LC_POP_CELL, height: LC_POP_CELL) + } + + /// 命中二级菜单 popover 图标 cell(-1 = 不在 popover 内;point 为 CG 全局坐标)。 + private func hitTestPopover(_ point: CGPoint) -> Int { + guard menuRows() > 0, !popoverRect.isNull else { return -1 } + guard scPointInRect(point, popoverRect) else { return -1 } + let local = CGPoint(x: point.x - popoverRect.minX, y: point.y - popoverRect.minY) + for i in 0.. Int { + guard !barRect.isNull, scPointInRect(point, barRect) else { return -1 } + let local = CGPoint(x: point.x - barRect.minX, y: point.y - barRect.minY) + let rects = layoutItemRects() + for item in LCItem.allCases { + if item == .sep1 || item == .sep3 { continue } + if scPointInRect(local, rects[item.rawValue]) { return item.rawValue } + } + return -1 + } + + // MARK: 生命周期 + + /// 创建底条窗口:选区下方居中(放不下退上方、再退选区内底部),避让右侧小地图面板 + /// (对齐 LongCaptureCreateToolbar 的放置边界与退化链)。 + func create() { + guard let session = session, barRect.isNull, window == nil else { return } + let w = barWidth + let h = LC_BAR_H + let margin = LC_BAR_MARGIN + // 放置边界取「选区所在显示器」(多屏异分辨率时整虚拟屏包络会被高分屏拉大) + let bounds = scMonitorBounds(for: session.selection) ?? session.virtualBounds + let sel = session.selection + var x = sel.midX - w / 2 + var y = sel.maxY + margin + // 选区下方放不下 → 上方 + if y + h > bounds.maxY { y = sel.minY - margin - h } + // 上方也放不下 → 贴近底部(选区内底边),并钳回显示器范围兜底 + if y < bounds.minY { + y = sel.maxY - margin - h + if y < sel.minY { y = sel.minY + margin } + if y + h > bounds.maxY { y = bounds.maxY - h } + if y < bounds.minY { y = bounds.minY } + } + if x + w > bounds.maxX - 4 { x = bounds.maxX - w - 4 } + if x < bounds.minX + 4 { x = bounds.minX + 4 } + // 小地图面板避让:面板与工具栏矩形重叠时把工具栏左移到面板左侧 + if let pr = session.panel?.panelRect, !pr.isNull, + pr.minX < x + w, pr.maxX > x, pr.minY < y + h, pr.maxY > y { + let nx = pr.minX - margin - w + if nx >= session.virtualBounds.minX + 4 { x = nx } + } + barRect = CGRect(x: x, y: y, width: w, height: h) + let win = ScreenshotPanelWindow( + contentRect: lcNSRect(fromCG: barRect), styleMask: .borderless, + backing: .buffered, defer: false) + // 层级 = 小地图 +1(小地图 = 蒙版 +1):整族在蒙版之上、抓帧排除范围之内 + win.level = NSWindow.Level(rawValue: NSWindow.Level.screenSaver.rawValue + 3) + win.isOpaque = false + win.backgroundColor = .clear + win.hasShadow = false + win.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + win.isReleasedWhenClosed = false + let view = ScreenshotLCToolbarView( + controller: self, frame: NSRect(origin: .zero, size: barRect.size)) + win.contentView = view + window = win + barView = view + win.orderFrontRegardless() + } + + /// 销毁全部浮层(长截图收束/取消时调用;幂等)。 + func destroy() { + for win in [window, popoverWindow, tipWindow] { + win?.orderOut(nil) + win?.contentView = nil + } + window = nil + barView = nil + popoverWindow = nil + popoverView = nil + tipWindow = nil + barRect = .null + popoverRect = .null + hoverItem = -1 + menuHover = -1 + pressItem = -1 + pressMenuRow = -1 + dragging = false + menuKind = .none + popHoverDisarm = .none + popHoverSinceMs = 0 + popLeaveSinceMs = 0 + tipTarget = -1 + tipShown = false + } + + // MARK: 刷新入口 + + /// 全量重绘(hover/菜单开合/宽×高标签/自动滚动开关/裁剪 badge 等任何状态变化后调用; + /// 对齐 LongCaptureToolbarRepaint)。 + func refreshAll() { + barView?.needsDisplay = true + if menuKind != .none { popoverView?.needsDisplay = true } + } + + /// 收起二级菜单(保存模态等场景;对齐 LongCaptureSetMenu(c, LCM_None) 的收起段)。 + func closeMenu() { + openMenu(.none) + } + + // MARK: 二级菜单开合 + + /// 展开二级菜单(.direction/.crop)或收起(.none)。独立 popover 架构:底条位置不动, + /// popover 窗口按锚点水平居中 + 避让选区方向显示/隐藏。菜单间直接切换时先隐藏旧浮层。 + /// 菜单开/关/切换同时收起 tooltip 并重置停顿(对齐 LongCaptureSetMenu 的 TooltipCancel)。 + private func openMenu(_ kind: LCMenuKindEquatable) { + guard let session = session else { return } + guard menuKind != kind else { return } + let oldKind = menuKind + menuKind = kind + menuHover = -1 + hideTip() + tipTarget = -1 + if kind == .none { + popoverWindow?.orderOut(nil) + popoverRect = .null + barView?.needsDisplay = true + return + } + // 展开方向避让选区(LongCaptureMenuOpenBelow):底条在选区下方 → 向下展开; + // 在上方 → 向上;重叠兜底形态选屏幕空余较大的一侧;仅当远离侧放不下才翻转 + let rows = menuRows() + let pw = LC_POP_PAD * 2 + CGFloat(rows) * LC_POP_CELL + CGFloat(max(rows - 1, 0)) * LC_POP_CELL_GAP + let ph = LC_POP_H + var below: Bool + let sel = session.selection + if barRect.minY >= sel.maxY - 2 { below = true } + else if barRect.maxY <= sel.minY + 2 { below = false } + else { below = (barRect.minY - session.virtualBounds.minY) + < (session.virtualBounds.maxY - barRect.maxY) } + let mon = scMonitorBounds(for: barRect) ?? session.virtualBounds + if below && barRect.maxY + ph + LC_MENU_GAP > mon.maxY { below = false } + else if !below && barRect.minY - ph - LC_MENU_GAP < mon.minY { below = true } + menuBelow = below + // 水平居中对齐锚点按钮并夹在底条范围内 + let rects = layoutItemRects() + let anchorLocal = rects[menuAnchorItem] + let anchorCenterX = barRect.minX + anchorLocal.midX + var px = anchorCenterX - pw / 2 + if px < barRect.minX { px = barRect.minX } + if px + pw > barRect.maxX { px = barRect.maxX - pw } + let py = below ? barRect.maxY + LC_MENU_GAP : barRect.minY - LC_MENU_GAP - ph + popoverRect = CGRect(x: px, y: py, width: pw, height: ph) + if popoverWindow == nil { + let win = ScreenshotPanelWindow( + contentRect: lcNSRect(fromCG: popoverRect), styleMask: .borderless, + backing: .buffered, defer: false) + win.level = NSWindow.Level(rawValue: NSWindow.Level.screenSaver.rawValue + 4) + win.isOpaque = false + win.backgroundColor = .clear + win.hasShadow = false + win.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + win.isReleasedWhenClosed = false + let view = ScreenshotLCPopoverView( + controller: self, frame: NSRect(origin: .zero, size: popoverRect.size)) + win.contentView = view + popoverWindow = win + popoverView = view + } else { + popoverWindow?.setFrame(lcNSRect(fromCG: popoverRect), display: true) + popoverView?.frame = NSRect(origin: .zero, size: popoverRect.size) + } + _ = oldKind + popoverWindow?.orderFrontRegardless() + barView?.needsDisplay = true + } + + // MARK: UI 维护节拍(100ms;LongCaptureToolbarUiTick 逐段移植) + + /// UI 维护节拍(由长截图会话泵循环每 100ms 调用): + /// 1) popover 展开时检测「窗口外左键按下」并关闭(底条∪popover 区域外的透底不算); + /// 2) 悬停意图:方向/裁剪锚点按钮停留 300ms 后展开(另一菜单已展开时直接切换;扫过 + /// 不误触);离开「锚点∪popover」超过 250ms 后收起(宽限期足够跨过透底间隙); + /// 3) title 式 tooltip:悬停目标稳定 500ms 后显示(目标切换即重置停顿)。 + func uiTick() { + guard session != nil, window != nil else { return } + let now = lcNowMs() + let mouse = lcCurrentMouseCG() + // 全局左键按下沿检测(GetAsyncKeyState(VK_LBUTTON) 等价;NOACTIVATE 弹窗无焦点只能轮询) + let down = NSEvent.pressedMouseButtons & 1 != 0 + let pressed = down && !lDown + lDown = down + + let hv = hitTestBar(mouse) + let cell = menuKind != .none ? hitTestPopover(mouse) : -1 + // 光标是否在可见区域内(底条 ∪ popover;其余区域透底不算窗内) + let inside = scPointInRect(mouse, barRect) + || (menuRows() > 0 && scPointInRect(mouse, popoverRect)) + if pressed && menuKind != .none && !inside { + openMenu(.none) + return + } + + // hover 高亮同步(轮询驱动;拖拽中冻结) + if !dragging { + let newHover = down ? -1 : hv + if newHover != hoverItem { + hoverItem = newHover + barView?.needsDisplay = true + } + } + // popover cell hover 同步 + if menuKind != .none { + let newMenuHover = down ? -1 : cell + if newMenuHover != menuHover { + menuHover = newMenuHover + popoverView?.needsDisplay = true + } + } + + // —— 二级 popover:悬停展开 / 悬停切换 / 离开收起 —— + // 点击收起过的锚点在光标移出该按钮前不再因悬停重开(popHoverDisarm) + let hoverKind = hoverMenuKind(hv) + if popHoverDisarm != .none && hv != menuAnchorDisarmItem() { + popHoverDisarm = .none // 离开被解除武装的锚点按钮即恢复悬停展开 + } + if hoverKind != .none && hoverKind != menuKind && hoverKind != popHoverDisarm { + if popHoverSinceMs == 0 { popHoverSinceMs = now } + if now - popHoverSinceMs >= LC_POP_OPEN_DWELL_MS { + openMenu(hoverKind) + popHoverSinceMs = 0 + } + } else { + popHoverSinceMs = 0 + } + if menuKind != .none { + // 「使用中」判定:popover cell、当前锚点,或正悬停准备切换的另一锚点,都不算离开 + let usingPop = cell >= 0 || hv == menuAnchorItem || hoverKind != .none + if usingPop { + popLeaveSinceMs = 0 + } else { + if popLeaveSinceMs == 0 { popLeaveSinceMs = now } + if now - popLeaveSinceMs >= LC_POP_CLOSE_GRACE_MS { + openMenu(.none) + popLeaveSinceMs = 0 + } + } + } else { + popLeaveSinceMs = 0 + } + + // —— title 式 tooltip:底条按钮(无菜单时)或 popover 图标 cell —— + var target = -1 + if menuKind == .none && hv >= 0 && hv != LCItem.size.rawValue { + target = hv + } else if menuKind != .none && cell >= 0 { + target = 100 + menuKindIndex * 10 + cell + } + if target != tipTarget { + tipTarget = target + tipSinceMs = now + hideTip() + } else if tipTarget >= 0 && !tipShown && now - tipSinceMs >= LC_TIP_DELAY_MS { + let text: String? + if tipTarget >= 100 { + text = menuRowLabel(cell) + } else { + text = barItemTip(tipTarget) + } + if let text = text, !text.isEmpty { + showTip(text, target: target) + } + } + // 光标接管:把手格四向箭头提示可拖动(拖拽中 closedHand),其余箭头 + if dragging { + NSCursor.closedHand.set() + } else if scPointInRect(mouse, barRect), let rects = Optional(layoutItemRects()) { + let local = CGPoint(x: mouse.x - barRect.minX, y: mouse.y - barRect.minY) + if scPointInRect(local, rects[LCItem.grip.rawValue]) { + NSCursor.openHand.set() + } else { + NSCursor.arrow.set() + } + } else if !tipShown { + NSCursor.arrow.set() + } + } + + /// 解除武装的锚点项目码(popHoverDisarm 对应的 LCItem.rawValue;none 返回 -1 恒不匹配)。 + private func menuAnchorDisarmItem() -> Int { + switch popHoverDisarm { + case .crop: return LCItem.crop.rawValue + case .direction: return LCItem.direction.rawValue + case .none: return -1 + } + } + + /// menuKind 的数值编码(tooltip 目标编码用;对齐 Windows 的 menuKind*10 段)。 + private var menuKindIndex: Int { + switch menuKind { + case .direction: return 1 + case .crop: return 2 + case .none: return 0 + } + } + + // MARK: tooltip(LongCaptureTooltipShow/Hide 移植) + + /// 显示 tooltip:按文本测量定尺寸,锚定目标矩形——优先上方,放不下转下方;水平居中 + /// 并夹在虚拟屏幕内。窗口懒创建,复用至工具栏销毁。 + private func showTip(_ text: String, target: Int) { + guard let session = session else { return } + let padX = SC.tipPadX + let padY = SC.tipPadY + let attr = NSAttributedString(string: text, attributes: [ + .font: NSFont.systemFont(ofSize: SC.fontPx), + ]) + let textSize = attr.size() + let w = ceil(textSize.width) + padX * 2 + 2 // +2 抗锯齿边缘余量,杜绝触发截断 + let h = ceil(textSize.height) + padY * 2 + 2 + // 锚点 = 目标(底条 item / popover cell)在 CG 全局坐标中的矩形 + let anchor: CGRect + if target >= 100 { + let cellIdx = (target - 100) % 10 + let local = popoverCellRect(cellIdx) + anchor = local.offsetBy(dx: popoverRect.minX, dy: popoverRect.minY) + } else { + let rects = layoutItemRects() + anchor = rects[target].offsetBy(dx: barRect.minX, dy: barRect.minY) + } + let virtual = session.virtualBounds + var x = anchor.midX - w / 2 + if x < SC.tipEdgeClamp { x = SC.tipEdgeClamp } + if x + w > virtual.maxX - SC.tipEdgeClamp { x = virtual.maxX - SC.tipEdgeClamp - w } + var y = anchor.minY - SC.tipGap - h + if y < virtual.minY + SC.tipEdgeClamp { y = anchor.maxY + SC.tipGap } + if tipWindow == nil { + let win = ScreenshotPanelWindow( + contentRect: NSRect(origin: .zero, size: CGSize(width: w, height: h)), + styleMask: .borderless, backing: .buffered, defer: false) + win.level = NSWindow.Level(rawValue: NSWindow.Level.screenSaver.rawValue + 5) + win.isOpaque = false + win.backgroundColor = .clear + win.hasShadow = false + win.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + win.isReleasedWhenClosed = false + tipWindow = win + } + tipWindow?.contentView = ScreenshotLCTipView( + text: text, frame: NSRect(origin: .zero, size: CGSize(width: w, height: h))) + tipWindow?.setFrame(lcNSRect(fromCG: CGRect(x: x, y: y, width: w, height: h)), display: true) + tipWindow?.orderFrontRegardless() + tipShown = true + } + + /// 收起 tooltip 并清零停顿(目标切换/点击/菜单开合时调用)。 + private func hideTip() { + guard tipShown else { return } + tipShown = false + tipWindow?.orderOut(nil) + } + + /// 底条按钮的 tooltip 文案(LongCaptureToolbarItemTip 移植,全中文照搬;nil = 无)。 + private func barItemTip(_ item: Int) -> String? { + guard let session = session else { return nil } + switch LCItem(rawValue: item) { + case .grip: return "拖动工具栏" + case .direction: + return session.frameCount > 1 ? "滚动方向(已拼接多帧后锁定)" : "滚动方向" + case .autoScroll: return "自动滚动" + case .crop: return "裁剪" + case .save: return "保存到本地" + case .cancel: return "取消" + case .finish: return "完成并复制" + default: return nil + } + } + + /// popover cell 的 tooltip 文案(LongCaptureMenuRowLabel 移植):方向 = 纵向/横向; + /// 裁剪 = 丢弃上方(纵向)/左侧(横向)、丢弃下方/右侧、重置。 + private func menuRowLabel(_ row: Int) -> String { + guard let session = session else { return "" } + if menuKind == .direction { + return row == 0 ? "纵向" : "横向" + } + if session.isCropped && row == menuRows() - 1 { return "重置裁剪" } + if session.horizontal { + return row == 0 ? "丢弃选区左侧内容" : "丢弃选区右侧内容" + } + return row == 0 ? "丢弃选区上方内容" : "丢弃选区下方内容" + } + + // MARK: 鼠标事件(底条 / popover) + + /// 底条左键按下:记录按下目标(UP 必须命中同一目标才触发动作——关键防误触:编辑 + /// 工具栏「长截图」按钮按下瞬间进入长截图,本工具栏立即在附近生成,残留的松开事件 + /// 绝不能触发按钮);把手进入拖拽(收起菜单后计算抓取偏移)。 + func handleBarMouseDown(_ point: CGPoint) { + guard session != nil, !barRect.isNull else { return } + hideTip() + tipTarget = -1 + if hitTestBar(point) == LCItem.grip.rawValue { + if menuKind != .none { openMenu(.none) } + dragGrabDX = point.x - barRect.minX + dragGrabDY = point.y - barRect.minY + dragging = true + pressItem = -1 + pressMenuRow = -1 + NSCursor.closedHand.set() + return + } + pressItem = hitTestBar(point) + pressMenuRow = -1 + } + + /// 把手拖拽中:跟随鼠标平移工具栏窗口并钳制在虚拟屏幕内(对齐 WM_MOUSEMOVE 拖拽分支)。 + func handleBarMouseDragged(_ point: CGPoint) { + guard dragging, !barRect.isNull, let session = session else { return } + var nx = point.x - dragGrabDX + var ny = point.y - dragGrabDY + let minX = session.virtualBounds.minX + 4 + let maxX = max(minX, session.virtualBounds.maxX - 4 - barRect.width) + let minY = session.virtualBounds.minY + let maxY = max(minY, session.virtualBounds.maxY - barRect.height) + if nx < minX { nx = minX } + if nx > maxX { nx = maxX } + if ny < minY { ny = minY } + if ny > maxY { ny = maxY } + if nx != barRect.minX || ny != barRect.minY { + barRect = CGRect(x: nx, y: ny, width: barRect.width, height: barRect.height) + window?.setFrame(lcNSRect(fromCG: barRect), display: true) + } + } + + /// 底条左键抬起:把手拖拽结束;按钮点击要求「按下-抬起同目标」(不匹配直接吞掉)。 + func handleBarMouseUp(_ point: CGPoint) { + guard session != nil else { return } + if dragging { + dragging = false + pressItem = -1 + pressMenuRow = -1 + NSCursor.openHand.set() + return + } + hideTip() // 任何点击立即收起 tooltip 并清零停顿(网页 title 同款) + tipTarget = -1 + let hit = hitTestBar(point) + let sameTarget = hit >= 0 && hit == pressItem && pressMenuRow < 0 + pressItem = -1 + pressMenuRow = -1 + guard sameTarget, let session = session else { return } + // 点击任一直接动作按钮时收起展开中的菜单(方向/裁剪按钮自身负责切换菜单状态) + if hit != LCItem.direction.rawValue && hit != LCItem.crop.rawValue && menuKind != .none { + openMenu(.none) + } + switch LCItem(rawValue: hit) { + case .finish: + session.requestFinish() + case .cancel: + session.requestCancel() + case .save: + session.requestSave() + case .direction: + // 点击方向:与裁剪同款开合(悬停展开见 uiTick)。已拼接多帧后方向锁定, + // 点击不展开菜单 + if session.frameCount <= 1 { + if menuKind == .direction { + openMenu(.none) + popHoverDisarm = .direction + } else { + openMenu(.direction) + } + } + case .autoScroll: + session.setAutoScroll(!session.autoScroll) + case .crop: + // 点击裁剪:未展开则立即展开;已展开则收起并解除悬停武装(需移出按钮再进入 + // 才会因悬停重开,防止点击收起与悬停展开互相打架) + if menuKind == .crop { + openMenu(.none) + popHoverDisarm = .crop + } else { + openMenu(.crop) + } + default: + break + } + } + + /// popover 左键按下:裁剪 cell → 立即应用对应项(延迟剔除登记);方向 cell → 可用时 + /// 切换方向(禁用 cell 仅收起);随后收起菜单(对齐 WM_LBUTTONUP 的 menuRow 分支)。 + func handlePopoverMouseDown(_ point: CGPoint) { + guard session != nil else { return } + hideTip() + tipTarget = -1 + let row = hitTestPopover(point) + guard row >= 0, let session = session else { return } + if menuKind == .crop { + applyCropPopover(row: row) + } else if menuKind == .direction { + let wantHorizontal = (row == 1) + if wantHorizontal != session.horizontal && menuRowEnabled(row) { + session.switchDirection() + } + } + openMenu(.none) + } + + /// popover 行可用性(LongCaptureMenuRowEnabled 移植):方向菜单中「非当前方向」的行 + /// 在已拼接多帧后禁用;裁剪行始终可用(只收紧输出行窗口,不碰拼接/匹配状态)。 + private func menuRowEnabled(_ row: Int) -> Bool { + guard let session = session else { return false } + if menuKind == .direction { + let current = session.horizontal ? 1 : 0 + return row == current || session.frameCount <= 1 + } + return true + } + + /// 应用裁剪 popover 选项(会话转调 lc_apply_crop;行号语义见 ScreenshotLongCaptureMac + /// 的 applyCrop —— 登记待剔除区间,物理删行由下次朝该方向成功提交触发)。 + private func applyCropPopover(row: Int) { + guard let session = session else { return } + // 行号 0/1 = 丢弃上方/下方(横向 = 左侧/右侧);已裁剪时末行 = 重置 + let cropRow: Int32 + if session.isCropped && row == menuRows() - 1 { + cropRow = 2 // LC_CROP_RESET + } else { + cropRow = row == 0 ? 0 : 1 // LC_CROP_DISCARD_TOP / LC_CROP_DISCARD_BOTTOM + } + session.applyCrop(row: cropRow) + } + + // MARK: 绘制(LongCaptureToolbarRender 移植) + + /// 底条按钮图标(含方向/自动滚动的 V/H 变体;对齐渲染段的图标选择)。 + private func barIcon(_ item: LCItem) -> SCToolbarIcon? { + guard let session = session else { return nil } + switch item { + case .direction: return session.horizontal ? .directionH : .directionV + case .autoScroll: return session.horizontal ? .autoScrollH : .autoScrollV + case .crop: return .cropIcon + case .save: return .save + case .cancel: return .cancel + case .finish: return .confirm + default: return nil + } + } + + /// 绘制底条(白底圆角条 + 浅灰描边 + 分隔线 + 尺寸标签 + 把手 6 点 + 图标三态)。 + /// - Parameter ctx: 底条视图 CG 上下文(已翻转,本地坐标 = 底条本地) + func drawBar(_ ctx: CGContext) { + guard let session = session else { return } + let w = barRect.width + let h = LC_BAR_H + let rects = layoutItemRects() + // 1) 白色圆角背景 + 1px 浅灰边框(圆角外透明透出桌面) + let bgPath = lcRoundedRectPath(CGRect(x: 0, y: 0, width: w, height: h), LC_BAR_RADIUS) + ctx.addPath(bgPath) + ctx.setFillColor(NSColor.white.cgColor) + ctx.fillPath() + ctx.addPath(bgPath) + ctx.setStrokeColor(lcBorderCol.cgColor) + ctx.setLineWidth(1) + ctx.strokePath() + // 2) 分隔线(1px 竖线,上下各缩进 10) + ctx.setStrokeColor(lcSepCol.cgColor) + ctx.setLineWidth(1) + for sep in [LCItem.sep1, .sep3] { + let r = rects[sep.rawValue] + let x = r.midX + 0.5 // 半像素偏移使整数坐标下恰好落在单像素列 + ctx.move(to: CGPoint(x: x, y: r.minY)) + ctx.addLine(to: CGPoint(x: x, y: r.maxY)) + ctx.strokePath() + } + // 3) 尺寸标签(预览宽×高,随拼接/裁剪实时变化;超宽省略号兜底) + drawSizeLabel(ctx, rect: rects[LCItem.size.rawValue]) + // 4) 把手(6 点;hover/拖拽中铺浅蓝圆角底、圆点转主题蓝) + drawGrip(ctx, rect: rects[LCItem.grip.rawValue], + hot: dragging || hoverItem == LCItem.grip.rawValue) + // 5) 图标按钮三态 + let dirLocked = session.frameCount > 1 // 已拼接多帧:方向锁定 + drawIconButton(ctx, rect: rects[LCItem.direction.rawValue], icon: barIcon(.direction), + hover: hoverItem == LCItem.direction.rawValue || menuKind == .direction, + active: false, disabled: dirLocked) + drawIconButton(ctx, rect: rects[LCItem.autoScroll.rawValue], icon: barIcon(.autoScroll), + hover: hoverItem == LCItem.autoScroll.rawValue, + active: session.autoScroll, disabled: false) + drawIconButton(ctx, rect: rects[LCItem.crop.rawValue], icon: barIcon(.crop), + hover: hoverItem == LCItem.crop.rawValue || menuKind == .crop, + active: session.isCropped, // badge 高亮:已裁剪(含待剔除区间) + disabled: false) + drawIconButton(ctx, rect: rects[LCItem.save.rawValue], icon: barIcon(.save), + hover: hoverItem == LCItem.save.rawValue, active: false, disabled: false) + drawIconButton(ctx, rect: rects[LCItem.cancel.rawValue], icon: barIcon(.cancel), + hover: hoverItem == LCItem.cancel.rawValue, active: false, disabled: false) + drawIconButton(ctx, rect: rects[LCItem.finish.rawValue], icon: barIcon(.finish), + hover: hoverItem == LCItem.finish.rawValue, active: false, disabled: false) + } + + /// 绘制尺寸标签文本(12px 系统字体,水平/垂直居中;对齐 LongCaptureDrawSurfaceText)。 + private func drawSizeLabel(_ ctx: CGContext, rect: CGRect) { + guard let text = session?.sizeLabelText, !text.isEmpty else { return } + let attr = NSAttributedString(string: text, attributes: [ + .font: NSFont.systemFont(ofSize: SC.fontPx), + .foregroundColor: lcSizeLabelCol, + ]) + let size = attr.size() + // 超宽截断兜底(固定槽位 104pt) + let maxW = rect.width + let drawText = size.width > maxW ? truncated(text, width: maxW, attr: attr) : text + drawText.draw( + with: CGRect(x: rect.minX, y: rect.midY - size.height / 2, + width: maxW, height: ceil(size.height)), + options: [.usesLineFragmentOrigin], attributes: [:]) + } + + /// 超宽文本按宽度截断(尺寸标签兜底;逐字符累计宽度,末尾补省略号)。 + private func truncated(_ text: String, width: CGFloat, attr: NSAttributedString) -> String { + let font = NSFont.systemFont(ofSize: SC.fontPx) + var result = text + while result.count > 1 { + let candidate = result + "…" + let w = (candidate as NSString).size(withAttributes: [.font: font]).width + if w <= width { return candidate } + result = String(result.dropLast()) + } + return "…" + } + + /// 绘制把手「6 点拖拽」:2 列 × 3 排共 6 个小圆点居中;圆点几何随单元格尺寸缩放 + /// (列距 ±11%、行距 0/±16%、点半径 ~5%,对齐 LongCaptureDrawGrip)。 + private func drawGrip(_ ctx: CGContext, rect: CGRect, hot: Bool) { + if hot { + let path = lcRoundedRectPath(rect, 6) + ctx.addPath(path) + ctx.setFillColor(lcHoverBg.cgColor) + ctx.fillPath() + } + let cw = rect.width + let cx = rect.midX + let cy = rect.midY + let colGap = cw * 0.11 + let rowGap = cw * 0.16 + let r = max(1.2, cw * 0.05) + ctx.setFillColor(hot ? lcIconBlue.cgColor : lcGripDotCol.cgColor) + for row in -1...1 { + for col in [-1, 1] { + let dx = cx + CGFloat(col) * colGap + let dy = cy + CGFloat(row) * rowGap + ctx.fillEllipse(in: CGRect(x: dx - r, y: dy - r, width: r * 2, height: r * 2)) + } + } + } + + /// 绘制图标按钮三态(LongCaptureDrawIconButton 移植):hover/active 浅蓝圆角底 + + /// 蓝色图标,disabled 灰图标,常态深灰图标。 + private func drawIconButton(_ ctx: CGContext, rect: CGRect, icon: SCToolbarIcon?, + hover: Bool, active: Bool, disabled: Bool) { + if !disabled && (hover || active) { + let path = lcRoundedRectPath(rect, 6) + ctx.addPath(path) + ctx.setFillColor((active ? lcActiveBg : lcHoverBg).cgColor) + ctx.fillPath() + } + let color = disabled ? lcIconGray : (hover || active) ? lcIconBlue : lcIconDark + guard let image = icon?.tinted(color) else { return } + let size = SCToolbarIcon.iconPointSize + image.draw(in: NSRect(x: rect.midX - size / 2, y: rect.midY - size / 2, + width: size, height: size)) + } + + /// popover 第 i 个 cell 的图标(LongCaptureMenuCellIcon 移植):方向 = 纵向/横向变体; + /// 裁剪 = 丢弃起点/终点(纵向 = 上方/下方,横向 = 左侧/右侧),已裁剪时末位追加重置。 + private func popoverCellIcon(_ i: Int) -> SCToolbarIcon? { + guard let session = session else { return nil } + if menuKind == .direction { return i == 0 ? .directionV : .directionH } + if session.isCropped && i == menuRows() - 1 { return .cropReset } + if session.horizontal { return i == 0 ? .cropDiscardLeft : .cropDiscardRight } + return i == 0 ? .cropDiscardTop : .cropDiscardBottom + } + + /// 绘制二级菜单 popover(白色圆角底 + 浅灰描边 + 单行图标 cell;当前方向 active 蓝底、 + /// 锁定方向灰显;裁剪 cell 始终可用)。 + func drawPopover(_ ctx: CGContext) { + let rows = menuRows() + guard rows > 0, let session = session else { return } + let pw = popoverRect.width + let ph = popoverRect.height + let bgPath = lcRoundedRectPath(CGRect(x: 0, y: 0, width: pw, height: ph), LC_BAR_RADIUS) + ctx.addPath(bgPath) + ctx.setFillColor(NSColor.white.cgColor) + ctx.fillPath() + ctx.addPath(bgPath) + ctx.setStrokeColor(lcBorderCol.cgColor) + ctx.setLineWidth(1) + ctx.strokePath() + let curDir = session.horizontal ? 1 : 0 + for i in 0.. UInt64 { + return DispatchTime.now().uptimeNanoseconds / 1_000_000 +} + +/// BGRA 缓冲(0xAARRGGBB,内存字节序 B,G,R,A——与 lc_frame_io_windows.cpp 的 32bpp DIB 帧布局 +/// 一致)→ CGImage。reverse of captureFrame 的 CGContext 转换;小地图预览与最终输出共用。 +/// - Parameters: +/// - bgra: 像素缓冲(count 必须等于 width*height) +/// - width/height: 图像尺寸(像素) +/// - Returns: CGImage;参数非法返回 nil +func lcMakeCGImage(bgra: [UInt32], width: Int, height: Int) -> CGImage? { + guard width >= 1, height >= 1, bgra.count == width * height else { return nil } + var buf = bgra + return buf.withUnsafeMutableBytes { raw -> CGImage? in + guard let base = raw.baseAddress, + let ctx = CGContext(data: base, width: width, height: height, + bitsPerComponent: 8, bytesPerRow: width * 4, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedFirst.rawValue + | CGBitmapInfo.byteOrder32Little.rawValue) else { return nil } + return ctx.makeImage() + } +} + +/// 把 CG 全局逻辑矩形翻转为 NS 窗口 frame(NS 原点在主屏左下、Y 向上;浮层同款换算)。 +/// - Parameter rect: CG 全局逻辑坐标矩形 +/// - Returns: NSWindow.setFrame 用的 NSRect +func lcNSRect(fromCG rect: CGRect) -> NSRect { + let top = ScreenshotGeometry.primaryScreenHeight() - rect.minY + return NSRect(x: rect.minX, y: top - rect.height, width: rect.width, height: rect.height) +} + +// MARK: - 滚轮观察缓冲(tap 回调线程只写、泵循环消费) + +/// 滚轮事件原始增量条目(tap 回调线程合并写入;方向解析延迟到泵消费时按当前模式进行, +/// 避免 tap 线程读会话状态)。line* 为整数行增量(滚轮一格 = ±1 行),point* 为像素级 +/// 增量(触控板亚行滚动),shift = 事件携带的 Shift 修饰键。 +struct ScreenshotLCWheelSample { + var lineV: Int32 = 0 + var lineH: Int32 = 0 + var pointV: Double = 0 + var pointH: Double = 0 + var shift = false + var tickMs: UInt64 = 0 +} + +/// 滚轮观察共享缓冲(NSLock 保护;等价 Windows Raw Input 广播 → 面板 WM_INPUT 的 +/// 「只被动接收、置标志/累加缓冲,不拦截输入」语义)。 +final class ScreenshotLCWheelBuffer { + private let lock = NSLock() + private var has = false + private var sample = ScreenshotLCWheelSample() + + /// 写入一次滚轮事件(多次未消费事件合并:行/像素增量累加、方向键时刻取最新)。 + func push(_ s: ScreenshotLCWheelSample) { + lock.lock() + defer { lock.unlock() } + if has { + sample.lineV &+= s.lineV + sample.lineH &+= s.lineH + sample.pointV += s.pointV + sample.pointH += s.pointH + sample.shift = s.shift + sample.tickMs = s.tickMs + } else { + sample = s + has = true + } + } + + /// 取出并清空缓冲(泵循环消费;无未消费事件返回 nil)。 + func take() -> ScreenshotLCWheelSample? { + lock.lock() + defer { lock.unlock() } + guard has else { return nil } + has = false + return sample + } + + /// 清空缓冲(方向切换重置会话时调用,防旧方向增量污染新先验)。 + func clear() { + lock.lock() + has = false + lock.unlock() + } +} + +// MARK: - 滚轮观察 event tap(listen-only,等价 RIDEV_INPUTSINK) + +/// 当前活跃的长截图滚轮 tap(CGEventTap 回调为 C 函数指针不能捕获上下文,模块级桥接)。 +private var lcWheelTapCurrent: ScreenshotLCWheelTap? + +/// 长截图滚轮观察 tap 回调(C 函数指针兼容;无捕获)。 +private func lcWheelTapCallback( + _ proxy: CGEventTapProxy, _ type: CGEventType, _ event: CGEvent, _ userInfo: UnsafeMutableRawPointer? +) -> Unmanaged? { + guard let current = lcWheelTapCurrent else { + return Unmanaged.passUnretained(event) + } + return current.handleEvent(type: type, event: event) +} + +/// 长截图滚轮观察 tap(对齐 lc_frame_io_windows.cpp LongCaptureRegisterWheelObserver 的 Raw Input +/// RIDEV_INPUTSINK:非前台也接收广播、不拦截输入)。macOS 用 `.listenOnly` CGEventTap 监听 +/// scrollWheel——事件原样放行(照常送达选区下的目标窗口),这里只被动解析方向与时机。 +/// tap 回调线程只把原始增量写入共享缓冲(ScreenshotLCWheelBuffer),泵循环逐拍消费。 +final class ScreenshotLCWheelTap { + private let buffer: ScreenshotLCWheelBuffer + private var tap: CFMachPort? + private var runLoopSource: CFRunLoopSource? + private var runLoop: CFRunLoop? + private var stopped = false + + /// - Parameter buffer: 会话侧共享缓冲(tap 生命周期内由会话持有) + init(buffer: ScreenshotLCWheelBuffer) { + self.buffer = buffer + } + + /// 在后台线程创建 listen-only tap 并运行其 RunLoop(照抄取色器/覆盖层 tap 启动模式)。 + func start() { + DispatchQueue.global(qos: .userInteractive).async { [weak self] in + guard let self = self else { return } + let mask: CGEventMask = (1 << CGEventType.scrollWheel.rawValue) + guard let tap = CGEvent.tapCreate( + tap: .cgSessionEventTap, + place: .headInsertEventTap, + options: .listenOnly, // 只观察不拦截:等价 RIDEV_INPUTSINK 非前台广播 + eventsOfInterest: mask, + callback: lcWheelTapCallback, + userInfo: nil + ) else { + print("Error: Failed to create long-capture wheel observer tap. Check accessibility permissions.") + return + } + guard let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) else { + print("Error: Failed to create run loop source for long-capture wheel tap") + CFMachPortInvalidate(tap) + return + } + self.tap = tap + self.runLoopSource = source + self.runLoop = CFRunLoopGetCurrent() + if self.stopped { + // 会话在 tap 就绪前已收口:就地清理,避免泄漏 + self.stop() + return + } + // source 必须挂进当前 RunLoop,tap 的 mach port 才会被调度读取——缺失时 + // tap 创建并 enable 成功也永远收不到回调(滚轮观察静默失效,采样轮永不触发; + // 对齐 ZToolsNative.swift 取色器/鼠标监听的标准启动序:AddSource → Enable → Run) + CFRunLoopAddSource(CFRunLoopGetCurrent(), source, .commonModes) + lcWheelTapCurrent = self + CGEvent.tapEnable(tap: tap, enable: true) + CFRunLoopRun() + } + } + + /// 停止并释放 tap(会话收口时调用;CFRunLoopStop 线程安全)。 + func stop() { + stopped = true + lcWheelTapCurrent = nil + if let tap = tap { + CGEvent.tapEnable(tap: tap, enable: false) + } + if let source = runLoopSource, let runLoop = runLoop { + CFRunLoopRemoveSource(runLoop, source, .commonModes) + } + if let tap = tap { + CFMachPortInvalidate(tap) + } + if let runLoop = runLoop { + CFRunLoopStop(runLoop) + } + tap = nil + runLoopSource = nil + runLoop = nil + } + + /// tap 回调主体:解析滚轮事件原始增量写入缓冲后原样放行(listen-only 恒放行); + /// tap 被系统超时禁用(0xFFFFFFFE/0xFFFFFFFF)时重新启用(取色器同款处理)。 + /// - Parameters: + /// - type: 事件类型 + /// - event: 原始事件 + /// - Returns: 恒为放行的事件(本 tap 不消费任何输入) + func handleEvent(type: CGEventType, event: CGEvent) -> Unmanaged? { + if type.rawValue == 0xFFFFFFFE || type.rawValue == 0xFFFFFFFF { + if let tap = tap { + CGEvent.tapEnable(tap: tap, enable: true) + } + return Unmanaged.passUnretained(event) + } + if type == .scrollWheel { + var s = ScreenshotLCWheelSample() + // 纵向:Axis1 行增量(整数)+ 像素级增量(触控板亚行滚动) + s.lineV = Int32(clamping: event.getIntegerValueField(.scrollWheelEventDeltaAxis1)) + s.lineH = Int32(clamping: event.getIntegerValueField(.scrollWheelEventDeltaAxis2)) + s.pointV = event.getDoubleValueField(.scrollWheelEventPointDeltaAxis1) + s.pointH = event.getDoubleValueField(.scrollWheelEventPointDeltaAxis2) + s.shift = event.flags.contains(.maskShift) + s.tickMs = lcNowMs() + buffer.push(s) + } + return Unmanaged.passUnretained(event) + } +} + +// MARK: - 长截图灰蒙版视图 + +/// 长截图灰蒙版自绘视图(对齐 lc_panel_ui_windows.cpp EnterLongCaptureMask 的三层绘制): +/// 1) 整屏半透明灰 RGB(44,44,44) alpha 0xA0;2) 选区蓝色描边(路径 = 采样区向外偏移 +/// 2px 即选区矩形,2.5px 居中描边);3) 采样裁剪区清全透明(SourceCopy 语义,透出实况 +/// 桌面;放在描边之后,擦除描边内半圈越界的抗锯齿像素,保证抓屏取样范围内绝无蒙版像素)。 +final class ScreenshotLCMaskView: NSView { + /// 本窗口左上角的 CG 全局坐标(本地坐标 = CG 全局坐标 - cgOrigin)。 + let cgOrigin: CGPoint + /// 选区矩形(CG 全局逻辑坐标)。 + let selection: CGRect + /// 采样裁剪矩形 = 选区每边内缩 2px(CG 全局逻辑坐标)。 + let cropRect: CGRect + /// 描边路径圆角半径(选区圆角外扩 2px;对齐 Windows AddRoundedRect 的 radius 参数)。 + let cornerRadius: CGFloat + + init(cgOrigin: CGPoint, selection: CGRect, cropRect: CGRect, cornerRadius: CGFloat, frame: NSRect) { + self.cgOrigin = cgOrigin + self.selection = selection + self.cropRect = cropRect + self.cornerRadius = cornerRadius + super.init(frame: frame) + } + + required init?(coder: NSCoder) { + fatalError("ScreenshotLCMaskView is created programmatically only") + } + + override var isFlipped: Bool { return true } // 本地坐标与 CG 全局坐标同向 + + /// 三层蒙版绘制(绘制次序与 Windows 逐条对齐,见类注释)。 + override func draw(_ dirtyRect: NSRect) { + guard let ctx = NSGraphicsContext.current?.cgContext else { return } + // 1) 整屏半透明灰(LONG_MASK_GRAY=44 / LONG_MASK_ALPHA=0xA0,lc_panel_ui_windows.cpp) + ctx.setFillColor(NSColor(srgbRed: LC_MASK_GRAY_COMPONENT / 255.0, + green: LC_MASK_GRAY_COMPONENT / 255.0, + blue: LC_MASK_GRAY_COMPONENT / 255.0, + alpha: LC_MASK_ALPHA / 255.0).cgColor) + ctx.fill(bounds) + // 2) 选区蓝描边(SC_THEME_ACCENT_BLUE = RGB(0x00,0x88,0xFF),2.5px,internal.h; + // 路径 = 选区矩形 = 采样区向外偏移 2px,圆角半径同步外扩 2px) + let localSel = selection.offsetBy(dx: -cgOrigin.x, dy: -cgOrigin.y) + let radius = min(max(cornerRadius, 0), min(localSel.width, localSel.height) / 2) + 2 + let path = CGPath(roundedRect: localSel, cornerWidth: radius, cornerHeight: radius, transform: nil) + ctx.addPath(path) + ctx.setStrokeColor(NSColor(srgbRed: 0, green: 0x88 / 255.0, blue: 1.0, alpha: 1).cgColor) + ctx.setLineWidth(2.5) + ctx.strokePath() + // 3) 采样裁剪区清全透明(SourceCopy:整像素擦除该矩形全部通道,透出实况桌面) + let localCrop = cropRect.offsetBy(dx: -cgOrigin.x, dy: -cgOrigin.y) + ctx.setBlendMode(.copy) + ctx.setFillColor(NSColor.clear.cgColor) + ctx.fill(localCrop) + ctx.setBlendMode(.normal) + } +} + +// MARK: - 长截图会话主体 + +/// 长截图滚动捕获会话:持有灰蒙版窗口组、小地图/工具栏控制器、滚轮观察 tap、 +/// 算法层会话与全部会话侧簿记字段(lc_session_windows.cpp 中归属会话层的 wheelPending/lastDir/ +/// noChangeCount/reachedBottom/weakTries/frameCount/autoFailStreak 等;拼接累计状态全部 +/// 在算法层 LCAlgorithmSession 内,会话层绝不直接触碰)。生命周期:beginLongCapture 创建 +/// → start()(蒙版/首帧/浮层/tap)→ lcTick() 由覆盖层泵循环逐拍驱动 → 终止条件收束 +/// (完成/保存成功 → success 回调;取消/ESC/abort/失败 → 整会话 {success:false} 收束, +/// 对齐 wndproc_windows.cpp WM_LONGCAPTURE_RUN 结束后 ctx->state = CS_Done + DestroyWindow)。 +final class ScreenshotLongCaptureSession { + private weak var overlay: ScreenshotOverlaySession? + + // ---- 会话配置(解析链路透传;对齐 g_lcInterval)---- + let intervalMs: Int + + // ---- 几何(CG 全局逻辑坐标)---- + /// 编辑态选区(CG 全局逻辑坐标,整数点)。 + let selection: CGRect + /// 采样裁剪矩形 = 选区每边内缩 2px(CalcSampleCrop 语义,防选区描边入画,矩阵 #36)。 + let cropRect: CGRect + /// 选区所在屏的物理/逻辑缩放比(多屏时抓选区所在屏,见 init 注释)。 + let scale: CGFloat + /// 抓帧物理尺寸(未转置;横向模式帧缓冲转置后 physW/physH 互换,对齐 lc->capW/capH)。 + let capW: Int + let capH: Int + + /// 是否横向模式(进入时恒为纵向;方向切换经工具栏,对齐 lc->horizontal)。 + private(set) var horizontal = false + /// 帧缓冲尺寸(横向模式为 capW/capH 的转置,对齐 lc->physW/physH)。 + private(set) var physW: Int + private(set) var physH: Int + + /// 蒙版窗口号(实况抓帧排除自身浮层的基准窗口;start() 创建蒙版后写入, + /// 此后只读。0 = 未就绪——抓帧前置条件校验会拒绝抓帧)。 + private var maskNumber: CGWindowID = 0 + + // ---- 算法层会话(拼接累计状态唯一归属;交互只经 LCBridgeMac.swift)---- + private(set) var algo: LCAlgorithmSession? + + // ---- 会话侧簿记(对齐 LongCaptureContext 会话层字段,lc_session_windows.cpp)---- + /// 滚轮停稳防抖待采样标志(WM_INPUT 置位、停稳采样消费)。 + var wheelPending = false + /// 最近滚轮方向(+1 向下 / -1 向上 / 0 未知;喂给 lc_try_stitch 的 dir)。 + var lastDir: Int32 = 0 + /// 最近滚轮事件时刻 ms(停稳判定基准)。 + var lastWheelTickMs: UInt64 = 0 + /// 上次采样轮结束时刻 ms(滚动中主动节拍基准)。 + var lastSampleTickMs: UInt64 = 0 + /// 连续 NoChange 计数(达到 LC_BOTTOM_CONFIRM_SAMPLES 确认到底)。 + var noChangeCount = 0 + /// 已确认滚动到底(autoScroll 自动停止条件之一)。 + var reachedBottom = false + /// Weak 候选独立采样轮计数(耗尽即放弃候选链)。 + var weakTries = 0 + /// 已拼接帧数(首帧 = 1;仅用于方向锁定判定,不设上限、不触发自动完成)。 + var frameCount = 0 + /// 自动滚动开启中。 + var autoScroll = false + /// 自动滚动连续硬失败计数(达到 LC_AUTOSCROLL_STOP_FAILS 自动停止)。 + var autoFailStreak = 0 + + // ---- 采样轮内重试梯状态(对齐 RunLongCapture 轮内变量)---- + private var inSampleRound = false + private var normalTries = 0 + private var weakRetries = 0 + private var stableWaits = 0 + private var quickResamples = 0 + private var nextAttemptMs: UInt64 = 0 + private var trackRevBefore = 0 + private var lastFailReason: LCFailReason = .none + private var lastResult: LCTryStitchResult? + + // ---- 工具栏动作标志(工具栏控制器在主线程泵内置位;lc->finishFlag/saveFlag/abortFlag)---- + var finishRequested = false + var saveRequested = false + + // ---- UI 与观察器 ---- + private var maskWindows: [NSWindow] = [] + private(set) var panel: ScreenshotLCPanelController? + private(set) var toolbar: ScreenshotLCToolbarController? + private var wheelTap: ScreenshotLCWheelTap? + private let wheelBuffer = ScreenshotLCWheelBuffer() + + /// 自动滚动上次注入时刻 ms(31ms 节拍基准;SetTimer 等价物)。 + private var lastAutoScrollTickMs: UInt64 = 0 + /// 工具栏 UI 维护节拍上次触发时刻 ms(100ms 轮询,LC_TIMER_UI 等价物)。 + private var lastUiTickMs: UInt64 = 0 + /// 长截图主循环是否已收束(防二次收束)。 + private var ended = false + + /// 小地图缩略列宽(物理像素;对齐 lc->thumbW = min(面板预览内宽, physW))。 + let thumbW: Int + + // MARK: 初始化(对齐 BeginLongCapture 的上下文构造段) + + /// 计算几何并构造会话(不抓屏、不建窗口)。选区过小(内缩后宽/高 < 1 物理像素)返回 nil + /// (对齐 Windows BeginLongCapture 的 delete lc; return——会话停留在编辑态,无回调)。 + /// 多屏映射:Windows 为单虚拟屏单一 dpiScale;macOS 选区可能跨屏且各屏缩放不同, + /// 本实现取「采样裁剪矩形中心所在屏」的 backingScaleFactor 作为采样/输出比例 + /// (与区域截图确认输出路径 baseFrame.scale 的单比例模型一致,混合 DPI 跨屏选区为 + /// 已知近似,与 Windows 侧同级别局限一致)。 + init?(overlay: ScreenshotOverlaySession, selection sel: CGRect) { + self.overlay = overlay + self.intervalMs = overlay.options.longCaptureIntervalMs + let sel = sel.standardized + self.selection = sel + + // 采样裁剪 = 选区每边内缩(lc_panel_ui_windows.cpp CalcSampleCrop 逐式移植; + // inset 取算法层常量 LC_CROP_INSET_LOGI = 2,防漂移) + let inset = CGFloat(LCAlgoConsts.shared.cropInsetLogical) + let cropL = min(sel.maxX - 1, sel.minX + inset) + let cropT = min(sel.maxY - 1, sel.minY + inset) + let cropR = max(cropL + 1, sel.maxX - inset) + let cropB = max(cropT + 1, sel.maxY - inset) + let crop = CGRect(x: cropL, y: cropT, width: cropR - cropL, height: cropB - cropT) + self.cropRect = crop + + // 选区所在屏与缩放比(裁剪矩形中心命中;无命中退主屏) + let center = CGPoint(x: crop.midX, y: crop.midY) + let screen = NSScreen.screens.first { + ScreenshotGeometry.cgFrame(of: $0).contains(center) + } ?? NSScreen.screens.first + guard let screen = screen else { return nil } + self.scale = screen.backingScaleFactor + + // 抓帧物理尺寸((int)(v*ds + 0.5) 语义);过小 = 选区过小 + let w = Int((crop.width * scale) + 0.5) + let h = Int((crop.height * scale) + 0.5) + guard w >= 1, h >= 1 else { return nil } + self.capW = w + self.capH = h + self.physW = w // 进入时恒为纵向(横向 physW/physH 随方向切换互换) + self.physH = h + + // 小地图缩略列宽 = 面板预览内宽(物理像素),不超过帧宽(lc_session_windows.cpp BeginLongCapture) + let previewPx = Int(((LC_PANEL_WIDTH - 2 * LC_PANEL_PAD_PX) * scale) + 0.5) + self.thumbW = min(max(1, previewPx), physW) + } + + // MARK: 生命周期 + + /// 启动长截图会话:隐藏覆盖层 → 创建灰蒙版 → 算法层会话 + 首帧基准 → 小地图/工具栏 + /// → 滚轮观察 tap。任一关键步骤失败时 FailFast 收束整个截图会话(对齐 Windows + /// LongCaptureInitFirstFrame 失败 → LongCaptureEmitFailure → 会话清理,矩阵 #48)。 + /// - Returns: true 会话就绪(state 置 .longCapturing 后由泵循环驱动);false 已收束 + func start() -> Bool { + guard let ov = overlay else { return false } + + // 0) 重置 JS 中止标志(会话创建时重置,防上次会话遗留置位误收束) + resetLongCaptureAbort() + + // 1) 隐藏覆盖层与编辑工具栏(Windows ShowWindow(SW_HIDE):甜甜圈设计不再使用, + // 由独立灰蒙版接管;编辑工具栏为独立浮层窗口需一并隐藏) + ov.hideOverlayWindowsForLongCapture() + ov.toolbar.syncVisibility(false) + + // 2) 灰蒙版:每 NSScreen 一个 borderless 窗口(与覆盖层同规格), + // 整窗点击穿透(ignoresMouseEvents=true)+ 选区描边 + 采样区全透明 + guard createMaskWindows() else { + finishWholeSession(payload: ov.failurePayload("failed to create long capture mask")) + return false + } + + // 3) 算法层会话(进入时恒纵向;缩略图列宽随帧宽钳制由 bridge 完成) + do { + algo = try LCAlgorithmSession( + width: physW, height: physH, + config: LCAlgorithmSession.Config( + interval: intervalMs, thumbW: thumbW, horizontal: false)) + } catch { + finishWholeSession(payload: ov.failurePayload("failed to init long capture algorithm session")) + return false + } + + // 4) 首帧基准:进入长截图时选区内的当前内容即首屏(既作主体段也作重叠检测基准; + // LongCaptureInitFirstFrame 失败 = 抓帧失败 → FailFast,绝不把黑图报成功) + if !initBaseline() { + finishWholeSession(payload: "{\"success\":false}") + return false + } + lastSampleTickMs = lcNowMs() + + // 5) 小地图面板 + 长截图工具栏(后于蒙版创建保证位于其上;蒙版点击穿透不影响交互) + panel = ScreenshotLCPanelController(session: self) + panel?.create() + toolbar = ScreenshotLCToolbarController(session: self) + toolbar?.create() + + // 6) 滚轮观察 tap(listen-only;注册失败不致命——仅失去自动采样时机来源, + // 用户仍可用 autoScroll 注入的滚轮驱动采样。Windows 注册失败即 EmitFailure, + // macOS listen-only 与 Raw Input 的失败面不同,降级为继续会话) + let tap = ScreenshotLCWheelTap(buffer: wheelBuffer) + tap.start() + wheelTap = tap + + // 7) 面板初始内容 + 工具栏初始宽×高标签(对齐 RunLongCapture 开头的两次刷新) + panel?.contentChanged() + toolbar?.refreshAll() + return true + } + + /// 创建每屏灰蒙版窗口(规格与覆盖层一致:borderless、screenSaver+1、 + /// canJoinAllSpaces/fullScreenAuxiliary;整窗点击穿透 = ignoresMouseEvents)。 + /// - Returns: 创建成功与否(无屏幕或选区所在屏窗口创建失败返回 false) + private func createMaskWindows() -> Bool { + let screens = NSScreen.screens + guard !screens.isEmpty else { return false } + let center = CGPoint(x: cropRect.midX, y: cropRect.midY) + for screen in screens { + let cgFrame = ScreenshotGeometry.cgFrame(of: screen) + let window = ScreenshotPanelWindow( + contentRect: screen.frame, styleMask: .borderless, backing: .buffered, defer: false) + window.level = NSWindow.Level(rawValue: NSWindow.Level.screenSaver.rawValue + 1) + window.isOpaque = false + window.backgroundColor = .clear + window.hasShadow = false + window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + window.isReleasedWhenClosed = false + window.ignoresMouseEvents = true // 整窗点击穿透(Windows WS_EX_TRANSPARENT 等价) + let view = ScreenshotLCMaskView( + cgOrigin: cgFrame.origin, selection: selection, cropRect: cropRect, + cornerRadius: overlay?.selectionCornerRadius ?? 0, + frame: NSRect(origin: .zero, size: cgFrame.size)) + window.contentView = view + maskWindows.append(window) + // 记录选区所在屏的蒙版窗口号(实况抓帧排除基准) + if cgFrame.contains(center) { + maskNumber = CGWindowID(window.windowNumber) + } + } + for w in maskWindows { + w.orderFrontRegardless() + } + return maskNumber != 0 + } + + /// 抓取首帧并初始化算法层基准(对齐 lc_frame_io_windows.cpp LongCaptureInitFirstFrame)。 + /// - Returns: 首帧抓取/初始化成功与否 + private func initBaseline() -> Bool { + guard let algo = algo, let frame = captureFrame() else { return false } + do { + try algo.initBaseline(bgra: frame) + } catch { + return false + } + frameCount = 1 + return true + } + + // MARK: 泵循环驱动(Windows RunLongCapture 主循环的 tick 化等价物) + + /// 泵循环逐拍入口(覆盖层 pumpTick 在 .longCapturing 态调用):检查点消费 + 采样轮推进 + /// + autoScroll 节拍 + UI 维护节拍。单次调用内不做任何阻塞等待(重试梯以「下次尝试 + /// 时刻」表达,等价 Windows LongCaptureWaitMessages 的有界等待)。 + func lcTick() { + guard !ended, !overlayFinished() else { return } + let now = lcNowMs() + + // 检查点 1:abortLongCapture(JS 线程置位)→ 整会话失败收束(LongCaptureAbort 语义) + if consumeLongCaptureAbort() { + finishWholeSession(payload: "{\"success\":false}") + return + } + // 检查点 2:完成并复制(工具栏 LTI_Finish → finishFlag) + if finishRequested { + finishRequested = false + finishAndCopy() + return + } + // 检查点 3:保存到本地(工具栏 LTI_Save → saveFlag;取消对话框则继续捕获) + if saveRequested { + saveRequested = false + handleSave() + if ended { return } + } + + // 消费滚轮观察缓冲(tap 线程只写缓冲,方向解析在泵线程按当前模式进行) + drainWheel(now: now) + + // 采样轮(三条件触发 + 重试梯推进 + 结局簿记) + pumpSampleRound(now: now) + if ended { return } + + // 自动滚动节拍(31ms 注入;LongCaptureAutoScrollTick 等价) + tickAutoScroll(now: now) + + // 工具栏 UI 维护节拍(100ms 轮询:popover 悬停展开/宽限收起/tooltip/菜单外点关闭) + if now - lastUiTickMs >= 100 { + lastUiTickMs = now + toolbar?.uiTick() + } + } + + /// 会话是否已被收束(覆盖层 finish 后残余 tick 防御)。 + private func overlayFinished() -> Bool { + return overlay?.isRunning == false + } + + // MARK: 滚轮消费(lc_panel_ui_windows.cpp WM_INPUT 分支逐条照搬) + + /// 解析并消费滚轮观察缓冲:纵向模式消费纵滚轮(忽略横滚轮);横向模式消费横滚轮与 + /// Shift+纵滚轮。方向解析在泵线程按当前模式进行(tap 线程只缓冲原始增量)。 + /// 行增量按 1 行 = 1 notch 归一到 Windows WHEEL_DELTA=120 计入软先验(只参与候选 + /// 排序加分,绝不约束搜索范围);亚行像素级滚动(触控板慢滚,行增量 = 0)只驱动 + /// 采样时机与方向,不计入 notch 先验,保持 px/notch 估计的量纲诚实。 + private func drainWheel(now: UInt64) { + guard let e = wheelBuffer.take() else { return } + var dir: Int32 = 0 + var accum: Int32 = 0 + var consume = false + if !horizontal { + // 纵向模式:正 delta = 向上滚(d<0 方向);忽略横滚轮 + if e.lineV != 0 { + dir = e.lineV > 0 ? -1 : 1 + accum = e.lineV * LC_WHEEL_DELTA_UNITS + consume = true + } else if e.pointV != 0 { + dir = e.pointV > 0 ? -1 : 1 + consume = true + } + } else if e.lineH != 0 { + // 横滚轮:正 delta = 向右滚 = 追加尾部(accum 取反对齐 wheelAccumDelta 符号约定) + dir = e.lineH > 0 ? 1 : -1 + accum = -e.lineH * LC_WHEEL_DELTA_UNITS + consume = true + } else if e.pointH != 0 { + // 亚行像素级横滚(触控板慢滚 / 本会话 autoScroll 注入的 pixel 增量): + // 只驱动采样时机与方向,不计入 notch 先验 + dir = e.pointH > 0 ? 1 : -1 + consume = true + } else if e.shift && e.lineV != 0 { + // Shift+滚轮向下 = 向右滚(多数应用把 Shift+滚轮翻译为水平滚动) + dir = e.lineV < 0 ? 1 : -1 + accum = e.lineV * LC_WHEEL_DELTA_UNITS + consume = true + } else if e.shift && e.pointV != 0 { + dir = e.pointV < 0 ? 1 : -1 + consume = true + } + guard consume else { return } + wheelPending = true + lastDir = dir + lastWheelTickMs = e.tickMs + if accum != 0 { + algo?.accumulateWheelDelta(delta: accum) + } + } + + // MARK: 采样轮(lc_session_windows.cpp RunLongCapture 的采样三条件 + 重试梯) + + /// 采样轮推进:三条件任一满足则开启一轮(停稳防抖 / 滚动中主动节拍 / Weak 待复核), + /// 轮内按重试梯推进(稳定性闸门 → 瞬态快重采样 → Normal/Weak 重试档),绝不放宽匹配 + /// 条件;重试耗尽仍硬失败 → 本轮放弃:不拼接、不污染基准、绝不自动完成。 + private func pumpSampleRound(now: UInt64) { + let consts = LCAlgoConsts.shared + if !inSampleRound { + // 触发三条件(lc_session_windows.cpp L202-208 逐式移植): + // · 停稳采样:滚轮 interval 内无新事件——捕获滚动尾段,消费 wheelPending; + // · 滚动中主动采样:距上次采样已达 min(interval, LC_SCROLL_SAMPLE_MAX_GAP), + // 不等停稳主动抓帧拼接(不消费 wheelPending,停稳后仍补收尾采样); + // · Weak 待复核:存在 pendingMatch 时即使没有新滚轮也继续采样。 + let scrollGap = min(intervalMs, consts.scrollSampleMaxGap) + let settle = wheelPending && now - lastWheelTickMs >= UInt64(intervalMs) + let midScroll = wheelPending && !settle && now - lastSampleTickMs >= UInt64(scrollGap) + let weakRecheck = (algo?.state.pendingValid ?? false) + guard settle || midScroll || weakRecheck else { return } + if settle { wheelPending = false } + trackRevBefore = algo?.state.trackingRevision ?? 0 + normalTries = 0 + weakRetries = 0 + stableWaits = 0 + quickResamples = 0 + lastFailReason = .none + lastResult = nil + inSampleRound = true + nextAttemptMs = now + } + guard now >= nextAttemptMs else { return } + + // 抓帧 → 识别 → 校验 →(Weak 档延迟确认)→(全部通过才)提交(LongCaptureTryStitch)。 + // 抓帧失败按瞬态故障走 Normal 重试梯(Windows:BitBlt 失败同样落入 Failed 分支); + // lastFailReason 清零防上一轮失败分类误触发快重采样分支。 + var oc: LCSampleOutcome = .failed + if let algo = algo, let frame = captureFrame() { + do { + // 稳定性闸门仅在等待预算内启用(自动滚动持续注入时页面恒处微滚动, + // 闸门注定不放行,直接跳过以免白耗重试预算——对齐 RunLongCapture 传参) + let allowGate = !autoScroll && stableWaits < consts.stableMaxWaits + let result = try algo.tryStitch(bgra: frame, direction: lastDir, allowStabilityGate: allowGate) + oc = result.outcome + lastFailReason = result.failReason + lastResult = result + } catch { + oc = .failed + lastFailReason = .none + } + } + + // 结局分流 + 轮内重试梯(语义 =「等待页面稳定」,绝不放宽匹配条件) + switch oc { + case .stitched, .repositioned: + // 已提交(新增行)或已重定位(回滚到已捕获范围内):本轮即告结束,重试只会 + // 对着新基准匹配到相同内容 + endSampleRound(outcome: oc, now: now) + return + case .weakPending, .weakRejected: + // Weak 候选:延迟确认需要更多次稳定采样,间隔走 weak 档 + if weakRetries >= consts.weakRetryAttempts { + endSampleRound(outcome: oc, now: now) + return + } + nextAttemptMs = now + UInt64(consts.retryDelayWeak[weakRetries]) + weakRetries += 1 + case .unstable: + // 稳定性未过:短延迟后重新采样(预算独立于 Normal/Weak 重试梯) + if stableWaits >= consts.stableMaxWaits { + endSampleRound(outcome: oc, now: now) + return + } + nextAttemptMs = now + UInt64(consts.stableRetryDelay[stableWaits]) + stableWaits += 1 + case .failed: + if quickResamples < consts.quickResamples + && (lastFailReason == .noCandidate || lastFailReason == .globalMismatch) { + // 瞬态失败(无候选/全宽不可评——多为抓在滚动/重绘过渡帧):几十毫秒级 + // 快重采样等页面稳定,而不是放宽匹配条件;不消耗主重试预算 + nextAttemptMs = now + UInt64(consts.resampleDelayQuick[quickResamples]) + quickResamples += 1 + } else if normalTries >= consts.sampleAttempts - 1 { + endSampleRound(outcome: oc, now: now) + return + } else { + nextAttemptMs = now + UInt64(consts.retryDelayNormal[normalTries]) + normalTries += 1 + } + case .noChange: + // 内容未变化:与「匹配失败」完全独立的事件;按常规重试等待渲染稳定后重试 + if normalTries >= consts.sampleAttempts - 1 { + endSampleRound(outcome: oc, now: now) + return + } + nextAttemptMs = now + UInt64(consts.retryDelayNormal[normalTries]) + normalTries += 1 + } + } + + /// 采样轮收束:按最终结局更新会话侧簿记 + 终止条件检查 + autoScroll 自动停止 + /// (对齐 lc_session_windows.cpp L247-300 的采样轮后处理,逐分支移植)。 + private func endSampleRound(outcome: LCSampleOutcome, now: UInt64) { + inSampleRound = false + lastSampleTickMs = now + switch outcome { + case .stitched: + noChangeCount = 0 + weakTries = 0 + frameCount += 1 + panel?.contentChanged() // 面板尺寸重算 + 重绘(LongCapturePanelUpdate) + toolbar?.refreshAll() // 宽×高标签刷新(LongCaptureToolbarRepaint) + case .repositioned: + // 反向回滚未越出已捕获边界:无新增行——不计帧数、不重算面板尺寸 + noChangeCount = 0 + weakTries = 0 + case .noChange: + // 连续多次未变化才确认滚动到底;匹配失败绝不计入该计数 + weakTries = 0 + noChangeCount += 1 + if noChangeCount >= LCAlgoConsts.shared.bottomConfirmSamples { + reachedBottom = true + } + case .weakPending, .weakRejected: + // 「发现了候选,但 Weak 验证/复核不足」:不计入任何终止条件;独立预算耗尽 + // 即放弃当前候选链,从干净基准重新观察 + noChangeCount = 0 + weakTries += 1 + if weakTries >= LCAlgoConsts.shared.weakMaxTries { + weakTries = 0 + algo?.abandonWeakChain() // pendingMatch 作废 + 时间一致性样本清空 + } + case .unstable, .failed: + // 硬失败(重试耗尽仍无可信对齐):不拼接、不污染基准、也绝不自动完成 + noChangeCount = 0 + } + // 自动滚动的自动停止:确认滚动到底,或连续多次硬失败(内容可能已滚出可匹配 + // 范围,继续注入滚轮只会丢内容)时停止注入——会话保留,由用户处置 + if autoScroll { + if outcome == .failed { + autoFailStreak += 1 + if autoFailStreak >= LC_AUTOSCROLL_STOP_FAILS { + setAutoScroll(false) + } + } else if outcome != .unstable && outcome != .weakPending { + autoFailStreak = 0 + } + if reachedBottom { + setAutoScroll(false) + } + } + // tentative 跟踪状态在本轮发生变化(多跳恢复/预测推进/候选否决回退): + // 小地图按「预计位置」刷新——正式拼接未变,不触发面板尺寸重算 + if let algo = algo, algo.state.trackingRevision != trackRevBefore { + panel?.trackingChanged() + } + lastResult = nil + } + + // MARK: 实况抓帧(lc_frame_io_windows.cpp LongCaptureCaptureFrameBuf 的 macOS 等价物) + + /// 抓取选区当前视口帧并转为算法层 BGRA 缓冲: + /// 1) CGWindowListCreateImage(.optionOnScreenBelowWindow, 蒙版窗口号)——蒙版是选区 + /// 所在屏的最上层窗口,「其下」即用户内容(小地图/工具栏层级更高同样被排除, + /// CGWindowListCreateImage 在 macOS 14+ deprecated 但可用); + /// 2) 转换为 BGRA 物理像素缓冲(premultipliedFirst + byteOrder32Little = 内存字节序 + /// B,G,R,A,对齐 lc_frame_io 的 32bpp 自上而下 DIB 帧布局); + /// 3) 横向模式帧缓冲转置(capW×capH → capH×capW = physW×physH,水平滚动位移映射为 + /// 垂直位移,整条管线与纵向同构——lc_frame_io_windows.cpp 同款 T[r][q] = S[q][r])。 + /// - Returns: physW×physH 的 BGRA 帧缓冲;抓帧/转换失败返回 nil(绝不返回残帧) + private func captureFrame() -> [UInt32]? { + guard maskNumber != 0 else { return nil } + guard let img = CGWindowListCreateImage( + cropRect, .optionOnScreenBelowWindow, maskNumber, .bestResolution) else { return nil } + // 帧内容写入 capW×capH(未转置)BGRA 缓冲 + var raw = [UInt32](repeating: 0, count: capW * capH) + let ok = raw.withUnsafeMutableBytes { ptr -> Bool in + guard let base = ptr.baseAddress, + let ctx = CGContext(data: base, width: capW, height: capH, + bitsPerComponent: 8, bytesPerRow: capW * 4, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedFirst.rawValue + | CGBitmapInfo.byteOrder32Little.rawValue) else { return false } + ctx.interpolationQuality = .none + ctx.draw(img, in: CGRect(x: 0, y: 0, width: CGFloat(capW), height: CGFloat(capH))) + return true + } + guard ok else { return nil } + guard horizontal else { return raw } + // 横向模式:帧缓冲转置(对齐 lc_frame_io_windows.cpp 的 T(行 r= LC_AUTOSCROLL_TICK_MS else { return } + lastAutoScrollTickMs = now + let event: CGEvent? + if horizontal { + // 向右滚 = 追加尾部(+8px 亚档位增量;两轴事件用带横轴的构造器,pixel 单位) + event = CGEvent(scrollWheelEvent2Source: nil, units: .pixel, + wheelCount: 2, wheel1: Int32(0), wheel2: LC_AUTOSCROLL_STEP_DELTA, wheel3: Int32(0)) + } else { + // 向下滚 = 追加尾部(-8px 亚档位增量;正增量 = 向上滚) + event = CGEvent(scrollWheelEvent2Source: nil, units: .pixel, + wheelCount: 2, wheel1: -LC_AUTOSCROLL_STEP_DELTA, wheel2: Int32(0), wheel3: Int32(0)) + } + event?.post(tap: .cghidEventTap) + } + + // MARK: 裁剪与方向(工具栏回调;算法交互只经 LCAlgorithmSession) + + /// 应用裁剪(对齐 lc_toolbar_ui_windows.cpp LongCaptureApplyCrop 的会话侧调用): + /// row = 0 丢弃上方(横向 = 左侧)/ 1 丢弃下方(横向 = 右侧)/ 2 重置(仅已裁剪时)。 + /// 登记只收紧输出行窗口并记录待剔除区间;物理删行由下一次朝该方向的成功提交触发 + /// (CommitStitch 入口的延迟剔除,算法层已实现)。应用后刷新小地图与工具栏。 + func applyCrop(row: Int32) { + guard let algo = algo, !ended else { return } + let applied = (try? algo.applyCrop(row: row)) ?? false + guard applied else { return } + panel?.contentChanged() // 面板尺寸随输出行窗口重算(LongCapturePanelUpdate) + toolbar?.refreshAll() // badge 高亮(cropped → 裁剪按钮 active 态) + } + + /// 切换长截图方向(纵向 ⇄ 横向)。已拼接多帧(frameCount > 1)后禁用(坐标系不同 + /// 不可混拼,对齐 LongCaptureSwitchDirection)。macOS 实现 = 销毁并重建算法层会话 + /// (新帧缓冲尺寸)+ 完全重置会话侧簿记 + 重抓首帧(对齐 LongCaptureResetSession)。 + /// 重建失败按抓帧失败收束整个会话(FailFast)。 + func switchDirection() { + guard frameCount <= 1, !ended, let ov = overlay else { return } + horizontal.toggle() + // 帧缓冲转置复用纵向管线:physW/physH 交换(capW/capH 抓帧尺寸不变) + physW = horizontal ? capH : capW + physH = horizontal ? capW : capH + // 缩略图列宽随新帧宽重算:由新算法会话的 thumbW 配置承担(bridge 内钳制) + algo = try? LCAlgorithmSession( + width: physW, height: physH, + config: LCAlgorithmSession.Config( + interval: intervalMs, thumbW: thumbW, horizontal: horizontal)) + guard algo != nil else { + finishWholeSession(payload: ov.failurePayload("failed to reinit long capture session")) + return + } + // 完全重置会话侧簿记(对齐 LongCaptureResetSession 的会话字段段) + noChangeCount = 0 + reachedBottom = false + weakTries = 0 + autoFailStreak = 0 + wheelPending = false + lastDir = 0 + wheelBuffer.clear() + setAutoScroll(false) + if !initBaseline() { + finishWholeSession(payload: "{\"success\":false}") + return + } + lastSampleTickMs = lcNowMs() + panel?.resetForDirectionChange() + toolbar?.refreshAll() + } + + // MARK: 终止收束(lc_session_windows.cpp 主循环各 break 分支 + 输出路径) + + /// 完成并复制(工具栏「完成并复制」按钮触发;对齐 + /// RunLongCapture 主循环结束后的输出块):构建最终输出 → PNG → 剪贴板 → 成功回调 + /// (回调含 cropRect 坐标与拼接后逻辑尺寸);任一环节失败按 {success:false} 收束。 + func finishAndCopy() { + guard !ended, overlay != nil else { return } + guard let out = buildFinalOutput() else { + finishWholeSession(payload: "{\"success\":false}") + return + } + guard writePngToPasteboard(out.pngData) else { + finishWholeSession(payload: "{\"success\":false}") + return + } + finishWholeSession(payload: lcSuccessPayload(base64: out.base64, + logicalW: out.logicalW, logicalH: out.logicalH)) + } + + /// 保存到本地(工具栏「保存」;对齐 RunLongCapture 主循环的 saveFlag 分支): + /// 停自动滚动 → 收起菜单 → 临时降浮层层级(Windows 摘除 TOPMOST 等价)→ 保存对话框 + /// → 取消则继续捕获(无回调);选定路径则构建输出 → 原子落盘 → base64 回调 + /// (不进剪贴板)并收束整个会话。编码/落盘失败继续捕获(对齐 Windows 的 ok=false + /// 不 break 分支语义)。 + private func handleSave() { + guard !ended, let ov = overlay else { return } + setAutoScroll(false) // 模态对话框期间不得继续注入滚轮 + toolbar?.closeMenu() // 收起展开中的二级菜单 + // 弹出前临时降浮层层级 + event tap 放行模态按键(保存流同款,Windows 摘 TOPMOST) + duckLevels(true) + ov.saveModalFlag.set() + defer { + ov.saveModalFlag.reset() + duckLevels(false) + } + guard let path = scPromptSaveFilePath() else { return } // 取消对话框 → 继续捕获 + guard let out = buildFinalOutput() else { return } // 编码失败 → 继续捕获 + do { + try out.pngData.write(to: URL(fileURLWithPath: path), options: .atomic) + } catch { + return // 落盘失败 → 继续捕获 + } + // 保存成功:base64 回调(不进剪贴板)并收束整个会话(savedToFile 语义) + finishWholeSession(payload: lcSuccessPayload(base64: out.base64, + logicalW: out.logicalW, logicalH: out.logicalH)) + } + + /// 取消长截图(工具栏取消 / ESC)→ 整会话失败收束(对齐 Windows:lc_session_windows.cpp + /// 主循环 abortFlag → LongCaptureEmitFailure → EmitScreenshotResult(false), + /// wndproc WM_LONGCAPTURE_RUN 清理后 ctx->state = CS_Done + DestroyWindow—— + /// 取消不回编辑态)。销毁长截图浮层与滚轮 tap、复位中止标志,随后 finish 收口回调。 + func cancelSessionAsFailure() { + finishWholeSession(payload: "{\"success\":false}") + } + + /// 收束整个截图会话(abort / 完成 / 保存成功 / 失败路径的统一出口): + /// 销毁长截图浮层 → 覆盖层会话 finish(恰好一次回调 + 复位重入标志)。 + private func finishWholeSession(payload: String) { + guard !ended else { return } + ended = true + setAutoScroll(false) + lcTeardown() + guard let ov = overlay else { return } + ov.longCapture = nil + ov.finish(payload) + } + + /// 长截图浮层与观察器清理全集(收束与取消共用;幂等):销毁蒙版/小地图/工具栏窗口、 + /// 停滚轮 tap、复位长截图态标志(对齐 wndproc_windows.cpp WM_LONGCAPTURE_RUN 清理段)。 + func lcTeardown() { + wheelTap?.stop() + wheelTap = nil + toolbar?.destroy() + toolbar = nil + panel?.destroy() + panel = nil + for w in maskWindows { + w.orderOut(nil) + w.contentView = nil + } + maskWindows.removeAll() + overlay?.longCaptureFlag.reset() + resetLongCaptureAbort() + } + + /// 保存对话框模态期间临时降浮层层级(蒙版/小地图/工具栏/popover/tooltip 全组; + /// Windows LongCaptureSetTopmost(false) 的 macOS 等价——模态面板层级高于浮层族)。 + private var duckedLevels: [ObjectIdentifier: NSWindow.Level] = [:] + private func duckLevels(_ lowered: Bool) { + let panels = maskWindows + [panel?.window, toolbar?.window, toolbar?.popoverWindow, + toolbar?.tipWindow].compactMap { $0 } + if lowered { + duckedLevels.removeAll() + for w in panels { + duckedLevels[ObjectIdentifier(w)] = w.level + w.level = SC.saveModalDuckLevel + } + } else { + for w in panels { + if let level = duckedLevels[ObjectIdentifier(w)] { + w.level = level + } + } + duckedLevels.removeAll() + } + } + + // MARK: 输出(lc_frame_io_windows.cpp LongCaptureBuildResultBitmap + LongCaptureBuildFinalBitmap) + + /// 构建最终输出:读裁剪行窗口拼接缓冲(headRev 倒序头部段 + body 正序主体段的双段 + /// 映射已由 lc_read_rows 解出)→ 横向模式回转(拼接空间 physW×rows → 显示空间 + /// rows×physW)→ 物理像素 1:1 输出(Retina 下不再缩回逻辑尺寸——缩回丢一半分辨率 + /// 导致发虚,与区域截图统一输出管线同口径)→ 单次 PNG 编码, + /// PNG 携带 DPI 元数据供看图应用按逻辑尺寸显示。 + /// 长截图不合成编辑态标注、不做圆角蒙版——对齐 Windows LongCaptureBuildFinalBitmap + /// (仅 BuildResultBitmap + 缩放,无 CompositeAnnotations 调用)。 + /// - Returns: PNG 文件字节 + base64 data URL + 最终逻辑尺寸;无内容/编码失败返回 nil + func buildFinalOutput() -> (pngData: Data, base64: String, logicalW: Int, logicalH: Int)? { + guard let algo = algo else { return nil } + // 裁剪行窗口(未裁剪 = [0, stitchH);裁剪只约束输出,不动拼接缓冲) + guard let rows = try? algo.outputRows(), rows.bottom > rows.top else { return nil } + let rowCount = Int(rows.bottom - rows.top) + guard rowCount > 0, physW > 0, + let merged = try? algo.readRows(start: Int(rows.top), count: rowCount) else { return nil } + + // 横向模式:转置回原方向(F(行 fy= 1, lh >= 1 else { return nil } + + // 物理像素 1:1 编码(不重采样) + guard let ctx = CGContext( + data: nil, width: outWPhys, height: outHPhys, + bitsPerComponent: 8, bytesPerRow: outWPhys * 4, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + | CGBitmapInfo.byteOrder32Big.rawValue) else { return nil } + ctx.interpolationQuality = .none + ctx.draw(physImg, in: CGRect(x: 0, y: 0, width: CGFloat(outWPhys), height: CGFloat(outHPhys))) + guard let final = ctx.makeImage() else { return nil } + let rep = NSBitmapImageRep(cgImage: final) + // PNG 携带 DPI 元数据(Retina=144dpi):看图应用按逻辑尺寸显示,等价系统截图 + // 行为;像素数据不变,仅元数据 + rep.size = NSSize(width: CGFloat(lw), height: CGFloat(lh)) + guard let pngData = rep.representation(using: .png, properties: [:]), !pngData.isEmpty else { + return nil + } + return (pngData, + "data:image/png;base64," + pngData.base64EncodedString(), + lw, lh) + } + + /// 构造长截图成功回调 JSON(契约字段与 Windows RunLongCapture 的 EmitScreenshotResult + /// 一致:x/y/x2/y2 = 采样裁剪矩形(CG 全局逻辑坐标);width/height = 拼接后输出图像的 + /// 逻辑尺寸——与区域截图不同,纵向长图的 height 是拼接总行数而非选区高)。 + private func lcSuccessPayload(base64: String, logicalW: Int, logicalH: Int) -> String { + let x = Int(cropRect.minX.rounded()) + let y = Int(cropRect.minY.rounded()) + let x2 = Int(cropRect.maxX.rounded()) + let y2 = Int(cropRect.maxY.rounded()) + return "{" + + "\"success\":true" + + ",\"x\":\(x)" + + ",\"y\":\(y)" + + ",\"x2\":\(x2)" + + ",\"y2\":\(y2)" + + ",\"width\":\(logicalW)" + + ",\"height\":\(logicalH)" + + ",\"base64\":\"\(base64)\"" + + "}" + } + + // MARK: 小地图数据出口(ScreenshotLCPanelMac.swift 使用) + + /// 虚拟屏并集(浮层放置钳制边界;CG 全局逻辑坐标)。 + var virtualBounds: CGRect { overlay?.virtualBounds ?? .null } + + /// 是否存在任何生效的裁剪约束(裁剪按钮 badge 高亮;对齐 LongCaptureContext.cropped, + /// 含「已登记待剔除区间」的延迟剔除状态)。 + var isCropped: Bool { algo?.hasCropConstraint ?? false } + + /// 预览宽×高标签文本(LongCaptureOutputSizeLabel 移植:固定轴取 cropRect 逻辑尺寸、 + /// 滚动轴取 rows / scale;含裁剪窗口,横向模式宽高已回转)。 + var sizeLabelText: String { + let win = outputRowWindow + let rows = win.bottom - win.top + guard rows > 0 else { return "0 × 0" } + let w = horizontal ? Double(rows) / Double(scale) : Double(cropRect.width) + let h = horizontal ? Double(cropRect.height) : Double(rows) / Double(scale) + return "\(Int(w + 0.5)) × \(Int(h + 0.5))" + } + + /// 工具栏「完成并复制」→ 置完成标志(主循环检查点收束;对齐 finishFlag)。 + func requestFinish() { + finishRequested = true + } + + /// 工具栏「取消」→ 取消长截图:整会话失败收束(对齐 Windows lc_toolbar_ui_windows.cpp + /// LTI_Cancel → abortFlag 置位 → LongCaptureEmitFailure 路径)。 + func requestCancel() { + cancelSessionAsFailure() + } + + /// 工具栏「保存到本地」→ 置保存标志(主循环检查点处理;对齐 saveFlag)。 + func requestSave() { + saveRequested = true + } + + /// 当前输出行窗口(裁剪后的拼接行区间;小地图预览与视口框换算共用)。 + var outputRowWindow: (top: Int64, bottom: Int64) { + guard let rows = try? algo?.outputRows() else { + let h = Int64(algo?.state.stitchH ?? 0) + return (0, h) + } + return rows + } + + /// 读取当前输出行窗口的缩略图缓冲并转为显示空间 CGImage(小地图缩略列绘制用): + /// 纵向 = thumbW×rows 的纵向条;横向 = 回转后的 rows×thumbW(对齐 lc_frame_io_windows.cpp + /// LongCaptureRebuildThumb / LongCaptureRebuildThumbDisplay 的合并与回转序)。 + func readPreviewThumbImage() -> CGImage? { + guard let algo = algo else { return nil } + let st = algo.state + guard st.thumbW > 0, st.thumbH > 0 else { return nil } + let win = outputRowWindow + let rows = Int(win.bottom - win.top) + guard rows > 0 else { return nil } + let start = max(0, min(Int(win.top), st.thumbH - 1)) + let count = min(rows, st.thumbH - start) + guard count > 0, let buf = try? algo.readThumbRows(start: start, count: count) else { return nil } + guard horizontal else { + return lcMakeCGImage(bgra: buf, width: st.thumbW, height: count) + } + // 横向模式:转置回原方向(thumbDisplay[y*h + x] = thumbMerged[x*w + y],显示宽 = 行数) + let w = st.thumbW + let h = count + var disp = [UInt32](repeating: 0, count: buf.count) + for y in 0..= 1, sel.height >= 1 else { return } + guard let lc = ScreenshotLongCaptureSession(overlay: self, selection: sel), + lc.start() else { + return + } + longCapture = lc + state = .longCapturing + longCaptureFlag.set() // event tap 的 ESC 从此刻起路由到 longCancelFlag + } + + /// 取消长截图(泵循环消费 ESC/兜底取消标志):整会话按失败收束(对齐 Windows + /// lc_session_windows.cpp 取消路径 abortFlag → LongCaptureEmitFailure → 会话 CS_Done 结束; + /// 不回编辑态)。 + func cancelLongCaptureSession() { + guard state == .longCapturing else { return } + if let lc = longCapture { + longCapture = nil + lc.cancelSessionAsFailure() // finishWholeSession 内部复位 state 并收口回调 + } else { + // 防御:LC 会话缺席时兜底走覆盖层通用取消收口(正常路径不可达) + state = .confirmed + cancelSession() + } + } +} diff --git a/src/screenshot/macos/ScreenshotMac.swift b/src/screenshot/macos/ScreenshotMac.swift new file mode 100644 index 0000000..1233282 --- /dev/null +++ b/src/screenshot/macos/ScreenshotMac.swift @@ -0,0 +1,404 @@ +import Foundation +import AppKit +import CoreGraphics + +// MARK: - 截图模块(macOS) +// +// 本文件是 macOS 截图子系统的平台层落点(会话/覆盖层/绘制拆分在同目录其余文件): +// - CaptureBackend 抽象 + CGWindowListCreateImage 首期实现(接口按 ScreenCaptureKit 形状设计) +// - 屏幕录制权限预检/请求 +// - prime() 预抓帧(2 秒 TTL、互斥锁保护、锁内所有权转移,对齐 Windows capture_windows.cpp) +// - start() 闭环:权限 →(预抓帧或现场重抓)整屏底图 → 多屏覆盖层选区会话 +// (ScreenshotOverlayMac.swift:手动泵主循环 + 选区状态机)→ 确认时按选区裁剪底图 +// → PNG 编码 → NSPasteboard 写入 → 经 C++ screenshotTsfn 回调契约结果 +// - abortLongCapture() 中止标记:锁内置标志 set/consume/reset(长截图采样循环 +// 在检查点消费并按失败结果收束会话,对齐 Windows LongCaptureAbort 语义) +// +// 坐标系约定:会话内统一 CG 全局坐标(左上原点、逻辑点), +// 回调的 x/y/x2/y2/width/height 均为逻辑尺寸;base64 图像为物理像素(Retina 2x, +// PNG 携带 DPI 元数据供看图应用按逻辑尺寸显示,见 ScreenshotOutputMac.swift)。 + +// C 风格回调:截图会话结果(JSON 字符串;生命周期仅限本次调用,C++ 层负责复制) +public typealias ScreenshotResultCallback = @convention(c) (UnsafePointer?) -> Void + +// 预抓帧 TTL(对齐 Windows internal.h 的 SC_PRIMED_FRAME_TTL = 2 秒) +private let SC_PRIMED_FRAME_TTL_NANOS: UInt64 = 2_000_000_000 + +// MARK: - 基础类型 + +/// 一帧屏幕捕获结果:物理像素图像 + 还原逻辑坐标所需的元数据。 +/// 坐标为 CG 全局坐标(左上原点、逻辑点),与 Windows 回调的虚拟屏绝对坐标语义对齐。 +/// (跨文件共享:覆盖层会话按此裁剪底图/放大镜采样。) +struct CapturedFrame { + let image: CGImage // 物理像素位图(Retina 下为 2x) + let origin: CGPoint // 虚拟屏并集左上角(CG 全局逻辑坐标) + let logicalSize: CGSize // 逻辑尺寸(回调契约的 width/height 即此值) + let scale: CGFloat // 物理/逻辑缩放比(Retina = 2.0;混合 DPI 下为并集整体比例) + let capturedAtNanos: UInt64 // 单调时钟抓取时刻(DispatchTime.uptimeNanoseconds),TTL 判定用 +} + +/// 捕获内容过滤器(对齐 ScreenCaptureKit 的 SCContentFilter 形状)。 +/// rect 为 CG 全局逻辑坐标目标区域;excludingWindowNumbers 为需排除的自身窗口号 +/// (蒙版/面板等覆盖层窗口,语义对齐 SCK 的 excludingWindows)。 +private struct CaptureContentFilter { + let rect: CGRect + let excludingWindowNumbers: [CGWindowID] +} + +/// 捕获配置(对齐 ScreenCaptureKit 的 SCStreamConfiguration 形状,仅含分辨率与光标两项)。 +private struct CaptureConfiguration { + /// true = Retina 下输出物理像素(等价 CGWindowListCreateImage 的 .bestResolution) + var bestResolution: Bool = true + /// 是否包含鼠标光标。CG 后端天然不捕获光标(截图工具期望行为); + /// 升级 ScreenCaptureKit 时需显式传 showsCursor=false 保持行为一致。 + var showsCursor: Bool = false +} + +// MARK: - 捕获后端 + +/// 捕获后端抽象:接口按 ScreenCaptureKit 的「内容过滤 + 配置 → 图像」形状设计, +/// 首期实现为 CGWindowListCreateImage(10.15 可用、已被取色器验证、支持 +/// .optionOnScreenBelowWindow 排除自身窗口)。CGWindowListCreateImage 在 macOS 14+ +/// 标记 deprecated 但仍可用(deployment target 10.15);SCK(12.3+)升级时仅替换 +/// 后端实现,会话层不变。 +private protocol CaptureBackend { + /// 截取整屏底图:所有 NSScreen 的并集(等价 Windows 虚拟屏),Retina 下输出物理像素。 + /// - Returns: 捕获帧;失败返回 nil(对齐 Windows 抓帧失败绝不把黑图报成功的语义,矩阵 #48) + func captureVirtualScreenBase() -> CapturedFrame? + + /// 按内容过滤器抓取一帧(覆盖层底图 / 长截图实况抓帧共用入口)。 + /// - Parameters: + /// - filter: 目标区域与需排除的窗口 + /// - configuration: 分辨率/光标配置 + /// - Returns: 物理像素 CGImage;失败返回 nil + func captureImage(filter: CaptureContentFilter, configuration: CaptureConfiguration) -> CGImage? +} + +/// CGWindowListCreateImage 后端实现。 +private struct CGWindowListCaptureBackend: CaptureBackend { + func captureVirtualScreenBase() -> CapturedFrame? { + guard let bounds = ScreenshotGeometry.virtualScreenBounds() else { return nil } + guard let image = captureImage( + filter: CaptureContentFilter(rect: bounds, excludingWindowNumbers: []), + configuration: CaptureConfiguration() + ) else { return nil } + + let scale = bounds.width > 0 ? CGFloat(image.width) / bounds.width : 1.0 + return CapturedFrame( + image: image, + origin: bounds.origin, + logicalSize: bounds.size, + scale: scale, + capturedAtNanos: DispatchTime.now().uptimeNanoseconds + ) + } + + func captureImage(filter: CaptureContentFilter, configuration: CaptureConfiguration) -> CGImage? { + // 排除窗口:CGWindowListCreateImage 只支持「取某窗口之下」的单窗口排除—— + // 蒙版窗口是全屏最上层窗口,"其下"即用户内容(取色器 capturePixelsAroundCursor + // 已验证该手法);多窗口排除需 ScreenCaptureKit 的 excludingWindows。 + let option: CGWindowListOption + let windowID: CGWindowID + if let exclude = filter.excludingWindowNumbers.first { + option = .optionOnScreenBelowWindow + windowID = exclude + } else { + option = .optionOnScreenOnly + windowID = kCGNullWindowID + } + + let imageOption: CGWindowImageOption = configuration.bestResolution + ? .bestResolution + : .nominalResolution + return CGWindowListCreateImage(filter.rect, option, windowID, imageOption) + } +} + +// MARK: - 坐标换算 + +/// 坐标换算工具(会话内统一 CG 全局坐标、左上原点、逻辑点)。 +/// (跨文件共享:覆盖层窗口定位、鼠标事件换算、蒙版绘制均依赖。) +enum ScreenshotGeometry { + /// NS 全局坐标的 Y 轴翻转基准:主屏(frame.origin == .zero 的屏幕)的逻辑高度。 + static func primaryScreenHeight() -> CGFloat { + let screens = NSScreen.screens + return (screens.first { $0.frame.origin == .zero }?.frame.height) + ?? (screens.first?.frame.height ?? 0) + } + + /// 计算所有 NSScreen 的并集(等价 Windows 虚拟屏),返回 CG 全局逻辑坐标矩形。 + /// NSScreen.frame 为 NS 坐标(主屏左下原点、Y 向上),需翻转为 CG 左上原点。 + /// - Returns: 虚拟屏并集矩形;无屏幕或退化矩形时返回 nil + static func virtualScreenBounds() -> CGRect? { + let screens = NSScreen.screens + guard !screens.isEmpty else { return nil } + + var minX = CGFloat.greatestFiniteMagnitude + var minY = CGFloat.greatestFiniteMagnitude + var maxX = -CGFloat.greatestFiniteMagnitude + var maxY = -CGFloat.greatestFiniteMagnitude + for screen in screens { + let cg = cgFrame(of: screen) + minX = min(minX, cg.minX) + minY = min(minY, cg.minY) + maxX = max(maxX, cg.maxX) + maxY = max(maxY, cg.maxY) + } + + let bounds = CGRect(x: minX, y: minY, width: maxX - minX, height: maxY - minY) + guard bounds.width > 0, bounds.height > 0 else { return nil } + return bounds + } + + /// 单个 NSScreen 的 CG 全局逻辑坐标帧(左上原点):供每屏一个覆盖层窗口定位与 + /// 鼠标事件/绘制坐标换算(多屏覆盖层坐标系)。 + static func cgFrame(of screen: NSScreen) -> CGRect { + let frame = screen.frame + let cgTop = primaryScreenHeight() - frame.maxY + return CGRect(x: frame.minX, y: cgTop, width: frame.width, height: frame.height) + } + + /// NS 全局坐标点 → CG 全局坐标点(Y 翻转;取色器同款换算)。 + static func cgPoint(fromNS nsPoint: NSPoint) -> CGPoint { + return CGPoint(x: nsPoint.x, y: primaryScreenHeight() - nsPoint.y) + } +} + +// MARK: - 预抓帧缓存 + +/// 预抓帧缓存:对齐 Windows capture_windows.cpp 的 g_primedScreenshotFrame 语义—— +/// 2 秒 TTL、互斥锁保护、锁内所有权转移(写方写入、读方消费后原帧即失效)。 +private final class PrimedFrameStore { + private let lock = NSLock() + private var frame: CapturedFrame? + + /// 立即抓取整屏底图写入缓存。 + /// 抓帧在锁外执行(避免持锁做耗时系统调用),锁内仅做所有权转移。 + /// - Returns: 抓帧成功返回 true + func refresh(backend: CaptureBackend) -> Bool { + guard let newFrame = backend.captureVirtualScreenBase() else { return false } + lock.lock() + frame = newFrame + lock.unlock() + return true + } + + /// 消费缓存帧:未过期时移出并返回(锁内所有权转移,消费后缓存失效); + /// 未命中或超过 TTL 时清空缓存并返回 nil,调用方(start 会话)应现场重抓 + /// (对齐 Windows ConsumePrimedScreenshotFrame 的过期释放语义)。 + func consume() -> CapturedFrame? { + lock.lock() + defer { lock.unlock() } + guard let cached = frame else { return nil } + frame = nil + let elapsed = DispatchTime.now().uptimeNanoseconds &- cached.capturedAtNanos + guard elapsed <= SC_PRIMED_FRAME_TTL_NANOS else { return nil } + return cached + } + + /// 丢弃缓存帧(预留给会话异常清理路径;普通路径由 consume 转移所有权)。 + func discard() { + lock.lock() + frame = nil + lock.unlock() + } +} + +// MARK: - 会话状态 + +// 会话重入保护(C++ 层有对应 g_screenshotInProgress 拦截 JS 侧重复 start,此处兜底直接 FFI 调用)。 +// (跨文件共享:覆盖层会话 finish 时复位。) +let screenshotStateLock = NSLock() +var screenshotSessionActive = false + +// 预抓帧缓存与捕获后端(进程级单例;覆盖层会话消费底图) +private let primedFrameStore = PrimedFrameStore() +private let screenshotBackend: CaptureBackend = CGWindowListCaptureBackend() + +// 长截图会话中止状态(锁内置 abortFlag,与清理互斥防 use-after-free)。 +// 对齐 Windows LongCaptureAbort(lc_session_windows.cpp):JS 线程置位(request),长截图采样 +// 循环在检查点消费(consume,读后自动复位),会话创建时重置防跨会话粘滞(reset)。 +let longCaptureAbortLock = NSLock() +private var longCaptureAbortRequested = false + +/// 置位长截图中止标志(abortLongCapture 导出的实现体;可在任意线程调用)。 +func requestLongCaptureAbort() { + longCaptureAbortLock.lock() + longCaptureAbortRequested = true + longCaptureAbortLock.unlock() +} + +/// 消费长截图中止标志(长截图采样循环检查点调用;读后自动复位,保证一次中止只收束一次)。 +/// - Returns: 自上次消费以来是否被请求过中止 +func consumeLongCaptureAbort() -> Bool { + longCaptureAbortLock.lock() + defer { longCaptureAbortLock.unlock() } + if longCaptureAbortRequested { + longCaptureAbortRequested = false + return true + } + return false +} + +/// 重置长截图中止标志(长截图会话创建时调用;防上一次会话遗留的置位误杀新会话)。 +func resetLongCaptureAbort() { + longCaptureAbortLock.lock() + longCaptureAbortRequested = false + longCaptureAbortLock.unlock() +} + +// MARK: - 会话选项 + +/// 截图会话选项(JS options 的解析产物;C++ 层已按 Windows 语义钳制,此处二次校验兜底)。 +/// (跨文件共享:覆盖层会话读取 autoConfirm。) +struct ScreenshotSessionOptions { + /// 选区确定后直接出图,跳过编辑态(覆盖层生效:松手/单击吸附 → 直接确认输出) + var autoConfirm = true + /// 长截图滚轮停稳防抖间隔(50~2000ms,默认 250;长截图生效) + var longCaptureIntervalMs = 250 + + /// 从 C++ 层传入的 options JSON 解析;钳制范围与 Windows session_windows.cpp 一致, + /// 非法/越界值回落默认值(不做静默贴边钳制,对齐 Windows 行为)。 + static func parse(from jsonString: String?) -> ScreenshotSessionOptions { + var options = ScreenshotSessionOptions() + guard let jsonString = jsonString, + let data = jsonString.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return options + } + if let autoConfirm = object["autoConfirm"] as? Bool { + options.autoConfirm = autoConfirm + } + if let longCapture = object["longCapture"] as? [String: Any] { + if let interval = longCapture["interval"] as? Int, (50...2000).contains(interval) { + options.longCaptureIntervalMs = interval + } + } + return options + } +} + +// MARK: - 会话入口(覆盖层与选区) + +/// 启动覆盖层选区会话的统一入口:权限预检 → NSApplication 初始化 → 底图获取(预抓帧优先) +/// → 窗口吸附枚举 → 多屏覆盖层 + 手动泵主循环(ScreenshotOverlayMac.swift)。 +/// 任一前置失败都恰好回调一次 {success:false, error:...}(FailFast 语义); +/// 会话正常结束(确认/取消)由覆盖层会话负责回调并复位重入标志。 +/// - Parameters: +/// - options: 已解析的会话选项(autoConfirm / longCapture 参数均生效) +/// - callback: C++ 层注册的结果回调(JSON 字符串参数) +func runOverlayCaptureSession(options: ScreenshotSessionOptions, callback: ScreenshotResultCallback) { + // 单一出口:结果 JSON 回调一次并复位会话标志(重入保护随之解除) + func finish(_ payload: String) { + payload.withCString { cStr in + callback(cStr) + } + screenshotStateLock.lock() + screenshotSessionActive = false + screenshotStateLock.unlock() + } + + func failurePayload(_ error: String) -> String { + return "{\"success\":false,\"error\":\"\(error)\"}" + } + + // 0) AppKit 主线程硬要求:NSWindow 创建与手动泵事件循环只能在主线程执行 + // (线程模型:start() 的 N-API 调用线程即 AppKit 主线程)。 + guard Thread.isMainThread else { + finish(failurePayload("screenshot session must run on the main thread")) + return + } + + // 1) 屏幕录制权限:预检未过先请求(弹系统授权框),仍失败按契约回调。 + // 注意 CGWindowListCreateImage 在未授权时并不报错,而是返回缺窗口内容的"伪底图", + // 因此必须在抓帧前硬性预检,绝不把无窗口内容的图当成功输出。 + if !CGPreflightScreenCaptureAccess() { + _ = CGRequestScreenCaptureAccess() + if !CGPreflightScreenCaptureAccess() { + finish(failurePayload("screen recording permission required")) + return + } + } + + // 2) NSApplication 初始化(取色器模式:accessory policy;Node 主线程不跑 NSRunLoop, + // 覆盖层窗口由会话内的手动泵循环驱动) + let app = NSApplication.shared + app.setActivationPolicy(.accessory) + app.finishLaunching() + + // 3) 底图获取:优先消费 prime() 预抓帧(未过期),过期/未命中时现场重抓 + // (对齐 Windows AcquireScreenshotBase:预抓帧命中即用,否则 CaptureVirtualScreen 兜底) + guard let baseFrame = primedFrameStore.consume() ?? screenshotBackend.captureVirtualScreenBase() else { + finish(failurePayload("failed to capture screen")) + return + } + + guard let virtualBounds = ScreenshotGeometry.virtualScreenBounds() else { + finish(failurePayload("failed to query screen layout")) + return + } + + // 4) 覆盖层选区会话(会话内手动泵直至确认/取消;结束前回调恰好一次) + let session = ScreenshotOverlaySession( + options: options, + callback: callback, + baseFrame: baseFrame, + virtualBounds: virtualBounds + ) + guard session.start() else { + // 会话初始化失败:start 内部已 FailFast 回调并复位标志 + return + } + session.runEventPump() +} + +// MARK: - C 导出(binding_mac.cpp 经 dlsym 调用) + +/// 供 JS 主动触发的整屏预抓帧(对齐 Windows PrimeScreenshotFrameNow / primeScreenshotFrame 导出)。 +/// 抓帧在锁外执行、锁内所有权转移。未授权屏幕录制时直接失败且不弹授权框—— +/// 授权框交互只在 start() 会话内发生,避免把缺窗口内容的"伪底图"写进缓存。 +/// - Returns: 1 抓帧成功;0 失败(无权限 / 抓帧失败) +@_cdecl("primeScreenshotFrame") +public func primeScreenshotFrame() -> Int32 { + if !CGPreflightScreenCaptureAccess() { + return 0 + } + return primedFrameStore.refresh(backend: screenshotBackend) ? 1 : 0 +} + +/// 启动区域截图会话(覆盖层与选区;对齐 Windows startRegionCaptureWithPrimedFrame 导出)。 +/// 与 Windows 会话线程模型的差异:macOS 的 N-API 调用线程即 AppKit 主线程,覆盖层 +/// 会话在本调用内以手动泵循环运行直至确认/取消,故本函数阻塞至会话结束。 +/// - Parameters: +/// - optionsJson: C++ 层解析并钳制后的 options JSON(autoConfirm / longCapture 参数) +/// - callback: 结果回调;会话出口恰好回调一次(成功/失败均必达——FailFast 语义) +/// - Returns: 1 = 会话已受理(结果异步回调);0 = 拒绝(重入/参数非法,不会回调) +@_cdecl("startRegionCaptureWithPrimedFrame") +public func startRegionCaptureWithPrimedFrame( + _ optionsJson: UnsafePointer?, + _ callback: ScreenshotResultCallback? +) -> Int32 { + guard let callback = callback else { return 0 } + + // 重入保护:会话进行中拒绝再次进入(C++ 层已对 JS 抛错,此处兜底直接 FFI 调用) + screenshotStateLock.lock() + if screenshotSessionActive { + screenshotStateLock.unlock() + return 0 + } + screenshotSessionActive = true + screenshotStateLock.unlock() + + let options = ScreenshotSessionOptions.parse(from: optionsJson.map { String(cString: $0) }) + runOverlayCaptureSession(options: options, callback: callback) + return 1 +} + +/// 请求中止进行中的长截图滚动捕获(对齐 Windows LongCaptureAbort:锁内置 abortFlag, +/// 与清理互斥防 use-after-free;长截图采样循环在下一检查点(泵循环 tick)消费本标志, +/// 销毁长截图浮层并按失败结果收束整个会话回调 JS {success:false})。 +/// 无长截图会话时置位后即被下次会话创建的 reset 清除,等价 Windows 的空指针分支。 +@_cdecl("abortLongCapture") +public func abortLongCapture() { + requestLongCaptureAbort() +} diff --git a/src/screenshot/macos/ScreenshotMosaicMac.swift b/src/screenshot/macos/ScreenshotMosaicMac.swift new file mode 100644 index 0000000..79d1545 --- /dev/null +++ b/src/screenshot/macos/ScreenshotMosaicMac.swift @@ -0,0 +1,456 @@ +import Foundation +import AppKit +import CoreGraphics + +// MARK: - 马赛克子系统(macOS; +// Windows mosaic_windows.cpp reveal-mask 模型移植) +// +// 核心模型(对齐 Windows): +// - 会话内把整屏底图按当前块大小预像素化为 mosaicBase(1 缩略像素 = blockPx 逻辑像素); +// 仅在「块大小或屏幕尺寸变化」时重建,且首次需要马赛克的绘制才生成(延迟预处理) +// - 马赛克标注只是「蒙版」:框选 = 矩形 path、涂抹 = 沿路径 step=radius/2 的圆并集 path +// (相邻圆重叠 ≥50%,快速移动无缝隙);渲染 = 底图 + mosaicBase 经蒙版裁剪叠加 +// - 马赛克标注不可选中/不可拖动;块大小 {6,10,16}、涂抹半径 {12,22,36} +// - 导出时从裁剪底图现场重算(不依赖会话内缓存),对齐 CompositeAnnotations 序列 +// +// 像素化实现:Windows 用两趟 StretchBlt(HALFTONE 缩小 + COLORONCOLOR 最近邻放大); +// macOS 用手写平均块采样(纯 CPU)产出 1px/块 缩略图,渲染时以最近邻放大铺回 +//(interpolationQuality = .none 等价 COLORONCOLOR),视觉效果对齐 HALFTONE 两趟。 + +// MARK: - 常量(Windows 出处集中标注) + +extension SC { + /// 马赛克块大小预设,逻辑像素(internal.h: SC_MOSAIC_SIZES = { 6, 10, 16 }) + static let mosaicSizes: [Int] = [6, 10, 16] + /// 默认块大小档:中块(internal.h: SC_DEFAULT_MOSAIC_IDX = 1) + static let defaultMosaicIdx = 1 + /// 涂抹半径预设,逻辑像素(internal.h: SC_MOSAIC_RADIUS = { 12, 22, 36 }) + static let mosaicRadii: [Int] = [12, 22, 36] + /// 默认涂抹半径档:中半径(internal.h: SC_DEFAULT_MOSAIC_RADIUS_IDX = 1) + static let defaultMosaicRadiusIdx = 1 + /// 马赛克子菜单模式数(overlay_ui_windows.cpp: SC_MOSAIC_MODE_COUNT = 2,涂抹/框选) + static let mosaicModeCount = 2 + /// 涂抹轨迹圆并集步长系数(BuildMosaicMaskRegion:step = max(1, radius * 0.5), + /// 相邻圆重叠 ≥50% 保证无缝隙) + static let mosaicBrushStepFactor: Double = 0.5 + /// 马赛克子菜单命中码基址(HitTestMosaicPopup:+1/+2 模式;100+i+1 块大小;200+i+1 半径) + static let mosaicHitSizeBase = 100 + static let mosaicHitRadiusBase = 200 + /// 圆环光标位图外边距(CreateMosaicBrushCursor:pad = 3)与最小边长(size < 16 → 16) + static let mosaicCursorPad = 3 + static let mosaicCursorMinSize = 16 + /// base 重建的块大小下限(RebuildMosaicBase:blockPx < 2 → 2) + static let mosaicMinBlockPx = 2 +} + +// MARK: - 手写平均块采样(纯 CPU) + +/// 马赛克 base 缓存:源底图按块平均采样得到的缩略图(1 像素 = blockPx 逻辑像素的纯色块), +/// coverRect 为其覆盖的 CG 全局逻辑区域(渲染时放大铺回该矩形)。 +final class ScreenshotMosaicBase { + let image: CGImage + let blockPx: Int + let coverRect: CGRect + + init(image: CGImage, blockPx: Int, coverRect: CGRect) { + self.image = image + self.blockPx = blockPx + self.coverRect = coverRect + } +} + +/// 手写平均块采样(纯 CPU;对齐 MosaicBlitRect 的 HALFTONE 缩小视觉): +/// 源物理像素 → 每 blockPx 逻辑块求像素平均 → 1px/块 的 RGBA 缩略图。 +/// 渲染端以最近邻放大(.none 插值)铺回,等价 Windows「HALFTONE 缩小 + COLORONCOLOR +/// 最近邻放大」两趟的视觉效果。 +/// 实现说明:分带读取源图(每带 stripRows 物理行)避免整屏像素缓冲常驻; +/// 跨带的块用部分和累积至完整覆盖后写出平均色。缩略网格与 logicalRect 左上角对齐 +///(覆盖层 base 对齐虚拟屏原点、导出对齐选区原点,与 Windows dstX0/srcAbsX0 语义一致)。 +/// - Parameters: +/// - source: 源物理像素图像 +/// - logicalRect: 需要马赛克化的逻辑区域(CG 全局坐标;块网格以其左上角对齐) +/// - scale: 物理/逻辑缩放比(Retina = 2.0) +/// - blockPx: 块大小(逻辑像素,<1 视为 1) +/// - Returns: 缩略块图(尺寸 = ceil(logicalRect 尺寸 / blockPx));失败返回 nil +func scBuildMosaicReducedImage(source: CGImage, logicalRect: CGRect, scale: CGFloat, blockPx: Int) -> CGImage? { + let block = max(1, blockPx) + guard scale > 0, logicalRect.width >= 1, logicalRect.height >= 1 else { return nil } + let logicalW = Int(logicalRect.width.rounded(.up)) + let logicalH = Int(logicalRect.height.rounded(.up)) + let reducedW = (logicalW + block - 1) / block + let reducedH = (logicalH + block - 1) / block + guard reducedW > 0, reducedH > 0 else { return nil } + + let physW = source.width + let physH = source.height + guard physW > 0, physH > 0 else { return nil } + + var reduced = [UInt8](repeating: 0, count: reducedW * reducedH * 4) + // 部分和缓冲:块未完整覆盖前跨带累积(r/g/b/count 四通道交错) + var partial = [Double](repeating: 0, count: reducedW * reducedH * 4) + + let blockPhys = CGFloat(block) * scale + let stripRows = 128 + var stripBuf = [UInt8](repeating: 0, count: physW * min(stripRows, physH) * 4) + let colorSpace = CGColorSpaceCreateDeviceRGB() + let bitmapInfo = CGImageAlphaInfo.premultipliedLast.rawValue | CGBitmapInfo.byteOrder32Big.rawValue + + var stripTop = 0 + while stripTop < physH { + let rows = min(stripRows, physH - stripTop) + guard let crop = source.cropping(to: CGRect(x: 0, y: stripTop, width: physW, height: rows)) else { + return nil + } + let drawn = stripBuf.withUnsafeMutableBytes { ptr -> Bool in + guard let ctx = CGContext(data: ptr.baseAddress, width: physW, height: rows, + bitsPerComponent: 8, bytesPerRow: physW * 4, + space: colorSpace, bitmapInfo: bitmapInfo) else { return false } + ctx.interpolationQuality = .none + ctx.draw(crop, in: CGRect(x: 0, y: 0, width: CGFloat(physW), height: CGFloat(rows))) + return true + } + guard drawn else { return nil } + + stripBuf.withUnsafeMutableBufferPointer { buf in + for by in 0..= Int(y1.rounded(.up)) { + let c = partial[base + 3] + if c > 0 { + reduced[base] = UInt8(max(0, min(255, (partial[base] / c).rounded()))) + reduced[base + 1] = UInt8(max(0, min(255, (partial[base + 1] / c).rounded()))) + reduced[base + 2] = UInt8(max(0, min(255, (partial[base + 2] / c).rounded()))) + reduced[base + 3] = 255 + } + } + } + } + } + stripTop += rows + } + + guard let outCtx = CGContext(data: &reduced, width: reducedW, height: reducedH, + bitsPerComponent: 8, bytesPerRow: reducedW * 4, + space: colorSpace, bitmapInfo: bitmapInfo) else { return nil } + return outCtx.makeImage() +} + +// MARK: - 马赛克专属圆环光标(mosaic_windows.cpp CreateMosaicBrushCursor / InitMosaicBrushCursors 移植) + +/// 涂抹模式圆环光标缓存:半径圆(白描边底 + 深色虚线内圈 + 中心十字准星), +/// 热区居中,圆环直径随当前涂抹半径。OS 跟随鼠标,无重绘延迟。 +enum ScreenshotMosaicCursors { + private static var cache: [Int: NSCursor] = [:] // key = (半径 << 8) | scale(scale 为屏 backing 倍数 1/2/3) + + /// 取指定半径与屏缩放倍数的圆环光标(首次构建后缓存;会话收口可 reset 释放)。 + /// 位图显式按目标屏 backingScaleFactor 渲染(Retina = @2x)再以逻辑尺寸包装: + /// 用 NSImage(size:flipped:drawingHandler:) 惰性渲染生成的是 1x 位图,Retina 屏 + /// 光标视觉尺寸减半(环直径只有涂抹揭示圆的一半,涂抹时表现为「小环 + 大揭示圆」 + /// 两个圆框);scale 必须由调用方按鼠标所在屏传入(NSScreen.main 在覆盖层未激活 + /// 会话中不可靠,返回 nil 会错误兜底到 1x)。 + /// - Parameters: + /// - radius: 涂抹半径(逻辑像素) + /// - scale: 目标屏物理/逻辑倍数(≥1;retina = 2) + /// - Returns: 圆环光标(热区居中) + static func cursor(radius: Int, scale: Int) -> NSCursor { + let key = (radius << 8) | max(1, scale) + if let cached = cache[key] { return cached } + let pad = SC.mosaicCursorPad + var size = (radius + pad) * 2 + if size < SC.mosaicCursorMinSize { size = SC.mosaicCursorMinSize } + let r = CGFloat(radius) + let darkCol = NSColor(srgbRed: 30.0 / 255.0, green: 30.0 / 255.0, blue: 30.0 / 255.0, alpha: 1.0) + let sf = CGFloat(max(1, scale)) + let px = Int(CGFloat(size) * sf) + let rep = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: px, pixelsHigh: px, + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, + isPlanar: false, colorSpaceName: .deviceRGB, + bytesPerRow: 0, bitsPerPixel: 0) + let image: NSImage + if let rep = rep { + rep.size = NSSize(width: size, height: size) // 逻辑尺寸(位图像素 = size×scale) + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep) + // 绘制坐标系 = 逻辑点(size×size),与旧版绘制代码一致 + if let ctx = NSGraphicsContext.current?.cgContext { + let cx = CGFloat(size) / 2 + let cy = CGFloat(size) / 2 + let ring = CGRect(x: cx - r, y: cy - r, width: r * 2, height: r * 2) + // 外圈:白色描边底(保证暗背景可见) + ctx.setStrokeColor(NSColor.white.cgColor) + ctx.setLineWidth(3) + ctx.strokeEllipse(in: ring) + // 内圈:深色虚线描边 + ctx.setStrokeColor(darkCol.cgColor) + ctx.setLineWidth(1.5) + ctx.setLineDash(phase: 0, lengths: [4, 3]) + ctx.strokeEllipse(in: ring) + ctx.setLineDash(phase: 0, lengths: []) + // 中心十字准星(长度 = min(6, radius)) + let cl = CGFloat(min(6, radius)) + ctx.setStrokeColor(darkCol.cgColor) + ctx.setLineWidth(1) + ctx.move(to: CGPoint(x: cx - cl, y: cy)) + ctx.addLine(to: CGPoint(x: cx + cl, y: cy)) + ctx.move(to: CGPoint(x: cx, y: cy - cl)) + ctx.addLine(to: CGPoint(x: cx, y: cy + cl)) + ctx.strokePath() + } + NSGraphicsContext.restoreGraphicsState() + image = NSImage(cgImage: rep.cgImage!, size: NSSize(width: size, height: size)) + } else { + // 位图分配失败兜底:退回旧惰性渲染路径(环径可能失真,仅防御性保留) + image = NSImage(size: NSSize(width: size, height: size), flipped: false) { _ in + guard let ctx = NSGraphicsContext.current?.cgContext else { return false } + let cx = CGFloat(size) / 2 + let cy = CGFloat(size) / 2 + let ring = CGRect(x: cx - r, y: cy - r, width: r * 2, height: r * 2) + ctx.setStrokeColor(NSColor.white.cgColor) + ctx.setLineWidth(3) + ctx.strokeEllipse(in: ring) + ctx.setStrokeColor(darkCol.cgColor) + ctx.setLineWidth(1.5) + ctx.setLineDash(phase: 0, lengths: [4, 3]) + ctx.strokeEllipse(in: ring) + ctx.setLineDash(phase: 0, lengths: []) + let cl = CGFloat(min(6, radius)) + ctx.setStrokeColor(darkCol.cgColor) + ctx.setLineWidth(1) + ctx.move(to: CGPoint(x: cx - cl, y: cy)) + ctx.addLine(to: CGPoint(x: cx + cl, y: cy)) + ctx.move(to: CGPoint(x: cx, y: cy - cl)) + ctx.addLine(to: CGPoint(x: cx, y: cy + cl)) + ctx.strokePath() + return true + } + } + let cursor = NSCursor(image: image, hotSpot: NSPoint(x: CGFloat(size) / 2, y: CGFloat(size) / 2)) + cache[key] = cursor + return cursor + } + + /// 释放缓存(会话收口时调用;光标随会话生命周期重建)。 + static func reset() { + cache.removeAll() + } +} + +// MARK: - 会话扩展(base 重建 / 蒙版路径 / 覆盖层渲染 / 导出合成 / 绘制流程) + +extension ScreenshotOverlaySession { + /// 是否存在需要渲染的马赛克内容(已提交或正在绘制;对齐 HasMosaicToRender)。 + func hasMosaicToRender() -> Bool { + if hasCurDrawing && curDrawing.type == .mosaic { return true } + return annotations.contains { $0.type == .mosaic } + } + + /// 确保马赛克 base 缓存有效(对齐 MosaicBaseNeedsRebuild + RebuildMosaicBase): + /// 仅块大小或覆盖尺寸变化时重建;失败保留旧缓存(可能为 nil,调用方跳过揭示)。 + /// - Returns: 有效 base;生成失败且无旧缓存时返回 nil + @discardableResult + func ensureMosaicBase() -> ScreenshotMosaicBase? { + let blockPx = max(SC.mosaicMinBlockPx, SC.mosaicSizes[mosaicSizeIdx]) + let cover = CGRect(origin: baseFrame.origin, size: baseFrame.logicalSize) + if let cache = mosaicBaseCache, cache.blockPx == blockPx, cache.coverRect == cover { + return cache + } + guard let image = scBuildMosaicReducedImage(source: baseFrame.image, + logicalRect: cover, + scale: baseFrame.scale, + blockPx: blockPx) else { + return mosaicBaseCache + } + let base = ScreenshotMosaicBase(image: image, blockPx: blockPx, coverRect: cover) + mosaicBaseCache = base + return base + } + + /// 构建马赛克标注的蒙版路径(对齐 BuildMosaicMaskRegion): + /// 框选 = 规范化矩形;涂抹 = 沿路径以 step = max(1, radius*0.5) 插值取点的圆并集 + ///(子路径同向,nonzero 填充即并集)。ox/oy 把绝对坐标换算到目标局部坐标。 + /// - Parameters: + /// - ox/oy: 绝对坐标 → 目标局部坐标偏移(覆盖层 = -cgOrigin;导出 = -选区左上角) + /// - includeCurDrawing: 是否并入正在绘制的马赛克(覆盖层 true / 导出 false) + /// - Returns: 蒙版路径;无马赛克内容返回 nil + func mosaicMaskPath(ox: CGFloat, oy: CGFloat, includeCurDrawing: Bool) -> CGPath? { + var targets: [ScreenshotAnnotation] = annotations.filter { $0.type == .mosaic } + if includeCurDrawing && hasCurDrawing && curDrawing.type == .mosaic { + targets.append(curDrawing) + } + guard !targets.isEmpty else { return nil } + + func addCircle(_ path: CGMutablePath, _ cx: Double, _ cy: Double, _ radius: Int) { + let r = CGFloat(radius) + path.addEllipse(in: CGRect(x: CGFloat(cx) + ox - r, y: CGFloat(cy) + oy - r, + width: r * 2, height: r * 2)) + } + + let path = CGMutablePath() + for a in targets { + if a.mosaicRect { + // 框选:规范化矩形(对齐 absL/absT/absR/absB) + path.addRect(CGRect(x: CGFloat(min(a.x1, a.x2)) + ox, y: CGFloat(min(a.y1, a.y2)) + oy, + width: CGFloat(abs(a.x2 - a.x1)), height: CGFloat(abs(a.y2 - a.y1)))) + } else { + // 涂抹:相邻点线段按 step 插值取点,每点一个圆(重叠 ≥50% 无缝隙)。 + // 同笔内逐段并集:每帧从全部路径点重建(与 Windows WM_PAINT 重建 Region 等价)。 + let radius = max(1, a.brushRadius) + let step = max(1.0, Double(radius) * SC.mosaicBrushStepFactor) + guard let first = a.pts.first else { continue } + addCircle(path, Double(first.x), Double(first.y), radius) + for i in 1.. NSCursor { + let idx = (mosaicRadiusIdx >= 0 && mosaicRadiusIdx < SC.mosaicRadii.count) + ? mosaicRadiusIdx : SC.defaultMosaicRadiusIdx + let scale = NSScreen.screens.first { ScreenshotGeometry.cgFrame(of: $0).contains(mouse) }? + .backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 2 + return ScreenshotMosaicCursors.cursor(radius: SC.mosaicRadii[idx], scale: Int(scale)) + } +} diff --git a/src/screenshot/macos/ScreenshotOutputMac.swift b/src/screenshot/macos/ScreenshotOutputMac.swift new file mode 100644 index 0000000..fc7826e --- /dev/null +++ b/src/screenshot/macos/ScreenshotOutputMac.swift @@ -0,0 +1,307 @@ +import Foundation +import AppKit +import CoreGraphics +import UniformTypeIdentifiers + +// MARK: - 输出完善(macOS;Windows 基准 output_windows.cpp) +// +// 本文件承载输出子系统——确认输出与保存共用的统一输出管线(长截图完成/保存直接复用): +// - 统一输出辅助(对齐 EncodeHBitmapPng:单次编码同时产出 base64 与文件字节): +// 物理尺寸位图(Retina 2x)+ 逻辑坐标 scaleBy 绘制 → 合成标注(马赛克现场重算) +// → 圆角蒙版(radius>0)→ 单次 PNG 编码 → ScreenshotPngOutput(pngData + base64) +// - 圆角透明导出(对齐 BuildRoundedArgbFinal):CGContext 圆角 clip 路径 + 透明外围 → +// 保留 alpha 的 PNG。macOS 原生支持 alpha,等价 Windows「预乘 ARGB + 圆角蒙版逐像素 +// alpha → PARGB PNG + CF_DIB/PNG 双格式剪贴板」的最终视觉语义(单格式即达成跨应用透明) +// - 保存对话框(对齐 PromptSaveFilePath):NSSavePanel 默认目录 Pictures、默认名 +// Screenshot_YYYYMMDD_HHMMSS.png、仅允许 PNG、自带覆盖确认;弹出前临时降覆盖层/工具栏 +// 浮层族层级(对齐 Windows 摘除 TOPMOST);取消回编辑态(无回调);保存成功=原子落盘 +// + base64 回调但不写剪贴板,且会话结束(对齐 Windows「无论保存成功与否均关闭截图窗口」) +// +// Windows 出处标注:output_windows.cpp(BuildRoundedArgbFinal / EncodePremulArgbPng / +// EncodeHBitmapPng / PromptSaveFilePath / MakeDefaultScreenshotName)、 +// overlay_input_windows.cpp(TB_Save 分支:回调后会话状态与取消语义)。 +// +// 坐标系约定:与覆盖层会话一致——CG 全局逻辑坐标(左上原点);回调契约的 +// x/y/x2/y2/width/height 均为逻辑尺寸,base64 图像为物理像素(Retina 2x,PNG 携带 +// DPI 元数据按逻辑尺寸显示;Windows 端 dpiScale≈1.0 两者相等)。 + +// MARK: - 统一输出产物 + +/// 单次 PNG 编码的统一产物(对齐 Windows EncodeHBitmapPng 的「单次编码同时满足多个 +/// 输出需求」:base64Out + rawOut + 落盘,避免同一张图被反复编码)。确认输出与保存 +/// 路径共用;长截图「完成并复制 / 保存」直接复用。 +struct ScreenshotPngOutput { + let pngData: Data // PNG 文件字节(剪贴板写入与原子落盘共用) + let base64: String // data:image/png;base64,... data URL(回调契约字段) +} + +// MARK: - 保存对话框辅助(Windows 基准 output_windows.cpp) + +/// 生成默认保存文件名(对齐 MakeDefaultScreenshotName:Screenshot_YYYYMMDD_HHMMSS.png, +/// wsprintfW %04d%02d%02d_%02d%02d%02d + localtime 的本地时间语义,output_windows.cpp L594-603)。 +/// - Returns: 默认文件名字符串 +func scMakeDefaultScreenshotName() -> String { + let formatter = DateFormatter() + // POSIX locale 锁定数字格式,避免用户区域设置注入本地化分隔符(等价 wsprintfW 纯数字输出) + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "'Screenshot_'yyyyMMdd'_'HHmmss'.png'" + return formatter.string(from: Date()) +} + +/// 弹出系统保存对话框(对齐 PromptSaveFilePath,output_windows.cpp L611-664),返回用户 +/// 选择的文件完整路径;用户取消或无有效路径返回 nil。 +/// 行为对齐: +/// - 默认目录 Pictures(Windows FOLDERID_Pictures);目录不存在回落用户主目录 +/// (Windows 回落桌面 FOLDERID_Desktop;macOS 无等价语义,按任务基准回落主目录) +/// - 默认名 Screenshot_YYYYMMDD_HHMMSS.png(MakeDefaultScreenshotName) +/// - 仅允许 PNG(Windows lpstrFilter "PNG 图像 (*.png)" + lpstrDefExt "png":非 PNG 文件 +/// 置灰不可选、用户未输扩展名时自动补 .png) +/// - 覆盖提示:NSSavePanel 对已存在文件自带「替换确认」弹窗,与 Windows OFN_OVERWRITEPROMPT +/// 同为系统对话框自带覆盖确认(非自绘),语义一致 +/// 须在主线程调用(runModal 为模态事件循环,运行在会话泵所在的主线程;模态期间会话泵 +/// 暂停属预期,对齐 Windows GetSaveFileNameW 模态循环)。调用方须先临时降覆盖层/工具栏 +/// 浮层族层级(Windows 弹出前摘除 TOPMOST 的等价处理,见 ScreenshotOverlaySession. +/// saveSelectionToFile 与 duckOverlayLevelsForSaveModal)。 +/// - Returns: 选定文件的完整路径;取消返回 nil +func scPromptSaveFilePath() -> String? { + let panel = NSSavePanel() + // 默认目录:Pictures(FOLDERID_Pictures 对应的 ~/.Pictures);目录不存在回落主目录 + let fileManager = FileManager.default + var defaultDir: URL? + if let pictures = fileManager.urls(for: .picturesDirectory, in: .userDomainMask).first { + var isDir: ObjCBool = false + if fileManager.fileExists(atPath: pictures.path, isDirectory: &isDir), isDir.boolValue { + defaultDir = pictures + } + } + panel.directoryURL = defaultDir ?? fileManager.homeDirectoryForCurrentUser + + // 默认名:Screenshot_YYYYMMDD_HHMMSS.png(对齐 MakeDefaultScreenshotName) + panel.nameFieldStringValue = scMakeDefaultScreenshotName() + panel.canCreateDirectories = true // 允许新建目录(GetSaveFileNameW 亦具备,保持能力一致) + if #available(macOS 11.0, *) { + // 仅允许 PNG(对齐 lpstrFilter "PNG 图像 (*.png)";UTType 框架经 #available + // 守卫自动弱链接,10.15 目标不受影响) + panel.allowedContentTypes = [.png] + } else { + // 10.15 运行时回退(旧 API,12.0 起废弃但功能等价;老系统唯一可达分支) + panel.allowedFileTypes = ["png"] + } + + // runModal:模态事件循环。OK=用户确认保存路径;其余(ESC/取消按钮)=用户取消。 + // 覆盖提示由面板对已存在文件自动弹出(OFN_OVERWRITEPROMPT 语义)。 + guard panel.runModal() == NSApplication.ModalResponse.OK, let url = panel.url else { + return nil + } + return url.path +} + +/// 构造确认输出与保存成功共用的回调 JSON(契约字段与 Windows CallScreenshotJs 一致: +/// success/x/y/x2/y2/width/height/base64;坐标为 CG 全局逻辑坐标,x2/y2 = min + size, +/// 与既有 confirmSelection 输出逐字节一致)。 +/// - Parameters: +/// - sel: 选区(CG 全局逻辑坐标) +/// - base64: data URL 形式的 PNG base64 +/// - Returns: 回调 JSON 字符串 +func scSuccessPayloadJSON(sel: CGRect, base64: String) -> String { + let x = Int(sel.minX.rounded()) + let y = Int(sel.minY.rounded()) + let width = Int(sel.width.rounded()) + let height = Int(sel.height.rounded()) + return "{" + + "\"success\":true" + + ",\"x\":\(x)" + + ",\"y\":\(y)" + + ",\"x2\":\(x + width)" + + ",\"y2\":\(y + height)" + + ",\"width\":\(width)" + + ",\"height\":\(height)" + + ",\"base64\":\"\(base64)\"" + + "}" +} + +/// 将 PNG 图像写入系统剪贴板(对齐 Windows 确认路径 SaveBitmapToClipboard:成功出图必进 +/// 剪贴板)。圆角透明图直接以 PNG 类型写入,NSPasteboard 保留 alpha 通道——跨应用透明 +/// 对齐 Windows 圆角路径 CF_DIB+PNG 双格式中 PNG 的角色(透明度最可靠载体),macOS 单 +/// PNG 格式即达成等价语义,无需双格式。NSPasteboard 官方文档标注线程安全。 +/// (自 ScreenshotMac.swift 迁入:输出辅助统一收口本文件。) +/// - Parameter pngData: PNG 字节 +/// - Returns: 写入成功返回 true +func writePngToPasteboard(_ pngData: Data) -> Bool { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + return pasteboard.setData(pngData, forType: .png) +} + +// MARK: - 会话输出扩展(统一输出管线 + 保存流) + +extension ScreenshotOverlaySession { + // MARK: 统一输出管线(确认输出 / 保存共用;长截图复用) + + /// 按选区从常驻底图裁剪物理像素图(自 confirmSelection 抽出的公共步骤,对齐 + /// ExtractRegionResult / ComposeSelectedBitmap 的区域提取: + /// 物理 = (逻辑 − 虚拟屏原点) × dpiScale,四边钳制在位图内)。 + /// - Parameter sel: 选区(CG 全局逻辑坐标) + /// - Returns: 物理像素 CGImage;选区为空或裁剪失败返回 nil(失败不输出黑图) + func cropSelectionPhysical(_ sel: CGRect) -> CGImage? { + let scale = baseFrame.scale + let imgW = CGFloat(baseFrame.image.width) + let imgH = CGFloat(baseFrame.image.height) + let px0 = max(0, ((sel.minX - baseFrame.origin.x) * scale).rounded(.down)) + let py0 = max(0, ((sel.minY - baseFrame.origin.y) * scale).rounded(.down)) + let px1 = min(imgW, ((sel.maxX - baseFrame.origin.x) * scale).rounded(.down)) + let py1 = min(imgH, ((sel.maxY - baseFrame.origin.y) * scale).rounded(.down)) + guard px1 > px0, py1 > py0 else { return nil } + return baseFrame.image.cropping(to: CGRect(x: px0, y: py0, width: px1 - px0, height: py1 - py0)) + } + + /// 构建最终输出图并单次编码 PNG(输出路径复用核心;对齐 Windows + /// EncodeHBitmapPng + EncodePremulArgbPng + BuildRoundedArgbFinal 的合并语义): + /// 物理尺寸位图(Retina 下 2x)+ 逻辑坐标 scaleBy 绘制 → 合成标注(马赛克现场重算) + /// → 圆角蒙版(radius>0)→ 单次编码同时产出 base64 data URL 与 PNG 文件字节。 + /// + /// 输出分辨率:位图按「逻辑尺寸 × dpiScale」建,底图物理像素 + /// 1:1 落位、标注按矢量在高分辨率下重渲染——Retina 下不再缩回逻辑尺寸(缩回会丢一半 + /// 分辨率导致发虚;Windows 在 dpiScale≈1.0 时本就不缩放,macOS 主流 2x 屏必须保留 + /// 物理像素才能与屏幕所见一致)。PNG 写入 DPI 元数据(pixels/size×72,Retina=144dpi), + /// 看图应用按逻辑尺寸显示,与系统截图(Cmd+Shift+4)行为一致;回调契约的 + /// width/height 仍为逻辑尺寸,跨端语义不变。 + /// + /// 圆角蒙版实现(对齐 BuildRoundedArgbFinal 的逐像素 alpha 预乘): + /// 上下文为 premultipliedLast(预乘 RGBA,等价 PARGB 语义),内容落笔前先经圆角 + /// 路径裁剪,路径外保持透明——CGContext clip 的抗锯齿 coverage 即逐像素 alpha + /// (内部不透明、弧边预乘、外部全透明),PNG 编码器(NSBitmapImageRep)写回非预乘 + /// 字节,视觉正确。半径钳制与圆弧几何对齐 Windows:r = min(radius, min(w,h)/2) + /// (BuildRoundedArgbFinal L407),CGPath(roundedRect:) 与覆盖层选区圆角绘制同款 + /// (AddRoundedRect 均为圆弧构造),导出与所见一致。 + /// + /// - Parameters: + /// - cropped: 按选区裁剪的物理像素底图(cropSelectionPhysical 产物) + /// - sel: 选区(CG 全局逻辑坐标;输出尺寸与标注偏移基准) + /// - cornerRadius: 圆角半径(逻辑点;0 = 直角输出,与直角路径行为一致) + /// - Returns: 统一输出产物;任一步失败返回 nil(失败路径不输出黑图,对齐 Windows + /// 「拷贝/缩放/编码失败不输出黑图」) + func buildFinalPngOutput(cropped: CGImage, sel: CGRect, cornerRadius: CGFloat) -> ScreenshotPngOutput? { + let width = Int(sel.width.rounded()) + let height = Int(sel.height.rounded()) + let scale = baseFrame.scale + // 位图按物理尺寸建(Retina 2x),逻辑坐标经 scaleBy 映射——底图 1:1 落位不重采样, + // 标注/文字/圆角在高分辨率下重渲染(修复缩回逻辑尺寸导致的输出模糊) + let physW = Int((sel.width * scale).rounded()) + let physH = Int((sel.height * scale).rounded()) + guard physW > 0, physH > 0, scale > 0, + let ctx = CGContext( + data: nil, width: physW, height: physH, + bitsPerComponent: 8, bytesPerRow: physW * 4, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue | CGBitmapInfo.byteOrder32Big.rawValue + ) else { return nil } + ctx.scaleBy(x: scale, y: scale) + + // 圆角蒙版(radius>0):内容先经圆角路径裁剪再落笔,路径外保持透明。 + // radius==0 不加 clip,直角输出几何不变;路径按逻辑坐标构建,clip 抗锯齿经 + // scaleBy 在物理分辨率上生成,弧边平滑度随输出分辨率提升。 + let radius = min(cornerRadius, min(CGFloat(width), CGFloat(height)) / 2) + if radius >= 1 { + ctx.addPath(CGPath(roundedRect: CGRect(x: 0, y: 0, width: width, height: height), + cornerWidth: radius, cornerHeight: radius, transform: nil)) + ctx.clip() + } + + // 底图:物理像素 1:1 落位(缩放后上下文中逻辑选区矩形即物理尺寸;混合 DPI 等 + // scale 失配场景仍由插值兜底) + ctx.interpolationQuality = .high + ctx.draw(cropped, in: CGRect(x: 0, y: 0, width: CGFloat(width), height: CGFloat(height))) + + // 标注合成(原 compositeAnnotationsToPng 并入统一管线;无标注时跳过,保持 + // 直角空标注路径行为不变):raw CGContext 原点在左下,翻转为左上原点后复用 + // 覆盖层同款绘制函数(final 位图原点 = 选区左上角,ox/oy 对齐 CompositeAnnotations)。 + // 马赛克先从裁剪底图现场重算,矢量/文字标注清晰覆盖其上(对齐 CompositeAnnotations + // 的马赛克先行揭示 + 标注覆盖次序)。 + if !annotations.isEmpty { + ctx.saveGState() + ctx.translateBy(x: 0, y: CGFloat(height)) + ctx.scaleBy(x: 1, y: -1) + compositeMosaicLayer(ctx, cropped: cropped, region: sel) + for a in annotations { + scDrawAnnotation(ctx, a, ox: -sel.minX, oy: -sel.minY) + } + ctx.restoreGState() + } + + // 单次 PNG 编码同时产出 base64 与文件字节(EncodeHBitmapPng 等价)。 + // rep.size 声明逻辑尺寸 → PNG 携带 DPI 元数据(Retina=144dpi),看图应用按 + // 逻辑尺寸显示(等价系统截图行为);像素数据不变,仅元数据。 + guard let final = ctx.makeImage() else { return nil } + let rep = NSBitmapImageRep(cgImage: final) + rep.size = NSSize(width: CGFloat(width), height: CGFloat(height)) + guard let pngData = rep.representation(using: .png, properties: [:]), !pngData.isEmpty else { + return nil + } + return ScreenshotPngOutput( + pngData: pngData, + base64: "data:image/png;base64," + pngData.base64EncodedString()) + } + + // MARK: 保存流(工具栏「保存」;对齐 overlay_input_windows.cpp TB_Save 分支) + + /// 保存选区为 PNG 文件(保存对话框主流程;对齐 Windows TB_Save 分支 L337-359): + /// 1) 弹出前临时下调覆盖层/工具栏浮层族窗口层级 + event tap 放行模态期间按键 + /// (对齐 Windows 弹出前摘除 TOPMOST:PromptSaveFilePath 注释「覆盖层是 WS_EX_TOPMOST + /// 全屏窗口,通用对话框可能被遮挡」,L608-609/L632-636) + /// 2) NSSavePanel 模态(runModal 在主线程=会话泵线程执行;模态期间会话泵暂停属预期, + /// 对齐 GetSaveFileNameW 模态循环;面板自成模态,键盘/鼠标不会误触覆盖层) + /// 3) 取消对话框:层级恢复(defer)、回到编辑态继续会话、无回调(对齐 Windows + /// L358「用户取消保存对话框:不关闭,留在编辑态」) + /// 4) 选择路径:统一输出 → 原子落盘 → 回调(成功 success:true 含坐标与 base64 / + /// 失败 success:false)→ 会话结束(对齐 Windows L343「无论保存成功与否,均关闭 + /// 截图窗口」——state = CS_Done 后 DestroyWindow)。 + /// 不写剪贴板(对齐 Windows 保存路径语义:SaveRegionToPngFile 落盘 + 回调即收口, + /// 不经 SaveBitmapToClipboard/ExtractRegionResult 的剪贴板写入)。 + func saveSelectionToFile() { + guard isRunning, state == .confirmed else { return } + let sel = selection.standardized + guard sel.width >= 1, sel.height >= 1 else { return } // 防御:确认态选区恒有效 + + // 弹出前临时降层级(Windows 摘除 TOPMOST 等价)+ event tap 放行(模态期间 + // ESC/右键交给保存面板自消费,不触发会话取消) + duckOverlayLevelsForSaveModal(true) + saveModalFlag.set() + defer { + // 对话框关闭后恢复层级(对齐 PromptSaveFilePath 关闭后恢复 TOPMOST); + // 会话已结束(保存成功/失败路径)时窗口已销毁,恢复为 no-op 安全 + saveModalFlag.reset() + duckOverlayLevelsForSaveModal(false) + } + + // 弹出保存对话框;取消 → 回编辑态(无回调、会话继续,编辑态完好) + guard let path = scPromptSaveFilePath() else { return } + + // 统一输出(物理裁剪 → 逻辑尺寸 → 标注 → 圆角蒙版 → 单次编码;与确认输出同管线) + guard let cropped = cropSelectionPhysical(sel), + let output = buildFinalPngOutput(cropped: cropped, sel: sel, + cornerRadius: selectionCornerRadius) else { + // 用户已选择保存路径:编码失败同样结束会话并回调失败(对齐 Windows + // L352-354「无论保存成功与否均关闭截图窗口」+ EmitScreenshotResult(false)) + state = .done + finish(failurePayload("failed to encode screenshot")) + return + } + + // 原子落盘(对齐 EncodeHBitmapPng 的「同目录临时文件 + MOVEFILE_REPLACE_EXISTING + // 原子替换」语义:macOS 用 writeOptions .atomic——先写临时文件全部成功后再原子 + // 替换目标,磁盘满/权限中断不会留下截断 PNG 或覆盖旧图) + do { + try output.pngData.write(to: URL(fileURLWithPath: path), options: .atomic) + } catch { + state = .done + finish(failurePayload("failed to save screenshot")) + return + } + + // 保存成功:回调(success:true 含坐标与 base64,走既有 TSFN 出口)后结束会话; + // 不写剪贴板(对齐 Windows 保存路径语义) + state = .done + finish(scSuccessPayloadJSON(sel: sel, base64: output.base64)) + } +} diff --git a/src/screenshot/macos/ScreenshotOverlayMac.swift b/src/screenshot/macos/ScreenshotOverlayMac.swift new file mode 100644 index 0000000..feb3c48 --- /dev/null +++ b/src/screenshot/macos/ScreenshotOverlayMac.swift @@ -0,0 +1,1789 @@ +import Foundation +import AppKit +import ApplicationServices + +// MARK: - 覆盖层与选区(macOS) +// +// 本文件承载会话主体(绘制扩展见 ScreenshotPaintMac.swift): +// - 多屏覆盖层:每个 NSScreen 一个无边框透明 NSWindow,共享同一会话状态单例 +// - 手动泵主循环:NSApp.nextEvent/sendEvent 驱动 AppKit 事件直至会话收束(取色器模式) +// - 选区状态机:Idle → Selecting → Confirmed → (Resizing | Moving) → Done/Cancelled +// (对齐 Windows internal.h CaptureState) +// - 鼠标/键盘交互由覆盖层 NSView 处理;ESC 与右键取消另有 CGEventTap 兜底(失焦仍可取消) +// - 窗口吸附:CGWindowListCopyWindowInfo 枚举 + Z 序命中(对齐 Windows EnumWindowsForCapture) +// - 确认输出:底图按选区物理像素裁剪 → 物理尺寸合成(Retina 2x)→ PNG → NSPasteboard → 契约回调 +// +// 坐标系约定:会话内全部状态使用 CG 全局坐标(左上原点、逻辑点、整数点), +// 与 Windows 回调的"虚拟屏绝对坐标"语义对齐;覆盖层视图 isFlipped=true,本地坐标与 CG 同向。 + +// MARK: - 状态机(对齐 Windows internal.h) + +/// 截图状态机(对齐 Windows internal.h 的 CaptureState)。 +/// 使用 Idle/Selecting/Confirmed/Resizing/Moving/Done/Cancelled、 +/// Drawing(CS_Drawing:正在绘制矢量/马赛克标注)、 +/// TextEditing(CS_TextEditing:正在输入文字,会话子状态——见 ScreenshotTextMac.swift)、 +/// longCapturing(CS_LongCapturing:长截图滚动捕获中,覆盖层隐藏、由 +/// ScreenshotLongCaptureSession 接管;取消长截图 → 回 confirmed,完成 → 整会话收束)。 +enum ScreenshotCaptureState { + case idle // CS_Idle:等待选择(hover 窗口高亮 / 拖拽开始) + case selecting // CS_Selecting:正在拖拽框选 + case confirmed // CS_Confirmed:已确认选区,可调整/拖动/微调/标注编辑(编辑工具栏) + case resizing // CS_Resizing:正在拖拽手柄调整选区(标准 8 手柄或圆角手柄) + case moving // CS_Moving:正在整体拖动选区 + case drawing // CS_Drawing:正在绘制标注(矩形/圆/箭头/画笔/马赛克) + case textEditing // CS_TextEditing:正在输入文字(ESC=清缓冲回确认态,Enter=提交) + case longCapturing // CS_LongCapturing:长截图滚动捕获中(覆盖层隐藏、蒙版+小地图+工具栏接管) + case done // CS_Done:已确认输出 + case cancelled // CS_Cancelled:已取消(ESC / 右键) +} + +/// 选区调整手柄(对齐 Windows internal.h 的 ResizeHandle 枚举 raw 值)。 +/// 8/9 为箭头端点手柄(仅箭头标注使用)。 +enum ScreenshotResizeHandle: Int { + case none = -1 + case left = 0 + case right = 1 + case top = 2 + case bottom = 3 + case topLeft = 4 + case topRight = 5 + case bottomLeft = 6 + case bottomRight = 7 + case arrowStart = 8 // 箭头起点端点手柄(仅箭头用,拖动改起点) + case arrowEnd = 9 // 箭头终点端点手柄(仅箭头用,拖动改终点) + case cornerTL = 10 // 选区左上角内倒角手柄(拖动改圆角半径,不改变选区矩形) + case cornerTR = 11 // 右上角内倒角手柄 + case cornerBL = 12 // 左下角内倒角手柄 + case cornerBR = 13 // 右下角内倒角手柄 + + /// 是否为选区圆角内倒角手柄(对齐 Windows IsCornerRadiusHandle)。 + var isCorner: Bool { + return self == .cornerTL || self == .cornerTR || self == .cornerBL || self == .cornerBR + } + + /// 四个圆角手柄的固定遍历顺序(命中/靠近探测用,对齐 Windows 数组顺序 TL/TR/BL/BR)。 + static let cornerCases: [ScreenshotResizeHandle] = [.cornerTL, .cornerTR, .cornerBL, .cornerBR] +} + +/// 采样像素颜色(RGB 各 8bit;放大镜 HEX/RGB 文本与取色/回显共用;Equatable 供回显查找)。 +struct ScreenshotRGB: Equatable { + let r: UInt8 + let g: UInt8 + let b: UInt8 +} + +/// 线程安全的取消标志:CGEventTap 回调在 tap 自有后台线程置位,泵循环(主线程)逐拍消费。 +final class ScreenshotAtomicFlag { + private let lock = NSLock() + private var value = false + + /// 当前是否已置位。 + var isSet: Bool { + lock.lock() + defer { lock.unlock() } + return value + } + + /// 置位(幂等)。 + func set() { + lock.lock() + value = true + lock.unlock() + } + + /// 复位(幂等)。 + func reset() { + lock.lock() + value = false + lock.unlock() + } +} + +// MARK: - 窗口吸附枚举(对齐 Windows capture_windows.cpp EnumWindowsForCapture / FindWindowAtPoint) + +/// 吸附候选窗口快照(对齐 Windows internal.h 的 SCWindowInfo):rect 为 CG 全局逻辑坐标 +/// (kCGWindowBounds 即 top-left 原点的全局点坐标,无需换算)。 +struct ScreenshotSnapWindow { + let rect: CGRect + let title: String + let windowNumber: Int +} + +/// 枚举吸附候选窗口(对齐 Windows EnumWindowsForCapture 过滤语义,窗口吸附的数据源): +/// - `.optionOnScreenOnly`:等价 IsWindowVisible(不可见窗口天然排除) +/// - `.excludeDesktopElements` + layer==0:排除桌面/壁纸与悬浮球等特殊层级(等价排除桌面窗口) +/// - 常规 activationPolicy:排除工具窗/输入法等辅助进程(等价 WS_EX_TOOLWINDOW 过滤) +/// - 排除自身进程窗口(覆盖层不入候选;对齐 Windows 排除自身窗口) +/// - 排除空标题(对齐 titleLen==0 的空壳窗口过滤) +/// - 排除过小窗口(对齐 w<50 || h<50) +/// 返回顺序保持 CG Z 序(front-to-back);Windows 的 cloaked 幽灵窗在 macOS 无对应概念, +/// 由 layer==0 + on-screen + 常规策略过滤近似覆盖。 +/// - Returns: 候选窗口数组(front-to-back) +func enumerateSnapWindows() -> [ScreenshotSnapWindow] { + let options: CGWindowListOption = [.optionOnScreenOnly, .excludeDesktopElements] + guard let list = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] else { + return [] + } + var result: [ScreenshotSnapWindow] = [] + for info in list { + guard let layer = info[kCGWindowLayer as String] as? Int, layer == 0 else { continue } + guard let pid = info[kCGWindowOwnerPID as String] as? Int, pid > 0, pid != getpid() else { continue } + guard NSRunningApplication(processIdentifier: pid_t(pid))?.activationPolicy == .regular else { continue } + guard let title = info[kCGWindowName as String] as? String, !title.isEmpty else { continue } + guard let boundsValue = info[kCGWindowBounds as String] as? [String: Any], + let bounds = CGRect(dictionaryRepresentation: boundsValue as CFDictionary) else { continue } + // 过小窗口过滤(Windows w<50||h<50;零/负尺寸防御一并排除) + if bounds.width < 50 || bounds.height < 50 { continue } + // 取整到整数点,保证吸附矩形与选区坐标系一致(整数点运算) + let rect = CGRect(x: bounds.minX.rounded(), y: bounds.minY.rounded(), + width: bounds.width.rounded(), height: bounds.height.rounded()) + let windowNumber = info[kCGWindowNumber as String] as? Int ?? 0 + result.append(ScreenshotSnapWindow(rect: rect, title: title, windowNumber: windowNumber)) + } + return result +} + +/// 查找鼠标下方的候选窗口(Windows FindWindowAtPoint 移植):按枚举 Z 序返回首个命中项 +/// 索引(即最前面的窗口);无命中返回 -1。 +/// - Parameters: +/// - windows: 候选窗口数组(enumerateSnapWindows 产物) +/// - point: 鼠标 CG 全局坐标 +/// - Returns: 命中索引;无命中 -1 +func findWindowAtPoint(_ windows: [ScreenshotSnapWindow], _ point: CGPoint) -> Int { + for (i, w) in windows.enumerated() { + if scPointInRect(point, w.rect) { return i } + } + return -1 +} + +// MARK: - 覆盖层窗口与视图 + +/// 覆盖层窗口:无边框透明窗默认不可成为 key window,覆写为可 key 以接收键盘 +/// (方向键微调 / Enter 确认 / 无 event tap 时的 ESC 取消)。 +final class OverlayScreenshotWindow: NSWindow { + override var canBecomeKey: Bool { return true } +} + +/// 覆盖层自绘视图:底图 + 蒙版 + 选区交互浮层的绘制载体,并把鼠标/键盘事件换算为 +/// CG 全局坐标后转发给会话(多屏共享同一会话状态)。 +final class OverlayScreenshotView: NSView { + let session: ScreenshotOverlaySession + /// 本窗口左上角在 CG 全局坐标系的位置(逻辑点);本地坐标 = CG 全局坐标 - cgOrigin。 + let cgOrigin: CGPoint + + /// 本视图覆盖的 CG 全局矩形(宽高取视图 bounds,与所在屏幕一致)。 + var cgFrame: CGRect { + return CGRect(origin: cgOrigin, size: bounds.size) + } + + init(session: ScreenshotOverlaySession, cgOrigin: CGPoint, frame: NSRect) { + self.session = session + self.cgOrigin = cgOrigin + super.init(frame: frame) + } + + required init?(coder: NSCoder) { + fatalError("OverlayScreenshotView is created programmatically only") + } + + // 视图翻转:本地坐标与 CG 全局坐标同向(top-left 原点、Y 向下),换算只需平移 + override var isFlipped: Bool { return true } + + // 接收键盘事件(方向键微调 / Enter 确认 / 无 event tap 时的 ESC 取消): + // NSView 默认不接受 first responder,必须覆写,否则窗口成为 key 后键盘事件无人响应 + override var acceptsFirstResponder: Bool { return true } + + // 首击穿透(acceptsFirstMouse):会话由热键在其他应用前台时触发,macOS 14+ 协作式 + // 激活可能失败——覆盖层窗口照常显示(orderFrontRegardless 不依赖激活)但 App 未激活。 + // 该状态下 AppKit 把非 key 窗口上的首次 mouseDown 当"激活点击"吞掉(只用于激活应用, + // 不投递给视图),本次拖拽全程的 mouseDragged/mouseUp 因会话仍处 Idle 全部无效 → + // 表现为"进入截图后无法拖拽选区"(偶发:会话启动时激活成功则一切正常)。 + // 覆写后首击穿透直达本视图,实测 macOS 15 未激活状态下 mouseDown/mouseUp 正常送达, + // 拖拽选区恢复;键盘(方向键/Enter)仍依赖激活成功,失败时由 CGEventTap 的 + // ESC/右键兜底,与修复前行为一致。 + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { return true } + + /// 覆盖层绘制(脏区局部重绘;底图按脏区裁剪,禁止整图重采样) + override func draw(_ dirtyRect: NSRect) { + guard let cgContext = NSGraphicsContext.current?.cgContext else { return } + session.paint(context: cgContext, view: self, dirtyLocal: dirtyRect) + } + + // ---- 鼠标/键盘事件 → 会话 ---- + + /// 事件位置 → CG 全局逻辑坐标(整数点,对齐 Windows 的 int 鼠标坐标)。 + private func cgPoint(from event: NSEvent) -> CGPoint { + let local = convert(event.locationInWindow, from: nil) + return CGPoint(x: (local.x + cgOrigin.x).rounded(), y: (local.y + cgOrigin.y).rounded()) + } + + /// 左键按下:Idle 开始框选 / Confirmed 双击确认·手柄调整·整体拖动 + override func mouseDown(with event: NSEvent) { + session.handleMouseDown(cgPoint(from: event), clickCount: event.clickCount) + } + + /// 左键拖动:Selecting 更新终点 / Resizing 调整选区或圆角 / Moving 整体平移 + override func mouseDragged(with event: NSEvent) { + session.handleMouseDragged(cgPoint(from: event)) + } + + /// 左键抬起:Selecting 收束(吸附退化链/规范化/autoConfirm)/ Resizing 补足最小尺寸 / Moving 收束 + override func mouseUp(with event: NSEvent) { + session.handleMouseUp(cgPoint(from: event)) + } + + /// 右键:取消会话(对齐 Windows WM_RBUTTONDOWN;event tap 活跃时事件已被 tap 吞掉, + /// 本 handler 是无辅助功能权限时(tap 创建失败)的兜底取消路径) + override func rightMouseDown(with event: NSEvent) { + session.cancelSession() + } + + /// 鼠标移动(无按键):Idle hover 高亮/取色 / Confirmed 圆角手柄靠近探测 + override func mouseMoved(with event: NSEvent) { + session.handleMouseMoved(cgPoint(from: event)) + } + + /// 光标更新:按状态与命中切换系统光标(十字/resize/手型/箭头) + override func cursorUpdate(with event: NSEvent) { + session.updateCursor() + } + + /// 键盘:方向键微调 / Enter 确认 / ESC 取消 / 文字编辑键系(event tap 兜底缺位时的直接 + /// 路径)。文字编辑态优先经 inputContext 路由 NSTextInputClient(IME 组词/上屏)。 + override func keyDown(with event: NSEvent) { + session.handleKeyDown(event, textInputContext: inputContext) + } +} + +// MARK: - ESC/右键 CGEventTap 兜底(取色器模式) + +/// 当前活跃的覆盖层 event tap(CGEventTap 回调为 C 函数指针不能捕获上下文, +/// 以模块级弱持有桥接;会话收口时置 nil)。 +private var overlayTapCurrent: ScreenshotOverlayEventTap? + +/// CGEventTap 回调(C 函数指针兼容;无捕获):转发给当前活跃 tap 实例处理。 +private func screenshotOverlayEventTapCallback( + _ proxy: CGEventTapProxy, _ type: CGEventType, _ event: CGEvent, _ userInfo: UnsafeMutableRawPointer? +) -> Unmanaged? { + guard let current = overlayTapCurrent else { + return Unmanaged.passUnretained(event) + } + return current.handleEvent(type: type, event: event) +} + +/// 覆盖层 CGEventTap 兜底(对齐 Windows 失焦后 GetAsyncKeyState 轮询兜底,矩阵 #49): +/// 会话期间拦截 ESC keyDown 与右键按下并置取消标志(泵循环 ≤16ms 内消费收束), +/// 解决覆盖层失焦时 ESC/右键仍可取消。文字编辑态例外:ESC 放行给覆盖层视图 +///(NSTextInputClient 键系路径清缓冲回确认态——不是取消截图)。 +/// 长截图态例外:ESC 置 longCancelFlag 取消长截图(整会话 {success:false} 收束, +/// 对齐 Windows RunLongCapture 主循环的 GetAsyncKeyState(VK_ESCAPE) 轮询分支);右键放行 +///(Windows CS_LongCapturing 期间 OnRButtonDown 直接忽略,矩阵语义一致)。 +/// 回调运行在 tap 自有后台线程,仅读原子标志与吞事件,绝不触碰 NSWindow/NSView。 +/// 启动前置检查见会话 start()(无辅助功能权限时打印明确错误)。 +final class ScreenshotOverlayEventTap { + /// 共享取消标志(与会话交换的唯一通道)。 + let cancelFlag: ScreenshotAtomicFlag + /// 文字编辑态标志(编辑态 ESC 放行不取消;由泵循环/状态迁移在主线程同步)。 + let textEditingFlag: ScreenshotAtomicFlag + /// 保存对话框模态标志(模态期间 ESC/右键放行给 NSSavePanel 自消费,不触发会话取消; + /// 主线程保存流在 runModal 前后同步置位/复位——对齐 Windows GetSaveFileNameW 模态 + /// 循环期间消息不达覆盖层 WndProc 的语义)。 + let saveModalFlag: ScreenshotAtomicFlag + /// 长截图态标志(置位期间 ESC → longCancelFlag 而非取消整个会话;主线程同步)。 + let longCaptureFlag: ScreenshotAtomicFlag + /// 长截图取消标志(长截图态下 ESC 置位;泵循环消费 → 取消长截图,整会话失败收束)。 + let longCancelFlag: ScreenshotAtomicFlag + private var tap: CFMachPort? + private var runLoopSource: CFRunLoopSource? + private var runLoop: CFRunLoop? + private var stopped = false + + init(cancelFlag: ScreenshotAtomicFlag, textEditingFlag: ScreenshotAtomicFlag, + saveModalFlag: ScreenshotAtomicFlag, + longCaptureFlag: ScreenshotAtomicFlag = ScreenshotAtomicFlag(), + longCancelFlag: ScreenshotAtomicFlag = ScreenshotAtomicFlag()) { + self.cancelFlag = cancelFlag + self.textEditingFlag = textEditingFlag + self.saveModalFlag = saveModalFlag + self.longCaptureFlag = longCaptureFlag + self.longCancelFlag = longCancelFlag + } + + /// 在后台线程创建 event tap 并运行其 RunLoop(照抄取色器启动模式: + /// .cgSessionEventTap + headInsert + .defaultTap 拦截模式 + 被系统禁用时自动重启)。 + func start() { + DispatchQueue.global(qos: .userInteractive).async { [weak self] in + guard let self = self else { return } + let eventMask: CGEventMask = (1 << CGEventType.keyDown.rawValue) + | (1 << CGEventType.rightMouseDown.rawValue) + guard let tap = CGEvent.tapCreate( + tap: .cgSessionEventTap, + place: .headInsertEventTap, + options: .defaultTap, + eventsOfInterest: eventMask, + callback: screenshotOverlayEventTapCallback, + userInfo: nil + ) else { + print("Error: Failed to create screenshot overlay event tap. Check accessibility permissions.") + return + } + guard let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) else { + print("Error: Failed to create run loop source for screenshot overlay event tap") + CFMachPortInvalidate(tap) + return + } + self.tap = tap + self.runLoopSource = source + self.runLoop = CFRunLoopGetCurrent() + if self.stopped { + // 会话在 tap 就绪前已收口:就地清理,避免泄漏(stop 已跑过空清理) + self.stop() + return + } + // source 必须挂进当前 RunLoop,tap 的 mach port 才会被调度读取——缺失时 + // tap 创建并 enable 成功也永远收不到回调(失焦 ESC/右键兜底与长截图态 + // ESC 取消静默失效;对齐 ZToolsNative.swift 取色器/鼠标监听的标准启动序) + CFRunLoopAddSource(CFRunLoopGetCurrent(), source, .commonModes) + overlayTapCurrent = self + CGEvent.tapEnable(tap: tap, enable: true) + CFRunLoopRun() + } + } + + /// 停止并释放 event tap(主线程会话收口时调用;CFRunLoopStop 线程安全)。 + func stop() { + stopped = true + overlayTapCurrent = nil + if let tap = tap { + CGEvent.tapEnable(tap: tap, enable: false) + } + if let source = runLoopSource, let runLoop = runLoop { + CFRunLoopRemoveSource(runLoop, source, .commonModes) + } + if let tap = tap { + CFMachPortInvalidate(tap) + } + if let runLoop = runLoop { + CFRunLoopStop(runLoop) + } + tap = nil + runLoopSource = nil + runLoop = nil + } + + /// tap 回调主体:ESC keyDown(虚拟键码 53)与右键按下 → 置取消标志并吞掉事件; + /// 保存对话框模态期间按键全部放行(NSSavePanel 自消费 ESC=取消对话框); + /// tap 被系统超时禁用(0xFFFFFFFE/0xFFFFFFFF)时重新启用(取色器同款处理)。 + /// - Parameters: + /// - type: 事件类型 + /// - event: 原始事件 + /// - Returns: 放行事件;拦截时返回 nil + func handleEvent(type: CGEventType, event: CGEvent) -> Unmanaged? { + if type.rawValue == 0xFFFFFFFE || type.rawValue == 0xFFFFFFFF { + if let tap = tap { + CGEvent.tapEnable(tap: tap, enable: true) + } + return Unmanaged.passUnretained(event) + } + // 保存对话框模态期间:ESC/右键放行(面板自带 ESC=取消对话框语义;Windows + // GetSaveFileNameW 模态循环期间消息不达覆盖层 WndProc 的等价处理) + if saveModalFlag.isSet { + return Unmanaged.passUnretained(event) + } + switch type { + case .keyDown: + if event.getIntegerValueField(.keyboardEventKeycode) == 53 { // ESC + if longCaptureFlag.isSet { + // 长截图态:ESC = 取消长截图(整会话 {success:false} 收束,对齐 + // Windows RunLongCapture 主循环 GetAsyncKeyState(VK_ESCAPE) 检查点 + // → abortFlag → LongCaptureEmitFailure;吞掉事件避免同时触达选区下 + // 的目标应用——macOS 蒙版整窗点击穿透、无前台窗口承接) + longCancelFlag.set() + return nil + } + if textEditingFlag.isSet { + // 文字编辑态:放行(覆盖层视图 keyDown → 清缓冲回确认态,非取消截图) + return Unmanaged.passUnretained(event) + } + cancelFlag.set() + return nil + } + return Unmanaged.passUnretained(event) + case .rightMouseDown: + // 长截图态:右键忽略(Windows overlay_input_windows.cpp CS_LongCapturing + // 分支 return 0;放行交还系统,保持底层应用右键菜单等常规行为) + if longCaptureFlag.isSet { + return Unmanaged.passUnretained(event) + } + // 右键取消(对齐 Windows WM_RBUTTONDOWN) + cancelFlag.set() + return nil + default: + return Unmanaged.passUnretained(event) + } + } +} + +// MARK: - 覆盖层会话(状态机 + 输入 + 生命周期 + 输出) + +/// 覆盖层选区会话主体。每个 NSScreen 一个覆盖层窗口共享本实例;选区用 CG 全局 +/// 逻辑坐标跨屏统一表达,事件按窗口换算,绘制把 CG 坐标平移为窗口本地坐标。 +/// 生命周期:runOverlayCaptureSession 创建 → start()(窗口 + event tap)→ runEventPump() +/// 手动泵直至 confirmSelection/cancelSession 收束 → finish() 完整清理并回调恰好一次。 +final class ScreenshotOverlaySession { + // ---- 会话配置与基础设施(以下成员供 ScreenshotPaintMac.swift 的绘制扩展跨文件访问)---- + let options: ScreenshotSessionOptions + let baseFrame: CapturedFrame // 常驻底图(物理像素 CGImage,禁止整图重采样) + let virtualBounds: CGRect // 虚拟屏并集(CG 全局逻辑坐标) + var state: ScreenshotCaptureState = .idle // internal setter:标注绘制/交互由同模块扩展写入 + + // ---- 覆盖层窗口(每 NSScreen 一个)---- + private var windows: [NSWindow] = [] + private var views: [OverlayScreenshotView] = [] + private var eventTap: ScreenshotOverlayEventTap? + + // ---- 选区状态(CG 全局逻辑坐标,整数点对齐 Windows int 坐标语义)---- + private let callback: ScreenshotResultCallback + private var hasFinished = false + let cancelFlag = ScreenshotAtomicFlag() // event tap 兜底取消标志 + + var mouse: CGPoint = .zero // 最近鼠标位置 + var currentColor = ScreenshotRGB(r: 0, g: 0, b: 0) // 放大镜采样色(Idle/Selecting 取鼠标处;Resizing 取手柄锚点) + private var snapWindows: [ScreenshotSnapWindow] = [] // 吸附候选窗口(会话开始时枚举一次) + private var hoveredWindowIndex = -1 // Idle hover 命中的候选窗口(-1 = 无) + + // 拖拽创建选区 + private var dragStartPoint: CGPoint = .zero + private var dragCurrentPoint: CGPoint = .zero + + // 确认态(对齐 CaptureContext.selection / resizeHandle / selectionCornerRadius / kbDX/kbDY) + var selection = CGRect.null + var selectionCornerRadius: CGFloat = 0 // 选区圆角半径(0 = 直角;上限 min(w,h)/2) + var resizeHandle: ScreenshotResizeHandle = .none // CS_Resizing 活动手柄 + var hoveredCornerHandle: ScreenshotResizeHandle = .none // 确认态"靠近"的圆角手柄 + private var dragStartSelection = CGRect.null // 按下时选区快照(resize/move 基准) + private var handleDragStartPoint: CGPoint = .zero // 按下时鼠标位置 + private var cornerDragStartRadius: CGFloat = 0 // 圆角手柄拖拽起始半径 + private var kbDX: CGFloat = 0 // 方向键微调累计 X(Resizing 时叠加鼠标位移) + private var kbDY: CGFloat = 0 // 方向键微调累计 Y + + // ---- 矢量标注(对齐 CaptureContext 的标注字段,坐标为绝对 CG 全局坐标)---- + var annotations: [ScreenshotAnnotation] = [] // 已提交标注 + var undoStack: [[ScreenshotAnnotation]] = [] // 撤销快照栈(队首=最老,深度 SC.undoMaxDepth) + var redoStack: [[ScreenshotAnnotation]] = [] // 重做快照栈 + var curDrawing = ScreenshotAnnotation.empty // .drawing 中正在绘制的标注 + var hasCurDrawing = false // curDrawing 是否有效 + var selectedAnnotation = -1 // 选中的标注索引(-1=无,持久保持) + var draggingAnnotation = -1 // 正在拖动的标注索引(-1=无) + var resizingAnnotation = -1 // 正在缩放的标注索引(-1=无) + var annotationResizeHandle: ScreenshotResizeHandle = .none // 标注缩放活动手柄 + var annotationDragStartPoint: CGPoint = .zero // 按下时鼠标位置(拖拽/缩放共用) + var dragStartAnnotation = ScreenshotAnnotation.empty // 按下时标注快照(还原+平移基准) + var annotationResizeStartBox = CGRect.zero // 按下时包围盒(缩放基准) + var annotationOpHistoryPushed = false // 本次拖拽是否已入历史(首次位移才入栈) + /// 上帧标注操作(拖拽/缩放/绘制)的包围盒(.null = 无缓存)。标注拖拽/绘制热路径 + /// 按「上帧盒 ∪ 本帧盒」局部失效(对齐 Windows InvalidateAnnotationOp 的 + /// lastAnnotationBox ∪ curBox 语义,annotations_windows.cpp L842-853),逐帧链式 + /// 覆盖上一位置防残影;进入/退出拖拽态时复位(性能审计:整窗失效局部化)。 + var lastAnnotationOpBox = CGRect.null + var activeTool: ScreenshotToolButton? = nil // 当前激活工具(对齐 activeTool;确认态默认 drag) + var drawColorIdx = SC.defaultColorIdx // 当前选中颜色索引(子菜单) + var drawThickIdx = SC.defaultThickIdx // 当前选中粗细索引(子菜单) + + // ---- 文字输入(对齐 CaptureContext 的 CS_TextEditing 字段组,坐标为 CG 全局)---- + var textBuf = "" // 正在输入的文字缓冲(UTF-16 单元语义) + var textAnchorX: CGFloat = 0 // 文字锚点(绝对 CG 全局坐标) + var textAnchorY: CGFloat = 0 + var textCaretPos = 0 // 插入符位置(UTF-16 单元偏移) + var textCaretVisible = true // 光标是否可见(500ms 闪烁控制) + var textCaretLastBlink: TimeInterval = 0 // 上次闪烁切换时刻(单调时钟秒) + var textSelStart = -1 // 文字选择起始(-1 = 无选择) + var textSelEnd = -1 // 文字选择结束 + var textDraggingSelection = false // 是否正在拖动选择文字 + var textMarkedRange = NSRange(location: NSNotFound, length: 0) // IME 组词区间 + var hoveredAnnotation = -1 // 悬停非文字标注索引(-1 = 无;字段对齐 Windows, + // 视觉高亮已并入实时命中,暂仅记录) + var hoveredTextAnnotation = -1 // 悬停文字标注索引(-1 = 无) + var selectedTextAnnotation = -1 // 已选中文字标注索引(-1 = 无,持久保持) + var draggingTextAnnotation = -1 // 正在拖动的文字标注索引(-1 = 无) + var textDragStartPoint: CGPoint = .zero // 文字拖动按下点(CG 全局) + var textDragStartAnchor: CGPoint = .zero // 按下时标注锚点快照 + var fontSizeIdx = SC.defaultFontIdx // 当前选中字号索引(文字工具子菜单) + /// 上帧插入符矩形(CG 全局坐标;isNull = 无缓存),供闪烁/键系局部失效(对齐 lastCaretRect) + var lastCaretRect = CGRect.null + /// 编辑态 ESC 放行标志(event tap 后台线程读取;主线程在状态迁移/泵循环同步) + let textEditingFlag = ScreenshotAtomicFlag() + /// 保存对话框模态标志(模态期间 event tap 放行 ESC/右键给保存面板;保存流在 + /// runModal 前后置位/复位,见 ScreenshotOutputMac.swift 的 saveSelectionToFile) + let saveModalFlag = ScreenshotAtomicFlag() + /// 长截图态标志(event tap 据此把 ESC 路由到 longCancelFlag;泵循环同步置位/复位) + let longCaptureFlag = ScreenshotAtomicFlag() + /// 长截图取消标志(长截图态下 ESC 由 event tap 置位;泵循环消费 → 取消长截图整会话失败收束) + let longCancelFlag = ScreenshotAtomicFlag() + /// 长截图滚动捕获会话(进入长截图时创建,收束/取消后置 nil。 + /// 定义见 ScreenshotLongCaptureMac.swift) + var longCapture: ScreenshotLongCaptureSession? + + // ---- 马赛克(对齐 CaptureContext 的马赛克字段组)---- + var mosaicSizeIdx = SC.defaultMosaicIdx // 当前选中块大小索引 + var mosaicRadiusIdx = SC.defaultMosaicRadiusIdx // 当前选中涂抹半径索引 + var mosaicRectMode = false // true=框选区域模式;false=涂抹模式 + /// 会话内 mosaicBase 缓存(整屏按当前块大小预像素化;块大小变化才重建) + var mosaicBaseCache: ScreenshotMosaicBase? = nil + /// 工具栏/子菜单/tooltip 浮层族控制器(生命周期挂会话 start/finish;lazy 便于引用 self) + lazy var toolbar = ScreenshotToolbarController(session: self) + + // 脏区追踪(上帧浮层并集;局部失效与绘制共用几何,等价 Windows last*Rect 语义) + private var lastIdleOverlayRect: CGRect? + + // 保存对话框模态期间被降级的窗口层级快照(恢复用,见 duckOverlayLevelsForSaveModal) + private var savedOverlayLevels: [ObjectIdentifier: NSWindow.Level] = [:] + + init(options: ScreenshotSessionOptions, callback: ScreenshotResultCallback, + baseFrame: CapturedFrame, virtualBounds: CGRect) { + self.options = options + self.callback = callback + self.baseFrame = baseFrame + self.virtualBounds = virtualBounds + // 窗口吸附枚举(Windows 在会话开始 EnumWindowsForCapture 一次;此时覆盖层尚未创建, + // 候选天然不含自身窗口) + self.snapWindows = enumerateSnapWindows() + } + + /// 会话是否仍在进行(finish 后置 false;所有事件入口以此守卫防收束后残余事件写入状态)。 + var isRunning: Bool { return !hasFinished } + + // MARK: 生命周期 + + /// 创建多屏覆盖层窗口并显示,启动 ESC/右键 event tap 兜底。 + /// - Returns: true 会话就绪(随后 runEventPump);false 初始化失败(内部已 FailFast 回调并复位标志) + func start() -> Bool { + guard setupOverlayWindows() else { + finish("{\"success\":false,\"error\":\"failed to create overlay windows\"}") + return false + } + // CGEventTap 依赖辅助功能权限。启动时显式检查并打印明确错误; + // 无权限时 tap 创建失败,ESC/右键兜底自动降级为覆盖层自身 keyDown/rightMouseDown 处理。 + let trusted = AXIsProcessTrustedWithOptions( + [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: false] as CFDictionary) + if !trusted { + print("Error: Accessibility permission not granted - screenshot overlay ESC/right-click fallback (CGEventTap) unavailable") + } + eventTap = ScreenshotOverlayEventTap(cancelFlag: cancelFlag, textEditingFlag: textEditingFlag, + saveModalFlag: saveModalFlag, + longCaptureFlag: longCaptureFlag, + longCancelFlag: longCancelFlag) + eventTap?.start() + return true + } + + /// 手动泵主循环(取色器模式扩展):Node 主线程不跑 NSRunLoop, + /// 会话期间由本循环驱动 AppKit 事件分发(窗口绘制/鼠标/键盘),直至确认或取消收束。 + /// nextEvent 带超时返回,保证泵循环能逐拍消费 CGEventTap 兜底取消标志 + ///(对齐 Windows 空闲循环的 GetAsyncKeyState 轮询节奏)。 + /// + /// 功耗审计结论:16ms 超时是有意的事件等待节奏而非忙等——无事件时线程阻塞在 + /// nextEvent 上(空闲唤醒率上限 62.5/s),且泵循环仅在有截图会话期间运行(本就阻塞 + /// JS 主线程的瞬态交互期)。不能拉长超时换功耗:cancelFlag/event tap 兜底取消、 + /// 插入符闪烁、长截图采样轮都依赖 ≤16ms 的逐拍消费(event tap 注释的契约), + /// 拉长即违反响应性兜底;取色器用阻塞式 nextEvent(until: nil) 是其无轮询任务的 + /// 特例,不适用本会话。 + func runEventPump() { + while isRunning { + autoreleasepool { + if let event = NSApp.nextEvent(matching: .any, until: Date(timeIntervalSinceNow: 0.016), + inMode: .default, dequeue: true) { + NSApp.sendEvent(event) + } + pumpTick() + } + } + } + + /// 泵循环逐拍任务:消费兜底取消标志 + 工具栏浮层族的状态/位置同步与 tooltip 轮询 + ///(tooltip 定时进 pumpTick,走泵循环定时任务位)+ 编辑态插入符 500ms 闪烁 + ///(对齐 Windows 空闲循环 GetTickCount 分支)+ 编辑态标志同步(event tap ESC 放行)。 + private func pumpTick() { + guard isRunning else { return } + // 长截图态:泵循环驱动长截图采样/autoScroll/浮层刷新(ScreenshotLongCaptureMac.swift + // 的 lcTick;对齐 Windows RunLongCapture 在覆盖层窗口过程内自泵消息的语义)。 + // ESC(event tap → longCancelFlag)取消长截图:整会话 {success:false} 收束 + //(对齐 lc_session_windows.cpp abortFlag → LongCaptureEmitFailure);cancelFlag 兜底同义。 + if state == .longCapturing { + if cancelFlag.isSet || longCancelFlag.isSet { + cancelFlag.reset() + longCancelFlag.reset() + cancelLongCaptureSession() + } else { + longCapture?.lcTick() + } + return + } + longCancelFlag.reset() + if cancelFlag.isSet { + cancelFlag.reset() + cancelSession() + return + } + // 文字编辑态标志同步(≥16ms 延迟内 event tap 对 ESC 放行) + if state == .textEditing { + textEditingFlag.set() + } else { + textEditingFlag.reset() + } + // 编辑态插入符 500ms 闪烁(局部失效光标区域) + tickTextCaret(now: ProcessInfo.processInfo.systemUptime) + // 工具栏可见性随状态同步(对齐 OnPaint:Confirmed/Moving/Drawing/TextEditing 显示, + // Resizing 隐藏);!toolbarPlaced 时随选区自动重算位置(toolbarPlaced 语义)。 + let toolbarVisible = (state == .confirmed || state == .moving || state == .drawing + || state == .textEditing) + toolbar.syncVisibility(toolbarVisible) + if toolbarVisible { + toolbar.syncPlacement() + } + // hover 高亮 + tooltip 500ms 停顿轮询 + 浮层区域光标接管 + toolbar.tick(now: ProcessInfo.processInfo.systemUptime) + } + + /// 创建覆盖层窗口(每 NSScreen 一个;规格见 ScreenshotToolbarMac.swift 文件头「窗口规格」)并做初始 hover/取色。 + /// - Returns: 创建成功与否(无屏幕等极端环境返回 false) + private func setupOverlayWindows() -> Bool { + let screens = NSScreen.screens + guard !screens.isEmpty else { return false } + let initialMouse = ScreenshotGeometry.cgPoint(fromNS: NSEvent.mouseLocation) + mouse = initialMouse + currentColor = samplePixelColor(at: initialMouse) ?? ScreenshotRGB(r: 0, g: 0, b: 0) + hoveredWindowIndex = findWindowAtPoint(snapWindows, initialMouse) + + for screen in screens { + let cgFrame = ScreenshotGeometry.cgFrame(of: screen) + let window = OverlayScreenshotWindow( + contentRect: screen.frame, styleMask: .borderless, backing: .buffered, defer: false) + window.level = NSWindow.Level(rawValue: NSWindow.Level.screenSaver.rawValue + 1) + window.isOpaque = false + window.backgroundColor = .clear + window.hasShadow = false + window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + window.isReleasedWhenClosed = false + window.acceptsMouseMovedEvents = true + // 不设 ignoresMouseEvents:覆盖层需接收拖拽 + let view = OverlayScreenshotView( + session: self, cgOrigin: cgFrame.origin, + frame: NSRect(origin: .zero, size: cgFrame.size)) + window.contentView = view + // 键盘事件入口:显式指定初始 first responder,保证窗口成为 key 后 + // keyDown(方向键/ESC/文字编辑键系)直达覆盖层视图(NSTextInputClient 宿主) + window.initialFirstResponder = view + windows.append(window) + views.append(view) + } + for w in windows { + w.orderFrontRegardless() + } + // 鼠标所在屏的窗口做 key(键盘事件入口;点击其他屏窗口时 AppKit 自动换 key) + if let keyIndex = views.firstIndex(where: { $0.cgFrame.contains(initialMouse) }) { + windows[keyIndex].makeKeyAndOrderFront(nil) + } + NSApp.activate(ignoringOtherApps: true) + invalidateAll() + return true + } + + /// 会话统一出口:停 event tap → 销毁长截图会话 → 销毁工具栏浮层 → 销毁窗口 + /// → 回调(恰好一次)→ 复位重入标志(会话结束顺序;对齐 Windows 捕获线程末尾清理)。 + /// (internal:ScreenshotOutputMac.swift 的保存流 saveSelectionToFile 复用。) + func finish(_ payload: String) { + guard !hasFinished else { return } + hasFinished = true + NSCursor.arrow.set() + // 长截图会话随会话收束销毁(abort/save/finish 收束路径已在 LC 侧先行清理, + // 此处兜底防残余窗口/滚轮 tap 泄漏) + longCapture?.lcTeardown() + longCapture = nil + eventTap?.stop() + eventTap = nil + toolbar.destroy() + ScreenshotMosaicCursors.reset() // 圆环光标随会话生命周期释放 + mosaicBaseCache = nil // 马赛克 base 随会话释放 + for window in windows { + window.orderOut(nil) + window.contentView = nil + } + windows.removeAll() + views.removeAll() + payload.withCString { cStr in + callback(cStr) + } + screenshotStateLock.lock() + screenshotSessionActive = false + screenshotStateLock.unlock() + } + + /// 构造失败回调 JSON(macOS 契约新增可选 error 字段,不改既有字段)。 + /// (internal:ScreenshotOutputMac.swift 的保存流复用。) + func failurePayload(_ error: String) -> String { + return "{\"success\":false,\"error\":\"\(error)\"}" + } + + /// 覆盖层与工具栏浮层族窗口层级临时降级/恢复(保存对话框弹出前/关闭后调用)。 + /// 对齐 Windows PromptSaveFilePath:弹出前 SetWindowPos(HWND_NOTOPMOST) 让保存对话框 + /// 显示在最上层、关闭后恢复 HWND_TOPMOST(output_windows.cpp L608-609/L632-636/L654-657)。 + /// macOS 方案:覆盖层与工具栏浮层族在 screenSaver+n 层(约 1001+),而模态保存面板在 + /// NSModalPanelWindowLevel(8),不降级会被完全遮挡;统一临时下调到 SC.saveModalDuckLevel + /// (模态面板之下一档、普通应用窗口之上)——用户可见行为最接近 Windows:覆盖层暗化蒙版 + /// 与工具栏保持可见,仅保存对话框浮于其上(选 level 下调而非 orderOut,避免桌面闪烁)。 + /// - Parameter lowered: true 降级(记录原层级);false 恢复(会话已收口时窗口已销毁,安全 no-op) + func duckOverlayLevelsForSaveModal(_ lowered: Bool) { + if lowered { + savedOverlayLevels.removeAll() + for window in windows { + savedOverlayLevels[ObjectIdentifier(window)] = window.level + window.level = SC.saveModalDuckLevel + } + } else { + for window in windows { + if let level = savedOverlayLevels[ObjectIdentifier(window)] { + window.level = level + } + } + savedOverlayLevels.removeAll() + } + // 工具栏/子菜单/tooltip 浮层族同步降级/恢复(Windows 工具栏画在覆盖层窗口内, + // 摘 TOPMOST 一并生效;macOS 为独立窗口需同步处理) + toolbar.setPanelLevelBelowModal(lowered) + } + + // MARK: 长截图窗口切换(实现见 ScreenshotLongCaptureMac.swift) + + /// 隐藏全部覆盖层窗口(进入长截图时调用;会话与窗口对象保留至会话收束统一销毁)。 + /// 对齐 Windows EnterLongCapture 的 ShowWindow(overlayHwnd, SW_HIDE)——覆盖层被独立 + /// 灰蒙版 + 小地图 + 工具栏接管(lc_session_windows.cpp BeginLongCapture 注释)。 + func hideOverlayWindowsForLongCapture() { + for window in windows { + window.orderOut(nil) + } + } + + // MARK: 取消与确认 + + /// 取消会话(ESC / 右键;对齐 Windows OnKeyDown VK_ESCAPE 与 WM_RBUTTONDOWN): + /// 回调 {success:false}(取消语义无坐标/图像字段,与 Windows 取消回调一致)。 + func cancelSession() { + guard isRunning else { return } + state = .cancelled + finish("{\"success\":false}") + } + + /// 确认输出(对齐 Windows 工具栏确定/Enter/双击/autoConfirm 松手共用的 ExtractRegionResult + /// 路径):底图按选区物理像素裁剪 → 物理尺寸合成(Retina 2x,不再缩回逻辑尺寸)→ 合成 + /// 矢量标注 → 圆角蒙版(radius>0)→ PNG 编码 → NSPasteboard 写入 → 回调 + /// {success:true, x, y, x2, y2, width, height, base64}(坐标契约仍为 CG 全局逻辑坐标)。 + /// radius>0 走圆角透明导出(alpha 通道 PNG 写剪贴板, + /// 对齐 Windows BuildRoundedArgbFinal 语义);任一环节失败均按 {success:false, error:...} + /// 收口(失败不输出黑图)。编码/编码产物构建统一走 ScreenshotOutputMac.swift 的 + /// buildFinalPngOutput(与保存路径共用,长截图复用)。 + /// (工具栏确定按钮经 handleToolbarButton 调用,故为 internal。) + func confirmSelection() { + guard isRunning else { return } + let sel = selection.standardized + guard sel.width >= 1, sel.height >= 1 else { + cancelSession() + return + } + + // 物理像素裁剪(对齐 ExtractRegionResult:物理 = (逻辑 - 虚拟屏原点) * dpiScale,钳制在位图内) + guard let cropped = cropSelectionPhysical(sel) else { + finish(failurePayload("failed to crop screenshot region")) + return + } + + // 统一输出:物理尺寸合成(Retina 2x,底图 1:1)→ 合成标注 → 圆角蒙版(radius>0) + // → 单次 PNG 编码(同时产出 base64 与文件字节;半径取会话当前选区圆角,0 = 直角) + guard let output = buildFinalPngOutput(cropped: cropped, sel: sel, + cornerRadius: selectionCornerRadius) else { + finish(failurePayload("failed to encode screenshot")) + return + } + guard writePngToPasteboard(output.pngData) else { + finish(failurePayload("failed to copy screenshot to clipboard")) + return + } + + state = .done + finish(scSuccessPayloadJSON(sel: sel, base64: output.base64)) + } + + // MARK: 脏区失效(setNeedsDisplay 局部失效对齐 Windows InvalidateRect) + + /// 局部失效:CG 全局矩形换算到每个覆盖层视图本地后 setNeedsDisplay(rect)。 + /// rect 传 nil 时全屏失效(等价 Windows InvalidateRect(NULL)),蒙版边界变化的 + /// Selecting/Resizing/Moving 全程使用。 + func invalidate(_ rect: CGRect?) { + for view in views { + let local: CGRect + if let rect = rect { + let clipped = rect.intersection(view.cgFrame) + if clipped.isNull || clipped.width <= 0 || clipped.height <= 0 { continue } + local = clipped.offsetBy(dx: -view.cgOrigin.x, dy: -view.cgOrigin.y) + } else { + local = view.bounds + } + view.setNeedsDisplay(local) + } + } + + /// 全屏失效(所有覆盖层视图整体重绘)。 + func invalidateAll() { + invalidate(nil) + } + + /// 标注拖拽/缩放热路径的局部失效(overlay_input_windows.cpp InvalidateAnnotationOp + /// 调用点等价,性能审计):脏区 = 上帧标注盒(lastAnnotationOpBox)∪ 本帧盒, + /// 外扩 handleMargin(覆盖选中手柄、描边与抗锯齿)。调用后把本帧盒写回 + /// lastAnnotationOpBox 形成逐帧链式脏区,拖拽轨迹上一位置随之清除不留残影 + ///(AppKit 合并同帧内多次 setNeedsDisplay,语义与 Windows WM_PAINT 缓存更新等价)。 + /// - Parameter newBox: 本帧标注几何包围盒(未外扩,CG 全局坐标) + func invalidateAnnotationOpLocal(newBox: CGRect) { + let dirty = scUnionRect(lastAnnotationOpBox.isNull ? nil : lastAnnotationOpBox, newBox) + ?? newBox + lastAnnotationOpBox = newBox + invalidate(scInflate(dirty, SC.handleMargin)) + } + + // MARK: 底图采样(放大镜/取色共用;对齐 GetPixelColorFromBitmap 与 DrawInfoPanel 源区钳制) + + /// 从常驻底图 CGImage 裁剪以 focus 为中心的采样区(物理像素,四边钳制在位图内)。 + /// 左/上越界把起点钳回 0,右/下收窄采样宽高(对齐 DrawInfoPanel 的源区钳制语义)。 + /// - Parameters: + /// - focus: 采样焦点(鼠标或活动手柄锚点,CG 全局坐标) + /// - srcLogicalW/srcLogicalH: 采样区逻辑尺寸(绘制前除以放大倍数) + /// - Returns: 裁剪出的物理像素 CGImage;采样区非正(焦点越界)返回 nil + func cropBaseImage(around focus: CGPoint, srcLogicalW: CGFloat, srcLogicalH: CGFloat) -> CGImage? { + let scale = baseFrame.scale + let imgW = CGFloat(baseFrame.image.width) + let imgH = CGFloat(baseFrame.image.height) + let wPhys = (srcLogicalW * scale).rounded(.down) + let hPhys = (srcLogicalH * scale).rounded(.down) + guard wPhys > 0, hPhys > 0 else { return nil } + let fx = ((focus.x - baseFrame.origin.x) * scale).rounded(.down) + let fy = ((focus.y - baseFrame.origin.y) * scale).rounded(.down) + let x0 = max(0, fx - wPhys / 2) + let y0 = max(0, fy - hPhys / 2) + let w = min(wPhys, imgW - x0) + let h = min(hPhys, imgH - y0) + guard w > 0, h > 0 else { return nil } + return baseFrame.image.cropping(to: CGRect(x: x0, y: y0, width: w, height: h)) + } + + /// 采样指定点的像素颜色(对齐 GetPixelColorFromBitmap:物理 = (逻辑 - 原点) * scale)。 + /// - Parameter point: CG 全局逻辑坐标 + /// - Returns: RGB 颜色;点越界返回 nil(调用方保留上一次颜色) + func samplePixelColor(at point: CGPoint) -> ScreenshotRGB? { + let scale = baseFrame.scale + let px = ((point.x - baseFrame.origin.x) * scale).rounded(.down) + let py = ((point.y - baseFrame.origin.y) * scale).rounded(.down) + guard px >= 0, py >= 0, px < CGFloat(baseFrame.image.width), py < CGFloat(baseFrame.image.height) else { + return nil + } + guard let cropped = baseFrame.image.cropping(to: CGRect(x: px, y: py, width: 1, height: 1)) else { + return nil + } + var pixel = [UInt8](repeating: 0, count: 4) + let ok = pixel.withUnsafeMutableBytes { ptr -> Bool in + guard let ctx = CGContext( + data: ptr.baseAddress, width: 1, height: 1, bitsPerComponent: 8, bytesPerRow: 4, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue | CGBitmapInfo.byteOrder32Big.rawValue + ) else { return false } + ctx.draw(cropped, in: CGRect(x: 0, y: 0, width: 1, height: 1)) + return true + } + guard ok else { return nil } + return ScreenshotRGB(r: pixel[0], g: pixel[1], b: pixel[2]) + } + + // MARK: 浮层几何(绘制与局部失效共用,保证脏区与实际绘制一致) + + /// 拖拽创建中的实时选区(规范化,对齐 OnPaint Selecting 分支的 min/max 计算)。 + func currentDragRect() -> CGRect { + return CGRect( + x: min(dragStartPoint.x, dragCurrentPoint.x), + y: min(dragStartPoint.y, dragCurrentPoint.y), + width: abs(dragCurrentPoint.x - dragStartPoint.x), + height: abs(dragCurrentPoint.y - dragStartPoint.y)) + } + + /// Idle 态浮层几何:高亮框(hover 窗口,否则鼠标所在屏——对齐 OnPaint CS_Idle 退化链) + /// + 尺寸标签(W×H)+ 跟随放大镜面板。绘制与失效共用同一几何来源。 + func idleOverlayRects() -> (highlight: CGRect?, label: CGRect?, labelText: String, panel: CGRect) { + var highlight: CGRect? + var labelText = "" + if hoveredWindowIndex >= 0 && hoveredWindowIndex < snapWindows.count { + let r = snapWindows[hoveredWindowIndex].rect + highlight = r + labelText = "\(Int(r.width.rounded())) × \(Int(r.height.rounded()))" + } else if let screen = screenContaining(mouse) { + let cg = ScreenshotGeometry.cgFrame(of: screen) + highlight = cg + labelText = "\(Int(cg.width.rounded())) × \(Int(cg.height.rounded()))" + } + let label = labelText.isEmpty ? nil : highlight.flatMap { scCalcSizeLabelRect(labelText, $0, virtualBounds) } + return (highlight, label, labelText, scCalcPanelRect(mouse, virtualBounds)) + } + + /// Idle 态浮层并集(局部失效用)。 + func idleOverlayRectsUnion() -> CGRect? { + let r = idleOverlayRects() + return scUnionRect(scUnionRect(r.highlight, r.label), r.panel) + } + + /// 查找包含指定点的屏幕(鼠标所在屏退化链与单屏退化用)。 + private func screenContaining(_ point: CGPoint) -> NSScreen? { + return NSScreen.screens.first { ScreenshotGeometry.cgFrame(of: $0).contains(point) } + } + + /// 圆角手柄的脏区矩形(以手柄中心为基点外扩 handleMargin,覆盖半径 + 描边/抗锯齿; + /// 对齐 Windows CornerHandleDirtyRect)。 + private func cornerKnobDirtyRect(_ corner: ScreenshotResizeHandle) -> CGRect? { + guard corner != .none else { return nil } + let center = scCornerKnobCenter(selection, SC.cornerKnobInset, selectionCornerRadius, corner) + return scInflate(CGRect(origin: center, size: .zero), SC.handleMargin) + } + + // MARK: 鼠标事件(OverlayScreenshotView 转发;坐标已换算为 CG 全局整数点) + + /// 左键按下(对齐 Windows OnLButtonDown 的基础子集)。 + /// - Parameters: + /// - point: 鼠标 CG 全局坐标 + /// - clickCount: 点击计数(2 = 双击,确认态双击选区内确认) + func handleMouseDown(_ point: CGPoint, clickCount: Int) { + guard isRunning else { return } + mouse = point + switch state { + case .idle: + // 开始新的框选;新框选从直角开始(对齐 OnLButtonDown CS_Idle 分支) + dragStartPoint = point + dragCurrentPoint = point + selectionCornerRadius = 0 + state = .selecting + lastIdleOverlayRect = nil + invalidateAll() + case .textEditing: + // 文字编辑态:输入框拖选 / 选区内换位(提交路径 4)/ 选区外退出(提交路径 5)。 + // 工具栏/子菜单为独立浮层窗口,其点击提交路径(1/2/3)在工具栏控制器承接。 + handleTextEditingMouseDown(point) + case .confirmed: + // 双击选区内确认(对齐 WM_LBUTTONDBLCLK:确认态 + 选区内 → 确认截图) + if clickCount >= 2 && selection.contains(point) { + confirmSelection() + return + } + // ---- 标注交互(对齐 overlay_input_windows.cpp OnLButtonDown CS_Confirmed 分支顺序)---- + // 1) 已选中标注的缩放手柄命中 → 进入标注缩放(先于普通命中:手柄贴在选中框上; + // 拖拽按钮下不响应,纯选择模式) + if selectedAnnotation >= 0 && selectedAnnotation < annotations.count && activeTool != .drag { + let handle = scHitTestAnnotationHandle(annotations[selectedAnnotation], point, SC.handleSize) + if handle != .none { + resizingAnnotation = selectedAnnotation + annotationResizeHandle = handle + annotationDragStartPoint = point + annotationResizeStartBox = scMeasureAnnotationBounds(annotations[selectedAnnotation]) + dragStartAnnotation = annotations[selectedAnnotation] + annotationOpHistoryPushed = false + lastAnnotationOpBox = .null // 进入标注缩放:复位上帧脏区链 + invalidateAll() + return + } + } + // 2) 文字标注命中 → 优先选中并可拖动(先于非文字命中,避免覆盖物重叠时被吞掉)。 + // selectedTextAnnotation 持久保持选中;文字工具回显(activeTool=.text + 子菜单 + + // 字号/颜色回显,对齐 EchoFontIdx/EchoColorIdx 分支)。 + let hitText = scHitTestTextAnnotations(annotations, point) + if hitText >= 0 { + selectTextAnnotation(hitText, at: point) + return + } + // 3) 任意非文字标注命中(z 序取最上)→ 选中并进入拖拽 + 工具栏回显 + // (工具激活时也优先选中已有对象,点空白才绘制) + let hitAnn = scHitTestAnnotation(annotations, point) + if hitAnn >= 0 && annotations[hitAnn].type != .text { + let dirty = scUnionRect(selectedAnnotationDirtyRect(), selectedTextAnnotationDirtyRect()) + selectedTextAnnotation = -1 + draggingTextAnnotation = -1 + selectedAnnotation = hitAnn + draggingAnnotation = hitAnn + annotationDragStartPoint = point + dragStartAnnotation = annotations[hitAnn] + annotationOpHistoryPushed = false + lastAnnotationOpBox = .null // 进入标注拖拽:复位上帧脏区链 + // 工具栏回显:切换到该标注对应工具,子菜单回显粗细/颜色(对齐回显分支) + let hitA = annotations[hitAnn] + activeTool = scAnnotationTypeToTool(hitA.type) + if let tool = activeTool { + toolbar.openPopup(for: tool) + } + scEchoThickIdx(hitA.thickness) + scEchoColorIdx(hitA.color) + if let dirty = dirty { invalidate(dirty) } + invalidateAll() + return + } + // 4) 矢量工具激活 + 选区内点空白 → 开始绘制新标注(子菜单保持打开, + // 当前粗细/颜色在绘制开始时固化) + if let tool = activeTool, tool.isVectorTool, selection.contains(point) { + beginAnnotationDrawing(at: point) + return + } + // 5) 马赛克工具激活 + 选区内点空白 → 开始马赛克绘制(框选/涂抹按子菜单模式) + if activeTool == .mosaic, selection.contains(point) { + beginMosaicDrawing(at: point) + return + } + // 6) 文字工具激活 + 选区内点空白 → 进入文字编辑态(清选中,子菜单保持打开) + if activeTool == .text, selection.contains(point) { + beginTextEditing(at: point) + return + } + // 7) 8 个 resize 手柄命中 → Resizing(实时命中,不依赖 hover 缓存; + // 进入手柄调整时清除标注选中,对齐 Windows 同分支行为) + let handle = scHitTestHandle(point, selection, SC.handleSize) + if handle != .none { + let dirty = scUnionRect(selectedAnnotationDirtyRect(), selectedTextAnnotationDirtyRect()) + clearAnnotationSelection() + invalidate(dirty) + resizeHandle = handle + handleDragStartPoint = point + dragStartSelection = selection + kbDX = 0 + kbDY = 0 + state = .resizing + invalidateAll() + return + } + // 圆角手柄命中 → 圆角调整(复用 CS_Resizing + 角手柄,记录拖拽起始半径) + let corner = scHitTestCornerKnob(point, selection, SC.handleSize, SC.cornerKnobInset, selectionCornerRadius) + if corner != .none { + let dirty = scUnionRect(selectedAnnotationDirtyRect(), selectedTextAnnotationDirtyRect()) + clearAnnotationSelection() + invalidate(dirty) + resizeHandle = corner + handleDragStartPoint = point + dragStartSelection = selection + cornerDragStartRadius = selectionCornerRadius + kbDX = 0 + kbDY = 0 + state = .resizing + invalidateAll() + return + } + // 选区内 → 整体拖动(有标注内容时禁用,避免标注与背景错位——对齐 Windows; + // 此时点空白仅取消当前选中) + if selection.contains(point) { + if !canDragSelection() { + if selectedAnnotation >= 0 || selectedTextAnnotation >= 0 { + let dirty = scUnionRect(selectedAnnotationDirtyRect(), selectedTextAnnotationDirtyRect()) + clearAnnotationSelection() + invalidate(dirty) + } + return + } + clearAnnotationSelection() + handleDragStartPoint = point + dragStartSelection = selection + state = .moving + invalidateAll() + return + } + // 选区外点击:清除标注选中(确认态不可重新框选,对齐 Windows 选区外分支) + if selectedAnnotation >= 0 || selectedTextAnnotation >= 0 { + let dirty = scUnionRect(selectedAnnotationDirtyRect(), selectedTextAnnotationDirtyRect()) + clearAnnotationSelection() + invalidate(dirty) + } + default: + break + } + updateCursor() + } + + /// 左键拖动(对齐 Windows OnMouseMove 的基础子集)。 + /// - Parameter point: 鼠标 CG 全局坐标 + func handleMouseDragged(_ point: CGPoint) { + guard isRunning else { return } + mouse = point + switch state { + case .selecting: + // 更新框选终点;蒙版边界大范围变化,全屏失效(对齐 Selecting 的 InvalidateRect(NULL)) + dragCurrentPoint = point + invalidateAll() + case .resizing: + if resizeHandle.isCorner { + // 圆角调整:手柄沿所在角对角线滑动,四角同步(共用同一 radius)。 + // 取鼠标位移在该角对角线方向的投影(两向内分量之和/2)作为半径增量, + // 垂直于对角线的位移被忽略,手柄轨迹恒为对角线(对齐 CS_Resizing 圆角分支)。 + let dx = point.x - handleDragStartPoint.x + let dy = point.y - handleDragStartPoint.y + let inX: CGFloat + let inY: CGFloat + switch resizeHandle { + case .cornerTL: inX = dx; inY = dy // 右下为内 + case .cornerTR: inX = -dx; inY = dy // 左下为内 + case .cornerBL: inX = dx; inY = -dy // 右上为内 + case .cornerBR: inX = -dx; inY = -dy // 左上为内 + default: inX = dx; inY = dy + } + let diagDelta = (inX + inY) / 2 + selectionCornerRadius = scClampCornerRadius(cornerDragStartRadius + diagDelta, selection) + invalidateAll() + } else { + // 标准手柄:每帧从按下快照重算,活动端可穿越固定端翻转(拖拽中不强制最小尺寸) + applyResizeSelection(enforceMinSize: false) + } + case .moving: + // 整体平移:按"按下矩形 + 鼠标位移"重算并钳制在虚拟屏内(对齐 CS_Moving 分支) + let dx = point.x - handleDragStartPoint.x + let dy = point.y - handleDragStartPoint.y + var nl = dragStartSelection.minX + dx + var nt = dragStartSelection.minY + dy + if nl < virtualBounds.minX { nl = virtualBounds.minX } + if nt < virtualBounds.minY { nt = virtualBounds.minY } + if nl + dragStartSelection.width > virtualBounds.maxX { + nl = virtualBounds.maxX - dragStartSelection.width + } + if nt + dragStartSelection.height > virtualBounds.maxY { + nt = virtualBounds.maxY - dragStartSelection.height + } + selection = CGRect(origin: CGPoint(x: nl, y: nt), size: dragStartSelection.size) + // 未拖放过的工具栏实时跟随选区(对齐 OnPaint 的 CalcToolbarPosition 分支) + toolbar.syncPlacement() + invalidateAll() + case .drawing: + // 更新进行中标注终点/路径(终点钳制选区内、画笔/涂抹马赛克逐点追加,对齐分支) + updateAnnotationDrawing(point) + case .textEditing: + // 文字编辑态:拖动选择文字(对齐 OnMouseMove CS_TextEditing 分支) + handleTextEditingDragged(point) + case .confirmed: + // 标注拖拽/缩放(对齐 OnMouseMove 的 resizing/dragging 分支): + // 状态保持 confirmed,由索引标志区分(与文字拖拽机制对称,Windows 同款) + if resizingAnnotation >= 0 { + applyAnnotationResizeDrag(point) + } else if draggingAnnotation >= 0 { + applyAnnotationMoveDrag(point) + } else if draggingTextAnnotation >= 0 { + // 拖动文字标注(对齐 OnMouseMove draggingTextAnnotation 分支) + applyTextAnnotationMoveDrag(point) + } + default: + break + } + updateCursor() + } + + /// 左键抬起(对齐 Windows OnLButtonUp 的基础子集;autoConfirm 仅在 Selecting 分支生效)。 + /// - Parameter point: 鼠标 CG 全局坐标 + func handleMouseUp(_ point: CGPoint) { + guard isRunning else { return } + mouse = point + switch state { + case .selecting: + let w = abs(dragCurrentPoint.x - dragStartPoint.x) + let h = abs(dragCurrentPoint.y - dragStartPoint.y) + var finalRect: CGRect + if w <= 1 && h <= 1 { + // 拖动 ≤1×1px 视为单击 → 智能窗口吸附退化链: + // 候选窗口(Z 序首个命中)→ 鼠标所在屏幕 → 虚拟屏(对齐 FindWindowAtPoint + MonitorFromPoint) + finalRect = snapRectAtPoint(dragCurrentPoint) + } else { + finalRect = currentDragRect() + } + enterConfirmed(finalRect) + if options.autoConfirm { + // autoConfirm:松手(或单击吸附确定)→ 直接确认输出,不进编辑态 + confirmSelection() + return + } + case .resizing: + if resizeHandle.isCorner { + // 圆角调整结束:钳制半径即足够(选区矩形未变),回确认态并重算靠近角 + selectionCornerRadius = scClampCornerRadius(selectionCornerRadius, selection) + resizeHandle = .none + hoveredCornerHandle = scFindNearestCornerKnob(mouse, selection, SC.handleSize, + SC.cornerKnobInset, selectionCornerRadius, SC.cornerProximity) + state = .confirmed + invalidateAll() + } else { + // resize 结束:只沿活动端当前所在侧补足最小尺寸(不能复用 EnterConfirmed 的 + // 固定向右/下扩张,否则穿越后会移动按下时的固定点);键盘微调一并固化。 + applyResizeSelection(enforceMinSize: true) + kbDX = 0 + kbDY = 0 + resizeHandle = .none + hoveredCornerHandle = .none + state = .confirmed + // 选区尺寸变化后半径可能越界,钳制 + selectionCornerRadius = scClampCornerRadius(selectionCornerRadius, selection) + invalidateAll() + } + case .moving: + // 整体拖动结束仍走确认流程(对齐 CS_Moving 分支的 EnterConfirmed) + enterConfirmed(selection) + case .drawing: + // 绘制结束 → 有效尺寸/路径提交并 Push 历史(对齐 OnLButtonUp CS_Drawing 分支; + // 马赛克框选 ≥2px / 涂抹 ≥1 点的有效性判定在 finishAnnotationDrawing 内) + finishAnnotationDrawing() + case .textEditing: + // 文字选择结束(对齐 OnLButtonUp CS_TextEditing 分支) + handleTextEditingMouseUp() + case .confirmed: + // 标注拖拽/缩放收束:退出拖拽态并按最终包围盒刷新(对齐 OnLButtonUp 分支, + // 含 draggingTextAnnotation)。局部脏区 = 上帧盒 ∪ 最终盒(性能审计: + // 对齐 Windows 收束路径 InvalidateAnnotationOp(finalBox) 的局部刷新,不退化为全屏重绘)。 + if resizingAnnotation >= 0 || draggingAnnotation >= 0 || draggingTextAnnotation >= 0 { + var finalBox = CGRect.null + if resizingAnnotation >= 0 && resizingAnnotation < annotations.count { + finalBox = scMeasureAnnotationBounds(annotations[resizingAnnotation]) + } else if draggingAnnotation >= 0 && draggingAnnotation < annotations.count { + finalBox = scMeasureAnnotationBounds(annotations[draggingAnnotation]) + } else if draggingTextAnnotation >= 0 && draggingTextAnnotation < annotations.count { + finalBox = scMeasureAnnotationBounds(annotations[draggingTextAnnotation]) + } + resizingAnnotation = -1 + draggingAnnotation = -1 + draggingTextAnnotation = -1 + annotationResizeHandle = .none + annotationOpHistoryPushed = false + if finalBox.isNull { + // 防御兜底:索引非法拿不到最终包围盒时退回全屏刷新(正常路径不可达) + lastAnnotationOpBox = .null + invalidateAll() + } else { + let dirty = scInflate(scUnionRect(lastAnnotationOpBox.isNull ? nil : lastAnnotationOpBox, + finalBox) ?? finalBox, SC.handleMargin) + lastAnnotationOpBox = .null + invalidate(dirty) + } + } + default: + break + } + updateCursor() + } + + /// 鼠标移动(无按键,对齐 Windows OnMouseMove 的 Idle/Confirmed hover 分支)。 + /// - Parameter point: 鼠标 CG 全局坐标 + func handleMouseMoved(_ point: CGPoint) { + guard isRunning else { return } + mouse = point + switch state { + case .idle: + // hover 窗口命中 + 采样取色 + 放大镜跟随:旧 ∪ 新浮层并集局部失效。 + // 节流审计结论(放大镜/面板跟随):Windows overlay_ui_windows.cpp 无显式 + // 30fps 节流常量,跟随 = WM_MOUSEMOVE 事件驱动 + 脏区失效(其 RDP 注释强调 + // 实时命中、不依赖 hover 缓存)。macOS 对齐同语义:跟随仅由鼠标事件驱动、 + // 放大镜区域随本并集局部重绘,无定时器轮询、无丢帧合并;长截图侧小地图/ + // 工具栏均为状态变化按需刷新(contentChanged/trackingChanged/uiTick 100ms)。 + let oldUnion = lastIdleOverlayRect + hoveredWindowIndex = findWindowAtPoint(snapWindows, point) + currentColor = samplePixelColor(at: point) ?? currentColor + let newUnion = idleOverlayRectsUnion() + if let dirty = scUnionRect(oldUnion, newUnion) { + invalidate(scInflate(dirty, 5)) // 余量覆盖 3px 高亮描边与抗锯齿 + } + lastIdleOverlayRect = newUnion + case .confirmed: + // 圆角手柄"靠近"探测:感应区比命中框大一圈,靠近即显示该角手柄; + // 出现/消失/换角时旧 ∪ 新手柄位置局部重绘(对齐 hoveredCornerHandle 更新逻辑) + let near = scFindNearestCornerKnob(point, selection, SC.handleSize, + SC.cornerKnobInset, selectionCornerRadius, SC.cornerProximity) + if near != hoveredCornerHandle { + var dirty = cornerKnobDirtyRect(hoveredCornerHandle) + dirty = scUnionRect(dirty, cornerKnobDirtyRect(near)) + hoveredCornerHandle = near + invalidate(dirty) + } + default: + break + } + updateCursor() + } + + /// 键盘事件(对齐 Windows OnKeyDown;ESC 优先走 event tap 兜底,tap 缺位时由覆盖层 + /// 自身 keyDown 承接。文字编辑态整段交给 handleTextEditingKeyDown:IME/可打印字符经 + /// NSTextInputClient,键系 Backspace/Delete/Left/Right/Home/End/Enter/ESC 自管)。 + /// - Parameters: + /// - event: 键盘事件 + /// - textInputContext: 覆盖层视图的输入上下文(文字编辑态路由 IME 用) + func handleKeyDown(_ event: NSEvent, textInputContext: NSTextInputContext? = nil) { + guard isRunning else { return } + // 文字编辑态:IME + 键系(含 ESC=清缓冲、Enter=提交,消费后不落入下方取消/确认分支) + if state == .textEditing, handleTextEditingKeyDown(event, inputContext: textInputContext) { + return + } + switch event.keyCode { + case 36, 76: + // Enter / 小键盘 Enter:确认态确认截图 + if state == .confirmed { + confirmSelection() + } + case 123, 124, 125, 126: + // 方向键微调(对齐 HandleSelectionNudgeKey):Resizing 微调活动边,Confirmed 整体平移; + // Shift 加速到 10px + let step: CGFloat = event.modifierFlags.contains(.shift) ? 10 : 1 + let ddx: CGFloat + let ddy: CGFloat + switch event.keyCode { + case 123: ddx = -step; ddy = 0 // Left + case 124: ddx = step; ddy = 0 // Right + case 125: ddx = 0; ddy = step // Down + default: ddx = 0; ddy = -step // Up + } + if state == .resizing && !resizeHandle.isCorner { + kbDX += ddx + kbDY += ddy + applyResizeSelection(enforceMinSize: false) + } else if state == .confirmed { + nudgeSelection(dx: ddx, dy: ddy) + } + case 51, 117: + // Delete / Backspace(Windows VK_DELETE 语义;Mac 上两键都删便于操作): + // 确认态删除选中覆盖物(文字标注优先,入历史),随后回到纯选择模式并关闭子菜单 + //(对齐 OnKeyDown VK_DELETE 分支的 activeTool=TB_Drag 收尾) + if state == .confirmed { + if selectedTextAnnotation >= 0 && selectedTextAnnotation < annotations.count { + pushAnnotationHistory() + annotations.remove(at: selectedTextAnnotation) + clearAnnotationSelection() + activeTool = .drag + toolbar.closePopup() + invalidateAll() + } else if selectedAnnotation >= 0 && selectedAnnotation < annotations.count { + pushAnnotationHistory() + annotations.remove(at: selectedAnnotation) + clearAnnotationSelection() + activeTool = .drag + toolbar.closePopup() + invalidateAll() + } + } + case 53: + // ESC:取消(event tap 活跃时该事件已被 tap 吞掉不会到达此处; + // 文字编辑态的 ESC 已在上方分支清缓冲消费,不取消截图) + cancelSession() + default: + break + } + } + + // MARK: 工具栏/子菜单动作(ScreenshotToolbarController 回话) + + /// 工具栏按钮点击(对齐 overlay_input_windows.cpp OnLButtonDown 的工具栏分支): + /// 选中态保留规则——点击「与已选中标注同类型」的工具按钮保留选中仅切换子菜单开合并 + /// 回显其参数;异类/无关按钮取消选中。 + /// - Parameter button: 命中的可用按钮 + func handleToolbarButton(_ button: ScreenshotToolButton) { + guard isRunning, state == .confirmed else { return } + switch button { + case .confirm: + confirmSelection() + return + case .cancel: + cancelSession() + return + case .save: + // 保存到本地(对齐 Windows TB_Save 分支):取消对话框回编辑态; + // 保存成功/失败均结束会话并回调(不写剪贴板),见 ScreenshotOutputMac.swift + saveSelectionToFile() + return + case .undo: + _ = undoAnnotations() + return + case .redo: + _ = redoAnnotations() + return + case .drag, .rect, .circle, .arrow, .brush, .text: + // 选中态保留规则:同类工具点击保留选中并回显;异类切换取消当前选中 + let matchesSelection: Bool + if button == .text { + // 文字按钮匹配「已选中文字标注」(对齐 TB_Text 分支的 matchesSelection) + matchesSelection = selectedTextAnnotation >= 0 && selectedTextAnnotation < annotations.count + } else if button == .drag { + matchesSelection = false + } else { + matchesSelection = selectedAnnotation >= 0 && selectedAnnotation < annotations.count + && scAnnotationTypeToTool(annotations[selectedAnnotation].type) == button + } + if !matchesSelection && (selectedAnnotation >= 0 || selectedTextAnnotation >= 0) { + let dirty = scUnionRect(selectedAnnotationDirtyRect(), selectedTextAnnotationDirtyRect()) + clearAnnotationSelection() + invalidate(dirty) + } + if button == .drag { + // 拖拽按钮 = 取消当前工具回到纯选择模式(再次点击切换开合); + // 仍有选中项时回显其参数(对齐 TB_Drag 分支:文字→字号/颜色,矢量→粗细/颜色) + if activeTool == .drag { + activeTool = nil + } else { + activeTool = .drag + if selectedTextAnnotation >= 0 && selectedTextAnnotation < annotations.count { + toolbar.openPopup(for: .text) + scEchoFontIdx(annotations[selectedTextAnnotation].thickness) + scEchoColorIdx(annotations[selectedTextAnnotation].color) + } else if selectedAnnotation >= 0 && selectedAnnotation < annotations.count { + let selA = annotations[selectedAnnotation] + if let tool = scAnnotationTypeToTool(selA.type) { + toolbar.openPopup(for: tool) + } + scEchoThickIdx(selA.thickness) + scEchoColorIdx(selA.color) + } else { + toolbar.closePopup() + } + } + } else if activeTool == button { + // 再次点同一工具:关闭工具与子菜单(同类开/合子菜单规则) + activeTool = nil + toolbar.closePopup() + } else { + // 切换到该工具并打开子菜单(作用于后续绘制的新标注; + // 匹配选中项时回显该标注的粗细/颜色或字号/颜色) + activeTool = button + toolbar.openPopup(for: button) + if matchesSelection { + if button == .text, selectedTextAnnotation >= 0 && selectedTextAnnotation < annotations.count { + scEchoFontIdx(annotations[selectedTextAnnotation].thickness) + scEchoColorIdx(annotations[selectedTextAnnotation].color) + } else if selectedAnnotation >= 0 && selectedAnnotation < annotations.count { + scEchoThickIdx(annotations[selectedAnnotation].thickness) + scEchoColorIdx(annotations[selectedAnnotation].color) + } + } + } + invalidateAll() + toolbar.refresh() + case .mosaic: + // 马赛克工具:切换激活态 + 开合三段子菜单(涂抹|框选|块大小|涂抹半径)。 + // 马赛克不可选中 → 永不匹配选中项;选项只影响后续绘制,点击即取消当前选中 + //(对齐 TB_Mosaic 分支 + 马赛克 popup 的 clearSel 语义)。 + if selectedAnnotation >= 0 || selectedTextAnnotation >= 0 { + let dirty = scUnionRect(selectedAnnotationDirtyRect(), selectedTextAnnotationDirtyRect()) + clearAnnotationSelection() + invalidate(dirty) + } + if activeTool == .mosaic { + activeTool = nil + toolbar.closePopup() + } else { + activeTool = .mosaic + toolbar.openPopup(for: .mosaic) + } + invalidateAll() + toolbar.refresh() + case .translate: + // 翻译:占位图标,无点击处理(与 Windows TB_Translate 行为一致) + break + case .longCapture: + // 长截图:进入长截图滚动捕获(隐藏覆盖层 → 灰蒙版 + 小地图 + 长截图 + // 工具栏接管;会话不销毁,完成/保存成功按成功收束、取消/ESC/abort 按失败收束)。 + // 实现见 ScreenshotLongCaptureMac.swift。 + beginLongCapture() + return + case .separator1, .separator2: + // 分隔线:不响应(保存已启用,见上方 .save 分支) + break + } + } + + /// 子菜单选择应用(矢量/文字命中码:+1..+3 粗细或字号 / -1..-8 颜色;马赛克命中码: + /// +1/+2 模式、+101.. 块大小、+201.. 涂抹半径)。 + /// 选中已有标注时回显后修改直接作用于该标注(先 Push 历史,对齐 Windows 就地修改语义); + /// 无选中时作用于后续绘制的新标注。 + /// - Parameter hit: 子菜单命中码 + func applyPopupSelection(_ hit: Int) { + guard isRunning, state == .confirmed, hit != 0, let tool = toolbar.popupTool else { return } + switch tool { + case .text: + // 文字子菜单:第一组为字号(对齐 popupTool == TB_Text 分支) + if hit > 0 && hit <= SC.fontSizes.count { + fontSizeIdx = hit - 1 + // 选中文字标注 → 修改其字号(先 Push 历史) + if selectedTextAnnotation >= 0 && selectedTextAnnotation < annotations.count { + let newSize = SC.fontSizes[fontSizeIdx] + if annotations[selectedTextAnnotation].thickness != newSize { + pushAnnotationHistory() + annotations[selectedTextAnnotation].thickness = newSize + invalidateAll() + } + } + } else if hit < 0 && -hit <= SC.colorPresets.count { + drawColorIdx = -hit - 1 + // 选中文字标注 → 修改其颜色(先 Push 历史) + if selectedTextAnnotation >= 0 && selectedTextAnnotation < annotations.count { + let newColor = SC.colorPresets[drawColorIdx] + if annotations[selectedTextAnnotation].color != newColor { + pushAnnotationHistory() + annotations[selectedTextAnnotation].color = newColor + invalidateAll() + } + } + } + case .mosaic: + // 马赛克专属三段子菜单(对齐 HitTestMosaicPopup 命中码与 clearSel 语义): + // 选项只影响后续绘制,均取消当前选中(脏区在清空前计算) + if hit == 1 || hit == 2 || (hit >= SC.mosaicHitSizeBase + 1 && hit < SC.mosaicHitSizeBase + 1 + SC.mosaicSizes.count) + || (hit >= SC.mosaicHitRadiusBase + 1 && hit < SC.mosaicHitRadiusBase + 1 + SC.mosaicRadii.count) { + let dirty = scUnionRect(selectedAnnotationDirtyRect(), selectedTextAnnotationDirtyRect()) + clearAnnotationSelection() + invalidate(dirty) + } + switch hit { + case 1: + mosaicRectMode = false // 涂抹模式 + case 2: + mosaicRectMode = true // 框选模式 + default: + if hit >= SC.mosaicHitSizeBase + 1, hit < SC.mosaicHitSizeBase + 1 + SC.mosaicSizes.count { + mosaicSizeIdx = hit - SC.mosaicHitSizeBase - 1 // 块大小 + } else if hit >= SC.mosaicHitRadiusBase + 1, hit < SC.mosaicHitRadiusBase + 1 + SC.mosaicRadii.count { + mosaicRadiusIdx = hit - SC.mosaicHitRadiusBase - 1 // 涂抹半径 + } + } + default: + // 矢量子菜单:第一组粗细 / 第二组颜色(选中矢量标注时就地修改,先 Push 历史) + if hit > 0 && hit <= SC.thickPresets.count { + drawThickIdx = hit - 1 + if selectedAnnotation >= 0 && selectedAnnotation < annotations.count { + let newThickness = SC.thickPresets[drawThickIdx] + if annotations[selectedAnnotation].thickness != newThickness { + pushAnnotationHistory() + annotations[selectedAnnotation].thickness = newThickness + invalidateAll() + } + } + } else if hit < 0 && -hit <= SC.colorPresets.count { + drawColorIdx = -hit - 1 + if selectedAnnotation >= 0 && selectedAnnotation < annotations.count { + let newColor = SC.colorPresets[drawColorIdx] + if annotations[selectedAnnotation].color != newColor { + pushAnnotationHistory() + annotations[selectedAnnotation].color = newColor + invalidateAll() + } + } + } + } + toolbar.refresh() + } + + // MARK: 选区状态机辅助 + + /// 进入确认态(wndproc_windows.cpp EnterConfirmed 移植):规范化选区、钳制虚拟屏边界、 + /// 补足最小尺寸(SC_MIN_SELECTION)、钳制圆角半径并切换状态。 + /// - Parameter rect: 候选选区(CG 全局坐标,未规范化) + private func enterConfirmed(_ rect: CGRect) { + var n = rect.standardized + // 约束到虚拟屏幕内(Windows 语义:仅收缩边界,固定端不移动) + if n.minX < virtualBounds.minX { n.origin.x = virtualBounds.minX } + if n.minY < virtualBounds.minY { n.origin.y = virtualBounds.minY } + if n.maxX > virtualBounds.maxX { n.size.width = virtualBounds.maxX - n.minX } + if n.maxY > virtualBounds.maxY { n.size.height = virtualBounds.maxY - n.minY } + // 最小尺寸保护(向右/下扩张) + if n.width < SC.minSelection { n.size.width = SC.minSelection } + if n.height < SC.minSelection { n.size.height = SC.minSelection } + selection = scIntegralRect(n) + resizeHandle = .none + hoveredCornerHandle = .none + selectionCornerRadius = scClampCornerRadius(selectionCornerRadius, selection) + state = .confirmed + invalidateAll() + // 工具栏:确认态默认回到纯选择模式(对齐 EnterConfirmed 的 activeTool 缺省 TB_Drag), + // 工具栏/子菜单浮层随状态机在 pumpTick 同步显隐与位置(首拍即出现)。 + if activeTool == nil { activeTool = .drag } + toolbar.syncVisibility(true) + toolbar.syncPlacement() + } + + /// 从按下快照 + (鼠标位移 + 键盘微调) 实时重算选区,并刷新放大镜焦点像素色 + /// (wndproc_windows.cpp ApplyResizeSelection 移植;MOUSEMOVE 与方向键共用保证一致)。 + /// - Parameter enforceMinSize: 拖拽中 false;松开时 true(沿活动端当前侧补足最小尺寸) + private func applyResizeSelection(enforceMinSize: Bool) { + let dx = (mouse.x - handleDragStartPoint.x) + kbDX + let dy = (mouse.y - handleDragStartPoint.y) + kbDY + selection = scResizeSelectionFromHandle(dragStartSelection, resizeHandle, dx, dy, + virtualBounds, annotationsContentBounds(), enforceMinSize) + // 放大镜焦点取活动手柄锚点(随活动边移动,键盘微调时鼠标不动也能跟随) + let anchor = scGetResizeHandleAnchor(resizeHandle, selection) + currentColor = samplePixelColor(at: anchor) ?? currentColor + invalidateAll() + } + + /// 确认态方向键整体平移(对齐 HandleSelectionNudgeKey 的 CS_Confirmed 分支):钳制虚拟屏。 + /// - Parameters: + /// - dx/dy: 位移(1px 或 Shift 10px) + private func nudgeSelection(dx: CGFloat, dy: CGFloat) { + var nl = selection.minX + dx + var nt = selection.minY + dy + if nl < virtualBounds.minX { nl = virtualBounds.minX } + if nt < virtualBounds.minY { nt = virtualBounds.minY } + if nl + selection.width > virtualBounds.maxX { nl = virtualBounds.maxX - selection.width } + if nt + selection.height > virtualBounds.maxY { nt = virtualBounds.maxY - selection.height } + selection = CGRect(origin: CGPoint(x: nl, y: nt), size: selection.size) + invalidateAll() + } + + /// 单击吸附退化链(对齐 Windows OnLButtonUp 单击分支):候选窗口(Z 序首个命中) + /// → 鼠标所在屏幕(MonitorFromPoint 等价)→ 虚拟屏兜底。 + /// - Parameter point: 鼠标 CG 全局坐标 + /// - Returns: 吸附矩形(CG 全局逻辑坐标) + private func snapRectAtPoint(_ point: CGPoint) -> CGRect { + let index = findWindowAtPoint(snapWindows, point) + if index >= 0 { + return snapWindows[index].rect + } + if let screen = screenContaining(point) { + return ScreenshotGeometry.cgFrame(of: screen) + } + return virtualBounds + } + + // MARK: 标注约束钩子 + + /// 标注内容包围盒钩子(对齐 Windows CalcAnnotationsBounds):返回已提交标注的内容 + /// 包围盒(绝对 CG 全局坐标),供 scResizeSelectionFromHandle 的内容约束分支使用 + /// (选区不可缩小到裁掉标注内容)。无标注时返回 nil(无内容约束)。 + private func annotationsContentBounds() -> CGRect? { + return scCalcAnnotationsBounds(annotations) + } + + /// 整体拖动可用性钩子:已有标注时禁用(标注与背景共享绝对坐标,整体拖动会错位, + /// 对齐 Windows 标注存在时禁止 CS_Moving 的行为)。 + private func canDragSelection() -> Bool { + return annotations.isEmpty + } + + /// 选中标注的脏区矩形(选中视觉变化时局部失效:包围盒外扩手柄余量,覆盖 + /// 虚线框与手柄抗锯齿;未选中返回 nil)。(跨文件共享:文字选中切换清脏区复用。) + func selectedAnnotationDirtyRect() -> CGRect? { + guard selectedAnnotation >= 0 && selectedAnnotation < annotations.count else { return nil } + let box = scMeasureAnnotationBounds(annotations[selectedAnnotation]) + return scInflate(box, SC.handleMargin) + } + + // MARK: 光标(对齐 Windows OnSetCursor 的基础子集) + + /// 按状态与命中结果切换系统光标(对齐 Windows OnSetCursor 分支次序): + /// 马赛克涂抹圆环(涂抹模式 + 选区内)→ 文字态 IBEAM → 绘制态十字 → + /// 标注缩放手柄方向 → 标注拖动/悬停手型 → 确认态兜底(选区内工具十字/有标注箭头/可整体 + /// 拖动手型/选区外箭头)。工具栏/子菜单区域的光标由 ScreenshotToolbarController.tick + /// 接管(独立窗口),接管范围内不调用本函数。 + func updateCursor() { + guard isRunning else { return } + + // 马赛克涂抹模式专属圆环光标(确认态/绘制态 + 选区内;对齐 OnSetCursor 的 + // mosaicBrushCursors 分支——该分支先于文字/绘制/确认态判定) + if activeTool == .mosaic && !mosaicRectMode + && (state == .confirmed || state == .drawing) + && selection.contains(mouse) { + currentMosaicCursor().set() + return + } + // 文字编辑中:I-beam 光标(对齐 CS_TextEditing 分支) + if state == .textEditing { + NSCursor.iBeam.set() + return + } + + let cursor: NSCursor + switch state { + case .idle, .selecting: + cursor = .crosshair + case .resizing: + cursor = scHandleCursor(resizeHandle) + case .moving: + cursor = .closedHand // Windows IDC_SIZEALL(拖动中) + case .drawing: + cursor = .crosshair // Windows IDC_CROSS(标注绘制中;马赛克框选同十字) + case .confirmed: + // 标注拖拽/缩放中的光标(对齐 resizingAnnotation/draggingAnnotation/ + // draggingTextAnnotation 分支:手柄方向光标 / 四向箭头) + if resizingAnnotation >= 0 && resizingAnnotation < annotations.count { + cursor = scHandleCursor(annotationResizeHandle) + } else if draggingAnnotation >= 0 || draggingTextAnnotation >= 0 { + cursor = .openHand // Windows IDC_SIZEALL(标注/文字拖动中) + } else { + let handle = scHitTestHandle(mouse, selection, SC.handleSize) + if handle != .none { + cursor = scHandleCursor(handle) + } else { + let corner = scHitTestCornerKnob(mouse, selection, SC.handleSize, + SC.cornerKnobInset, selectionCornerRadius) + if corner != .none { + cursor = scHandleCursor(corner) + } else if selectedAnnotation >= 0 && selectedAnnotation < annotations.count + && activeTool != .drag { + // 已选中标注的手柄(箭头端点/矩形圆 8 手柄)→ 对应光标;标注悬停 → 四向 + //(对齐 OnSetCursor 的选中标注手柄/悬停分支) + let annHandle = scHitTestAnnotationHandle(annotations[selectedAnnotation], + mouse, SC.handleSize) + if annHandle != .none { + cursor = scHandleCursor(annHandle) + } else if scHitTestAnnotation(annotations, mouse) >= 0 { + cursor = .openHand // Windows IDC_SIZEALL(标注可拖动/选中) + } else { + cursor = confirmedFallbackCursor() + } + } else if scHitTestAnnotation(annotations, mouse) >= 0 { + cursor = .openHand // 悬停任意标注(含文字)即可选中/拖动 + } else { + cursor = confirmedFallbackCursor() + } + } + } + default: + cursor = .arrow + } + cursor.set() + } + + /// 确认态选区内/外的兜底光标(对齐 OnSetCursor 末段):选区内矢量/文字/马赛克工具激活 + /// → 十字;有标注内容 → 箭头(整体拖动禁用);否则手型(可整体拖动);选区外箭头。 + private func confirmedFallbackCursor() -> NSCursor { + if selection.contains(mouse) { + if let tool = activeTool, tool.isVectorTool || tool == .text || tool == .mosaic { + return .crosshair + } + if !annotations.isEmpty { + return .arrow + } + return .openHand // Windows IDC_SIZEALL(可整体拖动) + } + return .arrow + } +} + +/// 手柄对应的系统光标(Windows HandleCursor 移植)。 +/// macOS 差异:系统无对角 resize 光标(Windows IDC_SIZENWSE/IDC_SIZENESW), +/// 四角与圆角手柄以十字光标替代。 +/// - Parameter handle: 手柄 +/// - Returns: 对应 NSCursor +private func scHandleCursor(_ handle: ScreenshotResizeHandle) -> NSCursor { + switch handle { + case .left, .right: + return .resizeLeftRight // IDC_SIZEWE + case .top, .bottom: + return .resizeUpDown // IDC_SIZENS + case .arrowStart, .arrowEnd: + return .openHand // IDC_SIZEALL(箭头端点拖拽,固定四向箭头) + case .topLeft, .bottomRight, .topRight, .bottomLeft, + .cornerTL, .cornerTR, .cornerBL, .cornerBR: + return .crosshair // 对角 resize 光标的 macOS 替代 + default: + return .arrow + } +} + +/// 矩形各分量取整(对齐 Windows 的 int RECT 语义:坐标恒为整数点)。 +private func scIntegralRect(_ rect: CGRect) -> CGRect { + return CGRect(x: rect.minX.rounded(), y: rect.minY.rounded(), + width: rect.width.rounded(), height: rect.height.rounded()) +} diff --git a/src/screenshot/macos/ScreenshotPaintMac.swift b/src/screenshot/macos/ScreenshotPaintMac.swift new file mode 100644 index 0000000..8155268 --- /dev/null +++ b/src/screenshot/macos/ScreenshotPaintMac.swift @@ -0,0 +1,785 @@ +import Foundation +import AppKit +import CoreGraphics + +// MARK: - 覆盖层常量(Windows 出处集中标注) +// +// 全部常量与 Windows src/screenshot/internal.h 及 overlay_ui_windows.cpp 对齐。 +// macOS 差异说明:Windows 按 dpiScale 缩放这些基准值(1080p → 4K 下手柄/面板同步放大); +// macOS 的逻辑点坐标系本身已与分辨率无关(NSScreen.backingScaleFactor 是像素密度而非 +// UI 缩放),因此常量恒以逻辑点使用,Retina 与低分屏下视觉大小一致。 +enum SC { + /// 最小选区尺寸(internal.h: SC_MIN_SELECTION) + static let minSelection: CGFloat = 10 + /// resize 手柄边长 / 命中半宽(internal.h: SC_HANDLE_SIZE) + static let handleSize: CGFloat = 10 + /// 圆角拖拽手柄距选区角的静止内缩距离(internal.h: SC_CORNER_KNOB_INSET) + static let cornerKnobInset: CGFloat = 18 + /// 倒角手柄"靠近"感应余量:命中框外再扩此距离即显示该角手柄(internal.h: SC_CORNER_PROXIMITY) + static let cornerProximity: CGFloat = 14 + /// 选区外遮罩强度(internal.h: SC_MASK_ALPHA,微信风格:选区内清晰、外暗化) + static let maskAlpha: CGFloat = 120.0 / 255.0 + /// 放大镜面板边长(internal.h: SC_PANEL_WIDTH/HEIGHT) + static let panelSize: CGFloat = 140 + /// 面板内放大镜区高度(internal.h: SC_MAGNIFIER_HEIGHT) + static let magnifierHeight: CGFloat = 74 + /// 面板与鼠标/选区的间距(internal.h: SC_PANEL_MARGIN) + static let panelMargin: CGFloat = 15 + /// 面板/尺寸标签圆角(internal.h: SC_PANEL_CORNER_RADIUS) + static let panelCornerRadius: CGFloat = 8 + /// 放大镜放大倍数(internal.h: SC_ZOOM_FACTOR) + static let zoomFactor: CGFloat = 4 + /// 面板内边距(overlay_ui_windows.cpp CalcPanelMetrics.borderPad 基准) + static let panelBorderPad: CGFloat = 2 + /// 面板文字内边距(CalcPanelMetrics.labelPad 基准) + static let panelLabelPad: CGFloat = 6 + /// 尺寸标签/信息面板字号(CalcPanelMetrics fontPx 基准 12) + static let fontPx: CGFloat = 12 + /// 尺寸标签水平内边距(CalcPanelMetrics sizeLabelPadX 基准) + static let sizeLabelPadX: CGFloat = 12 + /// 尺寸标签垂直内边距(CalcPanelMetrics sizeLabelPadY 基准) + static let sizeLabelPadY: CGFloat = 4 + /// 尺寸标签与参考矩形间距(CalcPanelMetrics sizeLabelGap 基准) + static let sizeLabelGap: CGFloat = 5 + + /// 强调蓝:选区/高亮/手柄/准星(internal.h: SC_THEME_ACCENT_BLUE RGB(0x00,0x88,0xFF)) + static let accentBlue = NSColor(srgbRed: 0x00 / 255.0, green: 0x88 / 255.0, blue: 0xFF / 255.0, alpha: 1.0) + /// 面板/标签底色(session_windows.cpp SCGdiResources::Init 的 bgBrush RGB(52,52,53)) + static let panelBg = NSColor(srgbRed: 52.0 / 255.0, green: 52.0 / 255.0, blue: 53.0 / 255.0, alpha: 1.0) + /// 面板/标签描边(borderPen RGB(102,102,102)) + static let panelBorder = NSColor(srgbRed: 102.0 / 255.0, green: 102.0 / 255.0, blue: 102.0 / 255.0, alpha: 1.0) + + /// 手柄脏区扩张余量(overlay_ui_windows.cpp CalcHandleMetrics.handleMargin = handleSize/2 + 4) + static var handleMargin: CGFloat { handleSize / 2 + 4 } + + /// 保存对话框模态期间浮层族的临时降级层级(对齐 Windows 弹保存对话框前摘除 + /// TOPMOST——output_windows.cpp PromptSaveFilePath 的 HWND_NOTOPMOST)。取 + /// NSModalPanelWindowLevel − 1:模态保存面板之下、普通应用窗口之上,覆盖层与工具栏 + /// 保持可见、仅对话框浮于其上(用户可见行为最接近 Windows)。 + static let saveModalDuckLevel = NSWindow.Level(rawValue: NSWindow.Level.modalPanel.rawValue - 1) +} + +// MARK: - 几何辅助(对齐 Windows overlay_ui_windows.cpp 的 RECT 工具族) + +/// 两矩形并集的外包矩形;任一方为 nil/空时返回另一方(对齐 UnionRectSafe 的零矩形安全语义)。 +func scUnionRect(_ a: CGRect?, _ b: CGRect?) -> CGRect? { + switch (a, b) { + case (nil, nil): return nil + case (let x, nil): return x + case (nil, let y): return y + case (let x?, let y?): return x.union(y) + } +} + +/// 矩形外扩 margin(对齐 InflateRectBy)。 +func scInflate(_ r: CGRect, _ margin: CGFloat) -> CGRect { + return r.insetBy(dx: -margin, dy: -margin) +} + +/// 点是否在矩形内(对齐 PointInRect 的 [left,right) 半开区间语义,CGRect.contains 同约定)。 +func scPointInRect(_ p: CGPoint, _ r: CGRect) -> Bool { + return p.x >= r.minX && p.x < r.maxX && p.y >= r.minY && p.y < r.maxY +} + +// MARK: - 手柄命中与选区调整(对齐 Windows overlay_ui_windows.cpp / wndproc_windows.cpp) + +/// 计算 8 个 resize 手柄的判定命中(overlay_ui_windows.cpp HitTestHandle 移植)。 +/// - Parameters: +/// - point: 鼠标 CG 全局坐标 +/// - selection: 当前选区(CG 全局坐标) +/// - handleSize: 命中半宽(SC.handleSize) +/// - Returns: 命中的手柄;未命中返回 .none +func scHitTestHandle(_ point: CGPoint, _ selection: CGRect, _ handleSize: CGFloat) -> ScreenshotResizeHandle { + let cx = selection.midX + let cy = selection.midY + // 8 个手柄的判定矩形(顺序与 Windows ResizeHandle 一致) + let tests: [(CGPoint, ScreenshotResizeHandle)] = [ + (CGPoint(x: selection.minX, y: cy), .left), + (CGPoint(x: selection.maxX, y: cy), .right), + (CGPoint(x: cx, y: selection.minY), .top), + (CGPoint(x: cx, y: selection.maxY), .bottom), + (CGPoint(x: selection.minX, y: selection.minY), .topLeft), + (CGPoint(x: selection.maxX, y: selection.minY), .topRight), + (CGPoint(x: selection.minX, y: selection.maxY), .bottomLeft), + (CGPoint(x: selection.maxX, y: selection.maxY), .bottomRight), + ] + for (anchor, handle) in tests { + let box = CGRect(x: anchor.x - handleSize, y: anchor.y - handleSize, + width: handleSize * 2, height: handleSize * 2) + if scPointInRect(point, box) { return handle } + } + return .none +} + +/// 圆角手柄中心位置:选区四角内侧,沿各自对角线内移 d = clamp(inset + radius, 0, maxR) +/// (overlay_ui_windows.cpp CornerRadiusHandleCenter 移植;radius 增大时四角同步向中心滑动)。 +/// - Parameters: +/// - selection: 选区矩形 +/// - inset: 静止内缩距离(SC.cornerKnobInset) +/// - radius: 当前选区圆角半径 +/// - corner: 目标角手柄 +/// - Returns: 手柄中心(CG 全局坐标);corner 非角手柄时返回选区左上角 +func scCornerKnobCenter(_ selection: CGRect, _ inset: CGFloat, _ radius: CGFloat, + _ corner: ScreenshotResizeHandle) -> CGPoint { + let maxR = max(0, min(selection.width, selection.height) / 2) + var d = inset + radius + if d > maxR { d = maxR } // 不越过中心 / 不出选区 + if d < 0 { d = 0 } + switch corner { + case .cornerTL: return CGPoint(x: selection.minX + d, y: selection.minY + d) + case .cornerTR: return CGPoint(x: selection.maxX - d, y: selection.minY + d) + case .cornerBL: return CGPoint(x: selection.minX + d, y: selection.maxY - d) + case .cornerBR: return CGPoint(x: selection.maxX - d, y: selection.maxY - d) + default: return CGPoint(x: selection.minX + d, y: selection.minY + d) + } +} + +/// 命中测试圆角手柄(overlay_ui_windows.cpp HitTestCornerRadiusHandle 移植)。 +/// 命中框半宽沿用 handleSize;手柄位置随 radius 移动,故命中也按 radius 计算。 +func scHitTestCornerKnob(_ point: CGPoint, _ selection: CGRect, _ handleSize: CGFloat, + _ inset: CGFloat, _ radius: CGFloat) -> ScreenshotResizeHandle { + for corner in ScreenshotResizeHandle.cornerCases { + let center = scCornerKnobCenter(selection, inset, radius, corner) + let box = CGRect(x: center.x - handleSize, y: center.y - handleSize, + width: handleSize * 2, height: handleSize * 2) + if scPointInRect(point, box) { return corner } + } + return .none +} + +/// 找出鼠标"靠近"的圆角手柄(overlay_ui_windows.cpp FindNearestCornerRadiusHandle 移植): +/// 感应半宽 = 命中半宽 + proximityMargin,切比雪夫距离取最近的一个角。 +func scFindNearestCornerKnob(_ point: CGPoint, _ selection: CGRect, _ handleSize: CGFloat, + _ inset: CGFloat, _ radius: CGFloat, + _ proximityMargin: CGFloat) -> ScreenshotResizeHandle { + let sense = handleSize + proximityMargin + var best: ScreenshotResizeHandle = .none + var bestDist = CGFloat.greatestFiniteMagnitude + for corner in ScreenshotResizeHandle.cornerCases { + let center = scCornerKnobCenter(selection, inset, radius, corner) + let dx = abs(point.x - center.x) + let dy = abs(point.y - center.y) + if dx <= sense && dy <= sense { + let dist = max(dx, dy) + if dist < bestDist { + bestDist = dist + best = corner + } + } + } + return best +} + +/// 取调整手柄在选区上的锚点(wndproc_windows.cpp GetResizeHandleAnchor 移植): +/// 左右手柄取边中点,顶/底取中点,四角取角点;作为放大镜焦点。 +func scGetResizeHandleAnchor(_ handle: ScreenshotResizeHandle, _ selection: CGRect) -> CGPoint { + switch handle { + case .left: return CGPoint(x: selection.minX, y: selection.midY) + case .right: return CGPoint(x: selection.maxX, y: selection.midY) + case .top: return CGPoint(x: selection.midX, y: selection.minY) + case .bottom: return CGPoint(x: selection.midX, y: selection.maxY) + case .topLeft: return CGPoint(x: selection.minX, y: selection.minY) + case .topRight: return CGPoint(x: selection.maxX, y: selection.minY) + case .bottomLeft: return CGPoint(x: selection.minX, y: selection.maxY) + case .bottomRight: return CGPoint(x: selection.maxX, y: selection.maxY) + default: return CGPoint(x: selection.midX, y: selection.midY) + } +} + +/// 钳制选区圆角半径到 [0, min(w,h)/2](wndproc_windows.cpp ClampCornerRadius 移植)。 +/// 选区尺寸变化(确认/调整/移动)后调用,避免半径越界导致手柄命中与渲染不一致。 +func scClampCornerRadius(_ radius: CGFloat, _ selection: CGRect) -> CGFloat { + let maxR = max(0, min(selection.width, selection.height) / 2) + return min(max(radius, 0), maxR) +} + +/// 约束单轴 resize 的活动端坐标(wndproc_windows.cpp ConstrainResizeActiveCoordinate 移植)。 +/// 固定端始终保持按下时的位置;已有标注时优先保证内容不被裁掉(已接入标注包围盒); +/// 松开时(enforceMinSize)按活动端当前所在侧补足最小尺寸,穿越后不带动固定端漂移。 +/// - Parameters: +/// - rawActive/originalActive: 当前候选值与按下时的活动端坐标(同轴) +/// - fixed: 固定端坐标(同轴) +/// - screenMin/screenMax: 虚拟屏幕在该轴上的边界 +/// - contentMin/contentMax: 标注内容包围盒在该轴上的边界(nil = 无内容约束,区域截图恒 nil) +/// - enforceMinSize: 仅松开时传 true(沿最终方向补足最小尺寸) +/// - Returns: 约束后的活动端坐标 +private func scConstrainResizeActiveCoordinate(rawActive: CGFloat, originalActive: CGFloat, fixed: CGFloat, + screenMin: CGFloat, screenMax: CGFloat, + contentMin: CGFloat?, contentMax: CGFloat?, + enforceMinSize: Bool) -> CGFloat { + var active = max(screenMin, min(rawActive, screenMax)) + + if let cMin = contentMin, let cMax = contentMax { + if fixed <= cMin { + // 内容位于固定端低侧:活动端必须覆盖内容高边,不能穿越后把内容留在选区外。 + active = max(active, cMax) + } else if fixed >= cMax { + // 内容位于固定端高侧:活动端必须覆盖内容低边。 + active = min(active, cMin) + } else { + // 固定端落在内容内部时不存在可完整覆盖内容的单侧区间,保留按下时活动端。 + active = originalActive + } + active = max(screenMin, min(active, screenMax)) + } + + if enforceMinSize { + // 活动端恰好落在固定端时沿按下时的方向补足,避免释放后方向不确定。 + let onLowSide = active < fixed || (active == fixed && originalActive < fixed) + if onLowSide { + active = min(active, fixed - SC.minSelection) + } else { + active = max(active, fixed + SC.minSelection) + } + // 固定端靠近虚拟屏幕边缘时目标侧可能不足最小尺寸;边界优先且固定端不动。 + active = max(screenMin, min(active, screenMax)) + } + + return active +} + +/// 从鼠标按下时的选区快照计算本帧 resize 结果(wndproc_windows.cpp ResizeSelectionFromHandle 移植)。 +/// 每个活动轴只更新对应手柄端,固定边/固定对角点始终取 startSelection;活动端可穿过固定端, +/// 最后规范化。contentBounds 非空时限制活动端以保留已有标注(由会话传入真实标注包围盒)。 +/// - Parameters: +/// - startSelection: 按下时的选区快照(CG 全局坐标) +/// - handle: 活动手柄 +/// - dx/dy: 鼠标位移 + 键盘微调累计 +/// - virtualBounds: 虚拟屏边界(钳制) +/// - contentBounds: 标注内容包围盒(nil = 无内容约束) +/// - enforceMinSize: 仅松开时传 true(沿最终方向补足最小尺寸) +/// - Returns: 本帧选区矩形(已规范化) +func scResizeSelectionFromHandle(_ startSelection: CGRect, _ handle: ScreenshotResizeHandle, + _ dx: CGFloat, _ dy: CGFloat, _ virtualBounds: CGRect, + _ contentBounds: CGRect?, _ enforceMinSize: Bool) -> CGRect { + var resized = startSelection + let standardContent = contentBounds?.standardized + + let movesLeft = handle == .left || handle == .topLeft || handle == .bottomLeft + let movesRight = handle == .right || handle == .topRight || handle == .bottomRight + let movesTop = handle == .top || handle == .topLeft || handle == .topRight + let movesBottom = handle == .bottom || handle == .bottomLeft || handle == .bottomRight + + // 注意 RECT↔CGRect 语义差:Windows 直接改 left/top 而保持 right/bottom(宽度随之收缩); + // CGRect 改 origin 恒保持 size,故活动端在左/上时须同时收缩 size(固定边 = startSelection)。 + if movesLeft { + let active = scConstrainResizeActiveCoordinate( + rawActive: startSelection.minX + dx, originalActive: startSelection.minX, fixed: startSelection.maxX, + screenMin: virtualBounds.minX, screenMax: virtualBounds.maxX, + contentMin: standardContent?.minX, contentMax: standardContent?.maxX, + enforceMinSize: enforceMinSize) + resized.origin.x = active + resized.size.width = startSelection.maxX - active + } else if movesRight { + resized.size.width = scConstrainResizeActiveCoordinate( + rawActive: startSelection.maxX + dx, originalActive: startSelection.maxX, fixed: startSelection.minX, + screenMin: virtualBounds.minX, screenMax: virtualBounds.maxX, + contentMin: standardContent?.minX, contentMax: standardContent?.maxX, + enforceMinSize: enforceMinSize) - resized.minX + } + + if movesTop { + let active = scConstrainResizeActiveCoordinate( + rawActive: startSelection.minY + dy, originalActive: startSelection.minY, fixed: startSelection.maxY, + screenMin: virtualBounds.minY, screenMax: virtualBounds.maxY, + contentMin: standardContent?.minY, contentMax: standardContent?.maxY, + enforceMinSize: enforceMinSize) + resized.origin.y = active + resized.size.height = startSelection.maxY - active + } else if movesBottom { + resized.size.height = scConstrainResizeActiveCoordinate( + rawActive: startSelection.maxY + dy, originalActive: startSelection.maxY, fixed: startSelection.minY, + screenMin: virtualBounds.minY, screenMax: virtualBounds.maxY, + contentMin: standardContent?.minY, contentMax: standardContent?.maxY, + enforceMinSize: enforceMinSize) - resized.minY + } + + return resized.standardized +} + +// MARK: - 浮层位置计算(对齐 Windows overlay_ui_windows.cpp Calc* 系列) + +/// 计算放大镜面板位置(overlay_ui_windows.cpp CalcPanelPosition 移植): +/// 优先鼠标右下,超出虚拟屏则翻转,仍越界贴屏边。 +/// - Parameters: +/// - mouse: 鼠标 CG 全局坐标 +/// - virtual: 虚拟屏并集(CG 全局逻辑坐标) +/// - Returns: 面板矩形(CG 全局逻辑坐标,SC.panelSize 边长) +func scCalcPanelRect(_ mouse: CGPoint, _ virtual: CGRect) -> CGRect { + var px = mouse.x + SC.panelMargin + var py = mouse.y + SC.panelMargin + if px + SC.panelSize > virtual.maxX { px = mouse.x - SC.panelSize - SC.panelMargin } + if py + SC.panelSize > virtual.maxY { py = mouse.y - SC.panelSize - SC.panelMargin } + if px < virtual.minX { px = virtual.minX + SC.panelMargin } + if py < virtual.minY { py = virtual.minY + SC.panelMargin } + return CGRect(x: px, y: py, width: SC.panelSize, height: SC.panelSize) +} + +/// 调整选区时放大镜面板位置(overlay_ui_windows.cpp CalcResizePanelPosition 移植): +/// 放在被拖手柄的"外侧"避免遮挡选区;外侧放不下翻对侧,对侧仍放不下贴屏边。 +/// - Parameters: +/// - handle: 活动手柄 +/// - selection: 当前选区 +/// - virtual: 虚拟屏并集 +/// - Returns: 面板矩形(CG 全局逻辑坐标) +func scCalcResizePanelRect(_ handle: ScreenshotResizeHandle, _ selection: CGRect, _ virtual: CGRect) -> CGRect { + let movesL = handle == .left || handle == .topLeft || handle == .bottomLeft + let movesR = handle == .right || handle == .topRight || handle == .bottomRight + let movesT = handle == .top || handle == .topLeft || handle == .topRight + let movesB = handle == .bottom || handle == .bottomLeft || handle == .bottomRight + var px: CGFloat + var py: CGFloat + if movesL { + px = selection.minX - SC.panelSize - SC.panelMargin + } else if movesR { + px = selection.maxX + SC.panelMargin + } else { + px = selection.midX - SC.panelSize / 2 + } + if movesT { + py = selection.minY - SC.panelSize - SC.panelMargin + } else if movesB { + py = selection.maxY + SC.panelMargin + } else { + py = selection.midY - SC.panelSize / 2 + } + // 外侧放不下 -> 翻到对侧;对侧仍放不下 -> 贴屏边 + if px < virtual.minX { px = selection.maxX + SC.panelMargin } + if px + SC.panelSize > virtual.maxX { px = selection.minX - SC.panelSize - SC.panelMargin } + if px < virtual.minX { px = virtual.minX + SC.panelMargin } + if px + SC.panelSize > virtual.maxX { px = virtual.maxX - SC.panelSize - SC.panelMargin } + if py < virtual.minY { py = selection.maxY + SC.panelMargin } + if py + SC.panelSize > virtual.maxY { py = selection.minY - SC.panelSize - SC.panelMargin } + if py < virtual.minY { py = virtual.minY + SC.panelMargin } + if py + SC.panelSize > virtual.maxY { py = virtual.maxY - SC.panelSize - SC.panelMargin } + return CGRect(x: px, y: py, width: SC.panelSize, height: SC.panelSize) +} + +/// 计算尺寸标签矩形(overlay_ui_windows.cpp DrawSizeLabel 的定位部分抽取为纯几何函数, +/// 绘制与失效共用同一几何,保证脏区与实际绘制一致):默认在参考矩形上方,放不下翻到 +/// 参考矩形内左上角,再钳制在虚拟屏内。 +/// - Parameters: +/// - text: 标签文本(如 "1280 × 800") +/// - refRect: 参考矩形(窗口/选区,CG 全局坐标) +/// - virtual: 虚拟屏并集 +/// - Returns: 标签矩形;文本为空返回 nil +func scCalcSizeLabelRect(_ text: String, _ refRect: CGRect, _ virtual: CGRect) -> CGRect? { + guard !text.isEmpty else { return nil } + let textSize = scLabelAttributedString(text).size() + let labelW = ceil(textSize.width) + SC.sizeLabelPadX * 2 + let labelH = ceil(textSize.height) + SC.sizeLabelPadY + + var lx = refRect.minX + var ly = refRect.minY - labelH - SC.sizeLabelGap + if ly < virtual.minY { + lx = refRect.minX + SC.sizeLabelGap + ly = refRect.minY + SC.sizeLabelGap + if lx + labelW > virtual.maxX { lx = virtual.maxX - labelW - SC.sizeLabelGap } + if ly + labelH > virtual.maxY { ly = virtual.maxY - labelH - SC.sizeLabelGap } + if lx + labelW > refRect.maxX { lx = refRect.maxX - labelW - SC.sizeLabelGap } + if ly + labelH > refRect.maxY { ly = refRect.maxY - labelH - SC.sizeLabelGap } + } + if lx < virtual.minX { lx = virtual.minX } + if ly < virtual.minY { ly = virtual.minY } + if lx + labelW > virtual.maxX { lx = virtual.maxX - labelW } + if ly + labelH > virtual.maxY { ly = virtual.maxY - labelH } + return CGRect(x: lx, y: ly, width: labelW, height: labelH) +} + +/// 尺寸标签/信息面板共用的白色文字属性串(12px 系统字体,对齐 Windows smallFont 基准)。 +func scLabelAttributedString(_ text: String) -> NSAttributedString { + return NSAttributedString(string: text, attributes: [ + .font: NSFont.systemFont(ofSize: SC.fontPx), + .foregroundColor: NSColor.white, + ]) +} + +// MARK: - 覆盖层绘制(ScreenshotOverlaySession 的绘制扩展;对齐 overlay_paint_windows.cpp) + +extension ScreenshotOverlaySession { + /// 覆盖层绘制主入口(由每个覆盖层 NSView 的 draw(_:) 调用)。 + /// 流程对齐 Windows OnPaint:先按脏区从常驻底图 CGImage 裁剪恢复背景(拖拽全程禁止 + /// 整图重采样),再按状态绘制覆盖层内容(AppKit 已把上下文裁剪到脏区)。 + /// - Parameters: + /// - ctx: 视图 CG 上下文(视图已翻转,本地坐标与 CG 同向、Y 向下) + /// - view: 当前绘制的覆盖层视图 + /// - dirtyLocal: 脏区(视图本地坐标) + func paint(context ctx: CGContext, view: OverlayScreenshotView, dirtyLocal: CGRect) { + // 本帧脏区 → CG 全局逻辑坐标(本地与 CG 同向,平移换算),钳制到本屏与虚拟屏 + let dirtyCG = dirtyLocal + .offsetBy(dx: view.cgOrigin.x, dy: view.cgOrigin.y) + .intersection(view.cgFrame) + .intersection(virtualBounds) + guard !dirtyCG.isNull, dirtyCG.width > 0, dirtyCG.height > 0 else { return } + + // 1) 背景恢复:按脏区从底图裁剪绘制 + drawBaseImage(ctx: ctx, view: view, dirtyCG: dirtyCG) + + // 2) 覆盖层内容(按状态) + switch state { + case .idle: + paintIdleOverlay(ctx: ctx, view: view) + case .selecting: + paintSelectingOverlay(ctx: ctx, view: view) + case .confirmed, .resizing, .moving, .drawing, .textEditing: + paintConfirmedOverlay(ctx: ctx, view: view) + default: + break + } + } + + // ---- 底图 ---- + + /// 把底图 CGImage 中与脏区对应的部分绘制到视图(ScreenshotPaint 底图恢复步骤)。 + /// 脏区映射为底图物理像素矩形 → cropping 裁剪(写时复制,无整图重采样)→ 1:1 绘制。 + private func drawBaseImage(ctx: CGContext, view: OverlayScreenshotView, dirtyCG: CGRect) { + let scale = baseFrame.scale + let origin = baseFrame.origin + let imgW = CGFloat(baseFrame.image.width) + let imgH = CGFloat(baseFrame.image.height) + // 脏区 → 底图物理像素(左上原点),四边钳制在位图内 + let px0 = max(0, ((dirtyCG.minX - origin.x) * scale).rounded(.down)) + let py0 = max(0, ((dirtyCG.minY - origin.y) * scale).rounded(.down)) + let px1 = min(imgW, ((dirtyCG.maxX - origin.x) * scale).rounded(.up)) + let py1 = min(imgH, ((dirtyCG.maxY - origin.y) * scale).rounded(.up)) + guard px1 > px0, py1 > py0 else { return } + + guard let cropped = baseFrame.image.cropping(to: CGRect(x: px0, y: py0, width: px1 - px0, height: py1 - py0)) else { return } + // 物理裁剪区左上角对应的 CG 点 → 视图本地坐标(视图与 CG 同向,平移即可) + let dstRect = CGRect( + x: (origin.x + px0 / scale) - view.cgOrigin.x, + y: (origin.y + py0 / scale) - view.cgOrigin.y, + width: (px1 - px0) / scale, + height: (py1 - py0) / scale) + ctx.interpolationQuality = .none // 同分辨率 blit(单屏等 scale 时物理像素 1:1 对齐) + scDrawCGImage(ctx, cropped, in: dstRect) + } + + /// 在已翻转(top-left 原点)的视图上下文中绘制 CGImage。 + /// CGImage 行序为 top-down 而 CGContextDrawImage 按未翻转坐标绘制,需翻转 Y 轴避免上下颠倒。 + /// (跨文件共享:马赛克 base 揭示与导出合成复用。) + func scDrawCGImage(_ ctx: CGContext, _ image: CGImage, in rect: CGRect) { + ctx.saveGState() + ctx.translateBy(x: rect.midX, y: rect.midY) + ctx.scaleBy(x: 1.0, y: -1.0) + ctx.draw(image, in: CGRect(x: -rect.width / 2, y: -rect.height / 2, width: rect.width, height: rect.height)) + ctx.restoreGState() + } + + // ---- Idle 态(hover 高亮 + 尺寸标签 + 放大镜)---- + + /// Idle 态绘制(对齐 OnPaint CS_Idle 分支):hover 窗口 3px 蓝框高亮 + 尺寸标签(W×H) + /// + 跟随放大镜面板;无命中窗口时高亮/标注鼠标所在屏幕(Windows 的 MonitorFromPoint 退化)。 + private func paintIdleOverlay(ctx: CGContext, view: OverlayScreenshotView) { + let rects = idleOverlayRects() + if let highlight = rects.highlight { + scStrokeRect(ctx, highlight.offsetBy(dx: -view.cgOrigin.x, dy: -view.cgOrigin.y), + color: SC.accentBlue, width: 3) // 对齐 DrawWindowHighlight 的 3px highlightPen + } + if let label = rects.label { + paintSizeLabel(ctx: ctx, view: view, rect: label, text: rects.labelText) + } + paintInfoPanel(ctx: ctx, view: view, panelRect: rects.panel, + focus: mouse, positionText: "\(Int(mouse.x.rounded())), \(Int(mouse.y.rounded()))") + } + + // ---- Selecting 态(蒙版 + 选区边框 + 尺寸标签 + 放大镜)---- + + /// Selecting 态绘制(对齐 OnPaint 非 confirmedMode 分支):暗化蒙版(直角)+ 1px 蓝选区框 + /// + 尺寸标签(W×H)+ 跟随放大镜面板。 + private func paintSelectingOverlay(ctx: CGContext, view: OverlayScreenshotView) { + let dragRect = currentDragRect() + paintDimMask(ctx: ctx, view: view, selection: dragRect, radius: selectionCornerRadius) + // 选区边框(对齐 DrawSelection 的 selectionPen 1px 蓝) + scStrokeRect(ctx, dragRect.offsetBy(dx: -view.cgOrigin.x, dy: -view.cgOrigin.y), + color: SC.accentBlue, width: 1) + let text = "\(Int(dragRect.width.rounded())) × \(Int(dragRect.height.rounded()))" + if let labelRect = scCalcSizeLabelRect(text, dragRect, virtualBounds) { + paintSizeLabel(ctx: ctx, view: view, rect: labelRect, text: text) + } + paintInfoPanel(ctx: ctx, view: view, panelRect: scCalcPanelRect(mouse, virtualBounds), + focus: mouse, positionText: "\(Int(mouse.x.rounded())), \(Int(mouse.y.rounded()))") + } + + // ---- 确认/调整/移动态 ---- + + /// 确认态族绘制(对齐 OnPaint confirmedMode 分支):蒙版(含圆角角帽)+ 马赛克揭示层 + /// + 已提交/进行中标注 + 选中标注视觉 + 确认边框 + 8 个 resize 手柄 + 圆角手柄 + /// (靠近/拖拽时)+ 文字编辑层(编辑态)+ Resizing 标准手柄时的放大镜。 + /// 绘制/整体拖动中工具栏保持显示(独立浮层窗口,由 pumpTick 同步显隐),调整中隐藏。 + private func paintConfirmedOverlay(ctx: CGContext, view: OverlayScreenshotView) { + paintDimMask(ctx: ctx, view: view, selection: selection, radius: selectionCornerRadius) + + // 马赛克揭示层(reveal-mask;在矢量/文字标注之下,对齐 OnPaint 的 + // RevealMosaicToTarget → DrawAnnotations 次序) + paintMosaicLayer(ctx: ctx, view: view) + + // 已提交标注 + 正在绘制的标注(裁剪到选区内;调整选区时保持显示便于看清内容 + // 是否会被裁掉——对齐 OnPaint 的 Confirmed/Drawing/Resizing 三态绘制) + paintAnnotationsLayer(ctx: ctx, view: view) + + // 文字编辑层:输入文字 + 边框 + 选择高亮 + 组词下划线 + 插入符 + //(对齐 OnPaint CS_TextEditing 分支) + paintTextEditingLayer(ctx: ctx, view: view) + + // 确认边框(对齐 DrawConfirmedBorder:radius≥1 用圆角路径,否则 1px 直角框) + paintConfirmedBorder(ctx: ctx, view: view) + + let draggingCorner = state == .resizing && resizeHandle.isCorner + // 选区 resize 手柄:确认/调整/移动中显示;倒角手柄拖拽时隐藏(对齐 OnPaint 逻辑) + if !draggingCorner { + paintResizeHandles(ctx: ctx, view: view) + } + // 圆角手柄:默认隐藏,仅"鼠标靠近某角"(确认态)或"正拖拽某角"(Resizing)时显示该角一个 + var visibleCorner: ScreenshotResizeHandle = .none + if draggingCorner { + visibleCorner = resizeHandle + } else if state == .confirmed { + visibleCorner = hoveredCornerHandle + } + if visibleCorner != .none { + paintCornerKnob(ctx: ctx, view: view, corner: visibleCorner) + } + + // 调整选区(标准手柄)时显示放大镜:焦点取活动手柄锚点,面板置于选区外侧 + // (对齐 OnPaint CS_Resizing 分支的 CalcResizePanelPosition + DrawInfoPanel) + if state == .resizing && !resizeHandle.isCorner { + let anchor = scGetResizeHandleAnchor(resizeHandle, selection) + let panelRect = scCalcResizePanelRect(resizeHandle, selection, virtualBounds) + paintInfoPanel(ctx: ctx, view: view, panelRect: panelRect, focus: anchor, + positionText: "\(Int(anchor.x.rounded())), \(Int(anchor.y.rounded()))") + } + } + + // ---- 蒙版(DrawDimMask 移植)---- + + /// 选区外暗化蒙版(overlay_paint_windows.cpp DrawDimMask 移植):全黑 alpha 120/255 分 + /// 四块绘制(上/下/左/右,选区内保持清晰),radius>0 时用「方框−圆角框」偶奇填充补 + /// 四角"角帽"——与 Windows capPath(外两条直边 + 内凹四分之一弧)逐像素等价。 + private func paintDimMask(ctx: CGContext, view: OverlayScreenshotView, selection sel: CGRect, radius: CGFloat) { + guard sel.width > 0, sel.height > 0 else { return } + let dark = NSColor.black.withAlphaComponent(SC.maskAlpha).cgColor + let local = { (r: CGRect) in r.offsetBy(dx: -view.cgOrigin.x, dy: -view.cgOrigin.y) } + + // 四块蒙版(等价 Windows 的四段 AlphaBlend:上/下全宽,左/右夹在选区上下之间) + let top = CGRect(x: virtualBounds.minX, y: virtualBounds.minY, + width: virtualBounds.width, height: sel.minY - virtualBounds.minY) + let bottom = CGRect(x: virtualBounds.minX, y: sel.maxY, + width: virtualBounds.width, height: virtualBounds.maxY - sel.maxY) + let left = CGRect(x: virtualBounds.minX, y: sel.minY, + width: sel.minX - virtualBounds.minX, height: sel.height) + let right = CGRect(x: sel.maxX, y: sel.minY, + width: virtualBounds.maxX - sel.maxX, height: sel.height) + ctx.setFillColor(dark) + for block in [top, bottom, left, right] where block.width > 0 && block.height > 0 { + ctx.fill(local(block)) + } + + // 圆角角帽:方角框与圆角框的对称差 = 四个角帽区域(偶奇填充,弧边抗锯齿)。 + // 半径钳制不超过短边一半(对齐 DrawDimMask 的 min(radius, min(w,h)/2))。 + let r = min(radius, min(sel.width, sel.height) / 2) + guard r >= 1 else { return } + let path = CGMutablePath() + path.addRect(CGRect(x: 0, y: 0, width: sel.width, height: sel.height)) + path.addPath(CGPath(roundedRect: CGRect(x: 0, y: 0, width: sel.width, height: sel.height), + cornerWidth: r, cornerHeight: r, transform: nil)) + ctx.saveGState() + ctx.translateBy(x: local(sel).minX, y: local(sel).minY) + ctx.addPath(path) + ctx.fillPath(using: .evenOdd) + ctx.restoreGState() + } + + // ---- 边框与手柄 ---- + + /// 确认态选区边框(overlay_ui_windows.cpp DrawConfirmedBorder 移植): + /// radius<1 为 1px 直角蓝框;radius≥1 为圆角路径 1px 蓝框(抗锯齿)。 + private func paintConfirmedBorder(ctx: CGContext, view: OverlayScreenshotView) { + let localSel = selection.offsetBy(dx: -view.cgOrigin.x, dy: -view.cgOrigin.y) + if selectionCornerRadius < 1 { + scStrokeRect(ctx, localSel, color: SC.accentBlue, width: 1) + return + } + let r = min(selectionCornerRadius, min(localSel.width, localSel.height) / 2) + let path = CGPath(roundedRect: localSel, cornerWidth: max(1, r), cornerHeight: max(1, r), transform: nil) + ctx.addPath(path) + ctx.setStrokeColor(SC.accentBlue.cgColor) + ctx.setLineWidth(1) + ctx.strokePath() + } + + /// 8 个选区调整手柄(overlay_ui_windows.cpp DrawResizeHandles 移植): + /// 蓝色方块 + 白色 1px 描边,抗锯齿。 + private func paintResizeHandles(ctx: CGContext, view: OverlayScreenshotView) { + let hs = SC.handleSize + let half = hs / 2 + let localSel = selection.offsetBy(dx: -view.cgOrigin.x, dy: -view.cgOrigin.y) + let cx = localSel.midX + let cy = localSel.midY + let anchors: [CGPoint] = [ + CGPoint(x: localSel.minX, y: cy), CGPoint(x: localSel.maxX, y: cy), + CGPoint(x: cx, y: localSel.minY), CGPoint(x: cx, y: localSel.maxY), + CGPoint(x: localSel.minX, y: localSel.minY), CGPoint(x: localSel.maxX, y: localSel.minY), + CGPoint(x: localSel.minX, y: localSel.maxY), CGPoint(x: localSel.maxX, y: localSel.maxY), + ] + let path = CGMutablePath() + for a in anchors { + path.addRect(CGRect(x: a.x - half, y: a.y - half, width: hs, height: hs)) + } + ctx.addPath(path) + ctx.setFillColor(SC.accentBlue.cgColor) + ctx.fillPath() + ctx.addPath(path) + ctx.setStrokeColor(NSColor.white.cgColor) + ctx.setLineWidth(1) + ctx.strokePath() + } + + /// 单个圆角拖拽手柄(overlay_ui_windows.cpp DrawCornerRadiusHandle 移植): + /// 白底圆 + 蓝环 + 朝向选区角的四分之一弧点缀;中心沿对角线内移 d = clamp(inset+radius, 0, maxR)。 + private func paintCornerKnob(ctx: CGContext, view: OverlayScreenshotView, corner: ScreenshotResizeHandle) { + let half = SC.handleSize / 2 + let gr = max(1, half - 2) + let centerCG = scCornerKnobCenter(selection, SC.cornerKnobInset, selectionCornerRadius, corner) + let center = CGPoint(x: centerCG.x - view.cgOrigin.x, y: centerCG.y - view.cgOrigin.y) + let circle = CGPath(ellipseIn: CGRect(x: center.x - half, y: center.y - half, + width: half * 2, height: half * 2), transform: nil) + ctx.addPath(circle) + ctx.setFillColor(NSColor.white.cgColor) + ctx.fillPath() + ctx.addPath(circle) + ctx.setStrokeColor(SC.accentBlue.cgColor) + ctx.setLineWidth(1.5) + ctx.strokePath() + + // 四分之一弧点缀(朝向所在选区角)。用显式参数化的折线逼近弧段,避免翻转坐标系下 + // CG 弧方向歧义;r 从 0..π/2 采样,四角按象限镜像(视觉与 Windows DrawArc 一致)。 + let steps = 10 + let path = CGMutablePath() + var first = true + for i in 0...steps { + let t = CGFloat(i) / CGFloat(steps) + let cosT = cos(t * .pi / 2) + let sinT = sin(t * .pi / 2) + let dxSign: CGFloat + let dySign: CGFloat + switch corner { + case .cornerTL: dxSign = -1; dySign = -1 + case .cornerTR: dxSign = 1; dySign = -1 + case .cornerBL: dxSign = -1; dySign = 1 + case .cornerBR: dxSign = 1; dySign = 1 + default: dxSign = -1; dySign = -1 + } + let p = CGPoint(x: center.x + dxSign * gr * cosT, y: center.y + dySign * gr * sinT) + if first { + path.move(to: p) + first = false + } else { + path.addLine(to: p) + } + } + ctx.addPath(path) + ctx.setStrokeColor(SC.accentBlue.cgColor) + ctx.setLineWidth(1) + ctx.strokePath() + } + + // ---- 尺寸标签与信息面板 ---- + + /// 尺寸标签(overlay_ui_windows.cpp DrawSizeLabel 移植):深色圆角底 + 灰描边 + 白字。 + /// 定位几何与失效计算共用 scCalcSizeLabelRect(保证脏区与绘制一致)。 + private func paintSizeLabel(ctx: CGContext, view: OverlayScreenshotView, rect: CGRect, text: String) { + let local = rect.offsetBy(dx: -view.cgOrigin.x, dy: -view.cgOrigin.y) + let path = CGPath(roundedRect: local, cornerWidth: SC.panelCornerRadius, + cornerHeight: SC.panelCornerRadius, transform: nil) + ctx.addPath(path) + ctx.setFillColor(SC.panelBg.cgColor) + ctx.fillPath() + ctx.addPath(path) + ctx.setStrokeColor(SC.panelBorder.cgColor) + ctx.setLineWidth(1) + ctx.strokePath() + + let attr = scLabelAttributedString(text) + let textSize = attr.size() + attr.draw(at: NSPoint(x: local.minX + SC.sizeLabelPadX, + y: local.minY + SC.sizeLabelPadY + (textSize.height - SC.fontPx) / 2)) + } + + /// 放大镜 + 坐标/HEX/RGB 信息面板(overlay_ui_windows.cpp DrawInfoPanel 移植)。 + /// 放大镜从常驻底图 CGImage 按物理像素裁剪采样(禁止整图重采样),4× 就近放大 + ///(对齐 Windows StretchBlt 的像素放大视觉),叠加 1px 蓝色十字准星。 + /// - Parameters: + /// - panelRect: 面板矩形(CG 全局坐标,140×140) + /// - focus: 采样焦点(鼠标或活动手柄锚点,CG 全局坐标) + /// - positionText: 坐标行文本 + private func paintInfoPanel(ctx: CGContext, view: OverlayScreenshotView, + panelRect: CGRect, focus: CGPoint, positionText: String) { + let local = panelRect.offsetBy(dx: -view.cgOrigin.x, dy: -view.cgOrigin.y) + let path = CGPath(roundedRect: local, cornerWidth: SC.panelCornerRadius, + cornerHeight: SC.panelCornerRadius, transform: nil) + ctx.addPath(path) + ctx.setFillColor(SC.panelBg.cgColor) + ctx.fillPath() + ctx.addPath(path) + ctx.setStrokeColor(SC.panelBorder.cgColor) + ctx.setLineWidth(1) + ctx.strokePath() + + // 放大镜区域(对齐 DrawInfoPanel:magW = w - 2*borderPad,magH = magnifierH - borderPad) + let magRect = CGRect(x: local.minX + SC.panelBorderPad, + y: local.minY + SC.panelBorderPad, + width: local.width - SC.panelBorderPad * 2, + height: SC.magnifierHeight - SC.panelBorderPad) + + // 采样窗口 = 放大区 / 倍数(Windows int 除法语义:140/4=35、74/4=18) + let srcW = (SC.panelSize / SC.zoomFactor).rounded(.down) + let srcH = (SC.magnifierHeight / SC.zoomFactor).rounded(.down) + if let sample = cropBaseImage(around: focus, srcLogicalW: srcW, srcLogicalH: srcH) { + ctx.saveGState() + // 圆角面板内裁剪,避免放大图溢出面板圆角 + ctx.addPath(CGPath(roundedRect: magRect, cornerWidth: 2, cornerHeight: 2, transform: nil)) + ctx.clip() + ctx.interpolationQuality = .none // 就近放大,保留像素感(对齐 StretchBlt 视觉) + scDrawCGImage(ctx, sample, in: magRect) + ctx.restoreGState() + } + + // 蓝色十字准星(对齐 crosshairPen:1px,横纵贯穿放大区) + ctx.setStrokeColor(SC.accentBlue.cgColor) + ctx.setLineWidth(1) + ctx.move(to: CGPoint(x: magRect.minX, y: magRect.midY)) + ctx.addLine(to: CGPoint(x: magRect.maxX, y: magRect.midY)) + ctx.move(to: CGPoint(x: magRect.midX, y: magRect.minY)) + ctx.addLine(to: CGPoint(x: magRect.midX, y: magRect.maxY)) + ctx.strokePath() + + // 三行信息(对齐 DrawInfoPanel:左标签 + 右对齐值) + let color = currentColor + let lines: [(String, String)] = [ + ("坐标", positionText), + ("HEX", String(format: "#%02X%02X%02X", color.r, color.g, color.b)), + ("RGB", "\(color.r), \(color.g), \(color.b)"), + ] + let lineH = SC.fontPx + 3 + let infoY = local.maxY - SC.panelLabelPad - lineH * 3 + let labelX = local.minX + SC.panelLabelPad + let valueRightX = local.maxX - SC.panelLabelPad + for (i, line) in lines.enumerated() { + let y = infoY + CGFloat(i) * lineH + scLabelAttributedString(line.0).draw(at: NSPoint(x: labelX, y: y)) + let value = scLabelAttributedString(line.1) + let valueSize = value.size() + value.draw(at: NSPoint(x: valueRightX - ceil(valueSize.width), y: y)) + } + } +} + +// MARK: - 基础绘制小工具 + +/// 1px/3px 矩形描边(对齐 GDI Rectangle 描边;width 为线宽)。 +private func scStrokeRect(_ ctx: CGContext, _ rect: CGRect, color: NSColor, width: CGFloat) { + ctx.setStrokeColor(color.cgColor) + ctx.setLineWidth(width) + ctx.stroke(rect) +} diff --git a/src/screenshot/macos/ScreenshotTextMac.swift b/src/screenshot/macos/ScreenshotTextMac.swift new file mode 100644 index 0000000..c173bbf --- /dev/null +++ b/src/screenshot/macos/ScreenshotTextMac.swift @@ -0,0 +1,813 @@ +import Foundation +import AppKit +import CoreGraphics +import CoreText + +// MARK: - 文字标注与编辑器(macOS) +// +// Windows 版文字链路(annotations_windows.cpp / overlay_input_windows.cpp / overlay_paint_windows.cpp): +// - 输入态 = CS_TextEditing 会话子状态:textBuf 缓冲 + 锚点 + 插入符 + 选择区间,全自绘 +// - IME:OnImeComposition 取 GCS_RESULTSTR 上屏串插入缓冲;可打印字符走 OnChar +// - 提交:6 条统一提交路径(见 commitPendingText 注释),CommitPendingText 构造参数完全一致 +// - 测量:GDI+ MeasureString 紧凑字形包围盒;边框 = 字形盒 ± padding 4 +// +// macOS 等价实现: +// - 覆盖层 NSView 实现 NSTextInputClient(本文件底部扩展):insertText 承接「可打印字符 + +// IME 上屏」两条入口(AppKit 把两者统一路由到该回调,语义合并 Windows OnChar 与 +// GCS_RESULTSTR);setMarkedText/unmarkText 承接组词串(marked text)自绘下划线, +// firstRect(forCharacterRange:) 返回插入符屏幕坐标供候选窗跟随 +// - 测量用 Core Text(NSFont/CTLine):字形紧凑包围盒 = useGlyphPathBounds;逐前缀累计宽度 +// 与 Windows MeasureCharWidthsGdip 的 widths[i] 语义一致(前 i 个 UTF-16 单元的右缘) +// - 键系(Backspace/Delete/Left/Right/Home/End/Enter/ESC)自管,行为逐条对齐 OnKeyDown +// +// 字体差异说明:Windows 用微软雅黑(SC_FONT_FACE);macOS 无该字体,使用系统字体 +// (systemFont,CJK 回退苹方),度量数值与 Windows 不逐像素一致,计算结构完全同构。 + +// MARK: - 常量(Windows 出处集中标注) + +extension SC { + /// 文字字号预设,逻辑像素;文字标注 thickness 即字号(internal.h: SC_FONT_SIZES = { 16, 24, 36 }) + static let fontSizes: [Int] = [16, 24, 36] + /// 默认字号档:中号(internal.h: SC_DEFAULT_FONT_IDX = 1) + static let defaultFontIdx = 1 + /// 文字标注最小字号(annotations_windows.cpp MeasureTextAnnotation:fontPx < 8 → 8) + static let textMinFontSize = 8 + /// 输入框边框 padding(overlay_input_windows.cpp / overlay_paint_windows.cpp:padding = 4, + /// 命中区与绘制边框完全一致) + static let textPadding: CGFloat = 4 + /// 空缓冲时输入框的最小字形宽(overlay_paint_windows.cpp:glyphW 兜底 20) + static let textMinGlyphW: CGFloat = 20 + /// 插入符闪烁间隔(session_windows.cpp 空闲循环:now - textCaretLastBlink >= 500 切换可见性) + static let caretBlinkInterval: TimeInterval = 0.5 + /// 文字选择高亮底色(internal.h textSelBrush RGB(51,153,255),AlphaBlend 常量 alpha 100) + static let textSelBg = NSColor(srgbRed: 51.0 / 255.0, green: 153.0 / 255.0, + blue: 255.0 / 255.0, alpha: 100.0 / 255.0) + /// 文字选中边框蓝(internal.h annTextSelPen PS_SOLID 2 RGB(0,136,255),与强调蓝同值) + static let textSelectedBorder = SC.accentBlue +} + +/// 空 NSRange(marked range 未激活语义,等价 Windows 无选择区间)。 +let scNotFoundRange = NSRange(location: NSNotFound, length: 0) + +// MARK: - 文字度量(对齐 MeasureTextGdip / MeasureCharWidthsGdip / CalcCaretPosFromMouse) + +/// 文字度量结果:紧凑字形包围盒(相对锚点偏移 + 紧凑宽高)与逐字符累计宽度。 +/// widths[i] = 前 i 个 UTF-16 单元的右缘(widths[0]=0),与渲染进度同源, +/// 光标 x = 锚点 x + widths[i]、选中高亮区间 = widths[s]..widths[e]。 +struct ScreenshotTextMetrics { + var offX: CGFloat = 0 // 字形左缘相对锚点偏移(通常 ≈0,侧边 bearing) + var offY: CGFloat = 0 // 字形顶缘相对锚点偏移(布局顶 → 字形顶的字体内部空隙) + var inkW: CGFloat = 0 // 字形紧凑宽 + var inkH: CGFloat = 0 // 字形紧凑高 + var widths: [CGFloat] = [0] +} + +/// 测量文字的紧凑字形包围盒与逐前缀累计宽度(对齐 MeasureTextGdip + MeasureCharWidthsGdip)。 +/// macOS 用 Core Text:整体行取 useGlyphPathBounds(字形墨迹紧凑盒); +/// 逐前缀宽度按前缀串测量(O(n²),文字标注为短文本,实测可接受;Windows 用 +/// MeasureCharacterRanges 批量测,语义等价)。 +/// 空文本:offX/offY/inkW = 0、inkH = fontPx、widths = [0](对齐 Windows 空缓冲兜底)。 +/// - Parameters: +/// - text: 待测文本 +/// - fontPx: 字号(逻辑像素) +/// - Returns: 度量结果 +func scMeasureTextMetrics(_ text: String, fontPx: CGFloat) -> ScreenshotTextMetrics { + var m = ScreenshotTextMetrics() + let font = NSFont.systemFont(ofSize: fontPx) + guard !text.isEmpty else { + m.inkH = fontPx + m.widths = [0] + return m + } + let attr = NSAttributedString(string: text, attributes: [.font: font]) + let line = CTLineCreateWithAttributedString(attr) + var ascent: CGFloat = 0, descent: CGFloat = 0, leading: CGFloat = 0 + _ = CTLineGetTypographicBounds(line, &ascent, &descent, &leading) + let ink = CTLineGetBoundsWithOptions(line, .useGlyphPathBounds) + if ink.width > 0 && ink.height > 0 { + m.offX = ink.minX + m.offY = ascent - ink.maxY // 基线 = 布局顶 + ascent,墨迹顶 = 布局顶 + offY + m.inkW = ink.width + m.inkH = ink.height + } else { + m.inkH = fontPx + } + var widths: [CGFloat] = [0] + let ns = text as NSString + for i in 1...ns.length { + let prefix = attr.attributedSubstring(from: NSRange(location: 0, length: i)) + let pl = CTLineCreateWithAttributedString(prefix) + widths.append(CGFloat(CTLineGetTypographicBounds(pl, nil, nil, nil))) + } + m.widths = widths + return m +} + +/// 绘制一行文字(顶部左对齐到锚点;对齐 DrawOneAnnotation AT_Text 分支的布局语义)。 +/// 上下文必须为左上原点(覆盖层视图天然翻转;导出合成由调用方翻转)。 +/// Core Text 在翻转上下文的标准绘制法:平移到基线后本地 scale(1,-1)。 +/// - Parameters: +/// - ctx: 目标上下文(左上原点) +/// - text: 文本 +/// - fontPx: 字号(逻辑像素) +/// - color: 文字颜色 +/// - x/y: 锚点(布局顶左角) +func scDrawTextLine(_ ctx: CGContext, text: String, fontPx: CGFloat, color: NSColor, + x: CGFloat, y: CGFloat) { + guard !text.isEmpty else { return } + let attr = NSAttributedString(string: text, attributes: [.font: NSFont.systemFont(ofSize: fontPx), + .foregroundColor: color]) + let line = CTLineCreateWithAttributedString(attr) + var ascent: CGFloat = 0, descent: CGFloat = 0, leading: CGFloat = 0 + _ = CTLineGetTypographicBounds(line, &ascent, &descent, &leading) + ctx.saveGState() + ctx.textMatrix = .identity + ctx.translateBy(x: x, y: y + ascent) // 基线 = 布局顶 + ascent + ctx.scaleBy(x: 1, y: -1) + ctx.textPosition = .zero + CTLineDraw(line, ctx) + ctx.restoreGState() +} + +/// 测量文字标注的包围盒(对齐 MeasureTextAnnotation):字形紧凑盒 ± padding 4, +/// 与编辑态输入框/命中区完全一致,保证选中边框 = resize 内容约束边界。 +/// 空文字/非文字标注返回零矩形(对齐 Windows 空返回 { x1,y1,x1,y1 })。 +func scMeasureTextAnnotationBox(_ a: ScreenshotAnnotation) -> CGRect { + guard a.type == .text, !a.text.isEmpty else { + return CGRect(x: CGFloat(a.x1), y: CGFloat(a.y1), width: 0, height: 0) + } + let fontPx = CGFloat(max(a.thickness, SC.textMinFontSize)) + let m = scMeasureTextMetrics(a.text, fontPx: fontPx) + let left = CGFloat(a.x1) + m.offX.rounded(.down) - SC.textPadding + let top = CGFloat(a.y1) + m.offY.rounded(.down) - SC.textPadding + let right = CGFloat(a.x1) + (m.offX + m.inkW).rounded(.up) + SC.textPadding + let bottom = CGFloat(a.y1) + (m.offY + m.inkH).rounded(.up) + SC.textPadding + return CGRect(x: left, y: top, width: right - left, height: bottom - top) +} + +/// 命中测试文字标注(对齐 HitTestTextAnnotations):从顶层向底层遍历, +/// 包围盒(含 padding 4)整体命中。马赛克不可选中由通用命中跳过,文字走本函数优先判定。 +/// - Parameters: +/// - anns: 标注数组 +/// - point: 鼠标 CG 全局坐标 +/// - Returns: 命中索引;未命中 -1 +func scHitTestTextAnnotations(_ anns: [ScreenshotAnnotation], _ point: CGPoint) -> Int { + for i in stride(from: anns.count - 1, through: 0, by: -1) { + if anns[i].type == .text, scPointInRect(point, scMeasureTextAnnotationBox(anns[i])) { + return i + } + } + return -1 +} + +/// 根据鼠标位置计算插入符位置(对齐 CalcCaretPosFromMouse):逐边界取 x 距离最近者。 +/// - Parameters: +/// - text: 编辑缓冲 +/// - fontPx: 字号 +/// - textX: 锚点 x +/// - mouseX: 鼠标 x(同坐标系) +/// - Returns: 插入符位置(UTF-16 单元偏移) +func scCalcCaretPosFromMouse(_ text: String, fontPx: CGFloat, textX: CGFloat, mouseX: CGFloat) -> Int { + if text.isEmpty { return 0 } + let m = scMeasureTextMetrics(text, fontPx: fontPx) + var best = 0 + var bestDist = CGFloat.greatestFiniteMagnitude + for i in 0.. CGFloat { + let idx = (fontSizeIdx >= 0 && fontSizeIdx < SC.fontSizes.count) ? fontSizeIdx : SC.defaultFontIdx + return CGFloat(SC.fontSizes[idx]) + } + + // MARK: 进入/退出编辑(对齐 OnLButtonDown TB_Text 分支 + CommitPendingTextAndExitEditing) + + /// 进入文字编辑态(文字工具激活 + 点击选区内空白):清缓冲、锚点=点击点、插入符归零; + /// 清除文字/非文字选中(对齐 Windows 进入输入态的清理序列),子菜单保持打开。 + /// - Parameter point: 点击点(CG 全局坐标) + func beginTextEditing(at point: CGPoint) { + textBuf = "" + textAnchorX = point.x + textAnchorY = point.y + textCaretPos = 0 + textSelStart = -1 + textSelEnd = -1 + textDraggingSelection = false + textMarkedRange = scNotFoundRange + textCaretVisible = true + textCaretLastBlink = ProcessInfo.processInfo.systemUptime + hoveredTextAnnotation = -1 + selectedTextAnnotation = -1 + draggingTextAnnotation = -1 + selectedAnnotation = -1 + hasCurDrawing = false + state = .textEditing + textEditingFlag.set() // event tap 对 ESC 放行(编辑态 ESC = 清缓冲,不取消截图) + invalidateAll() + } + + /// 把输入缓冲固化为一条文字标注并入栈历史(缓冲为空则无任何副作用)。 + /// 六条提交路径(1 编辑态点字号格 / 2 编辑态点颜色格 / 3 编辑态点工具栏按钮 / + /// 4 编辑态选区内换位 / 5 编辑态点选区外退出 / 6 Enter 键)构造参数完全一致, + /// 统一走本函数避免逐处手抄漂移(对齐 overlay_input_windows.cpp CommitPendingText)。 + func commitPendingText() { + guard !textBuf.isEmpty else { return } + pushAnnotationHistory() + var a = ScreenshotAnnotation(type: .text, + color: SC.colorPresets[drawColorIdx], + thickness: SC.fontSizes[fontSizeIdx]) + a.x1 = Int(textAnchorX) + a.y1 = Int(textAnchorY) + a.text = textBuf + annotations.append(a) + } + + /// 提交文字后的完整编辑出口:清缓冲/光标/选择区间/组词串并回确认态 + ///(对齐 CommitPendingTextAndExitEditing;Enter 与选区内换位路径不经过本函数, + /// 各自保持 Windows 原有清理序列)。 + func commitPendingTextAndExitEditing() { + commitPendingText() + textBuf = "" + textCaretPos = 0 + textSelStart = -1 + textSelEnd = -1 + textMarkedRange = scNotFoundRange + textEditingFlag.reset() + state = .confirmed + invalidateAll() + } + + // MARK: 鼠标(对齐 OnLButtonDown / OnMouseMove / OnLButtonUp 的 CS_TextEditing 分支) + + /// 文字编辑态左键按下(工具栏/子菜单为独立浮层窗口,其点击提交路径在 + /// ScreenshotToolbarController 中处理,此处只承接覆盖层命中): + /// 1) 输入框内 → 光标定位 + 开始拖选;2) 选区内其他位置 → 提交并开始新输入(路径 4); + /// 3) 选区外 → 提交并退出编辑态(路径 5)。 + /// - Parameter point: 鼠标 CG 全局坐标 + func handleTextEditingMouseDown(_ point: CGPoint) { + // 1) 输入框命中:命中区与绘制边框完全一致(同一计算,否则点边框附近会误判框外) + let box = textEditingInputBox() + if scPointInRect(point, box) { + let caret = scCalcCaretPosFromMouse(textBuf, fontPx: currentFontPx(), + textX: textAnchorX, mouseX: point.x) + textCaretPos = caret + textSelStart = caret + textSelEnd = caret + textDraggingSelection = true + invalidateTextLine() + return + } + // 2) 选区内其他位置:提交当前文字,开始新输入(保持编辑态;提交路径 4) + if selection.contains(point) { + commitPendingText() + textBuf = "" + textAnchorX = point.x + textAnchorY = point.y + textCaretPos = 0 + textSelStart = -1 + textSelEnd = -1 + textMarkedRange = scNotFoundRange + invalidateAll() + return + } + // 3) 选区外:提交并退出文字编辑态(提交路径 5) + commitPendingTextAndExitEditing() + } + + /// 编辑态拖动选择文字:按鼠标位置更新选择终点与插入符(对齐 OnMouseMove 分支)。 + /// - Parameter point: 鼠标 CG 全局坐标 + func handleTextEditingDragged(_ point: CGPoint) { + guard textDraggingSelection else { return } + let caret = scCalcCaretPosFromMouse(textBuf, fontPx: currentFontPx(), + textX: textAnchorX, mouseX: point.x) + textSelEnd = caret + textCaretPos = caret + invalidateTextLine() + } + + /// 编辑态左键抬起:结束拖选,范围退化(起=终)时清除选择(对齐 OnLButtonUp 分支)。 + func handleTextEditingMouseUp() { + guard textDraggingSelection else { return } + textDraggingSelection = false + if textSelStart == textSelEnd { + textSelStart = -1 + textSelEnd = -1 + } + } + + /// 编辑态输入框矩形(CG 全局坐标):字形紧凑包围盒 + padding 4,空缓冲最小宽 20。 + /// 与绘制边框同一公式(对齐 overlay_input_windows.cpp inputBox / overlay_paint_windows.cpp + /// 边框计算),保证点击边框附近不误判框外。 + func textEditingInputBox() -> CGRect { + let fontPx = currentFontPx() + let m = scMeasureTextMetrics(textBuf, fontPx: fontPx) + let inkW = (m.inkW > SC.textMinGlyphW || !textBuf.isEmpty) ? m.inkW : SC.textMinGlyphW + let inkH = m.inkH > 0 ? m.inkH : fontPx + let inkLeft = textAnchorX + m.offX + let inkTop = textAnchorY + m.offY + let left = (inkLeft - SC.textPadding).rounded(.down) + let top = (inkTop - SC.textPadding).rounded(.down) + return CGRect(x: left, y: top, + width: (inkLeft + inkW + SC.textPadding).rounded(.up) - left, + height: (inkTop + inkH + SC.textPadding).rounded(.up) - top) + } + + // MARK: 键系(对齐 OnKeyDown 的 CS_TextEditing 分支) + + /// 文字编辑态键盘处理。IME 与可打印字符优先交给 NSTextInputContext + ///(路由到 NSTextInputClient 的 insertText/setMarkedText),其余键系自管: + /// Enter=提交(路径 6)、ESC=清缓冲回确认态(不取消截图)、Backspace=删插入符前一字符 + ///(Windows VK_BACK 不处理选区语义,照搬)、ForwardDelete=删插入符处字符、 + /// Left/Right/Home/End=移动插入符。返回 true 表示事件已消费。 + /// - Parameters: + /// - event: 键盘事件 + /// - inputContext: 覆盖层视图的输入上下文(nil 时跳过 IME 路由) + /// - Returns: 是否已消费 + func handleTextEditingKeyDown(_ event: NSEvent, inputContext: NSTextInputContext?) -> Bool { + if let ctx = inputContext, ctx.handleEvent(event) { return true } + let len = (textBuf as NSString).length + switch event.keyCode { + case 36, 76: + // 提交路径 6:Enter 提交(Windows Enter 路径历史上不清 textSelStart/End,照搬) + commitPendingText() + textBuf = "" + textCaretPos = 0 + textMarkedRange = scNotFoundRange + exitTextEditingState() + return true + case 53: + // ESC:清缓冲回确认态(不是取消截图,再次 ESC 才取消) + textBuf = "" + textCaretPos = 0 + textMarkedRange = scNotFoundRange + exitTextEditingState() + return true + case 51: + // Backspace:删插入符前一字符(对齐 VK_BACK:仅删一字符,不处理选择区间) + if textCaretPos > 0 && textCaretPos <= len { + textBuf = (textBuf as NSString).replacingCharacters( + in: NSRange(location: textCaretPos - 1, length: 1), with: "") + textCaretPos -= 1 + invalidateTextLine() + } + return true + case 117: + // Forward Delete:删插入符处字符(对齐 VK_DELETE 文字分支) + if textCaretPos < len { + textBuf = (textBuf as NSString).replacingCharacters( + in: NSRange(location: textCaretPos, length: 1), with: "") + invalidateTextLine() + } + return true + case 123: + // Left:插入符左移一格(对齐 VK_LEFT) + if textCaretPos > 0 { + textCaretPos -= 1 + invalidateTextLine() + } + return true + case 124: + // Right:插入符右移一格(对齐 VK_RIGHT) + if textCaretPos < len { + textCaretPos += 1 + invalidateTextLine() + } + return true + case 115: + // Home:插入符到行首(对齐 VK_HOME) + textCaretPos = 0 + invalidateTextLine() + return true + case 119: + // End:插入符到行尾(对齐 VK_END) + textCaretPos = len + invalidateTextLine() + return true + case 125, 126: + return true // Up/Down:单行无移动语义,吞掉(对齐 Windows 无分支恒消费) + default: + return true // 其余按键在编辑态恒消费(对齐 OnKeyDown 恒返回 0) + } + } + + /// 退出编辑态回确认态(Enter/ESC 路径共用收尾;不清理选择区间,对齐 Windows 原序列)。 + private func exitTextEditingState() { + textEditingFlag.reset() + state = .confirmed + invalidateAll() + } + + // MARK: NSTextInputClient(IME:组词串 + 上屏;对齐 OnChar / OnImeComposition) + + /// 插入已提交文本(覆盖层视图 insertText 回调转发):直接键入与 IME 上屏共用入口 + ///(AppKit 统一路由,语义合并 Windows OnChar 与 GCS_RESULTSTR)。过滤控制字符 + ///(ch >= 32 && ch != 127);IME 上屏时先移除组词串再插入。Windows 的 OnChar 插入后 + /// 保留选择区间(不自动删除选中文字),照搬同语义。 + /// - Parameters: + /// - aString: 待插入字符串(String 或 NSAttributedString) + /// - replacementRange: IME 指定的替换区间(直接插入路径忽略,与 Windows 一致) + func textInsertText(_ aString: Any, replacementRange: NSRange) { + guard state == .textEditing, isRunning else { return } + let raw: String? + switch aString { + case let s as String: raw = s + case let attr as NSAttributedString: raw = attr.string + default: raw = nil + } + guard var s = raw, !s.isEmpty else { return } + s = String(String.UnicodeScalarView(s.unicodeScalars.filter { $0.value >= 32 && $0.value != 127 })) + guard !s.isEmpty else { return } + removeMarkedTextFromBuffer() + textInsertAtCaret(s) + textMarkedRange = scNotFoundRange + invalidateTextLine() + } + + /// 组词串更新(覆盖层视图 setMarkedText 回调转发):移除旧组词串 → replacementRange + /// 有效时替换该区间,否则在插入符处插入 → 登记新 markedRange。IME 建议的组词内选中段 + ///(selectedRange 参数)自绘编辑器不实现,忽略;组词串随缓冲一起被键系/渲染单路径处理。 + /// - Parameters: + /// - aString: 组词串(String 或 NSAttributedString) + /// - replacementRange: IME 指定的替换区间(NSNotFound = 在插入符处插入) + func textSetMarkedText(_ aString: Any, replacementRange: NSRange) { + guard state == .textEditing, isRunning else { return } + let raw: String? + switch aString { + case let s as String: raw = s + case let attr as NSAttributedString: raw = attr.string + default: raw = nil + } + guard let s = raw else { return } + removeMarkedTextFromBuffer() + let insertLen = (s as NSString).length + guard insertLen > 0 else { return } + let ns = textBuf as NSString + if replacementRange.location != NSNotFound, replacementRange.length > 0, + NSMaxRange(replacementRange) <= ns.length { + let loc = max(0, min(replacementRange.location, ns.length)) + let len = min(replacementRange.length, ns.length - loc) + textBuf = ns.replacingCharacters(in: NSRange(location: loc, length: len), with: s) + textCaretPos = loc + insertLen + textMarkedRange = NSRange(location: loc, length: insertLen) + } else { + let pos = max(0, min(textCaretPos, ns.length)) + textBuf = ns.replacingCharacters(in: NSRange(location: pos, length: 0), with: s) + textCaretPos = pos + insertLen + textMarkedRange = NSRange(location: pos, length: insertLen) + } + invalidateTextLine() + } + + /// 组词终止(覆盖层视图 unmarkText 回调转发):保留组词文本(已被用户确认的输入), + /// 仅清除 marked 标记。 + func textUnmark() { + guard state == .textEditing else { return } + textMarkedRange = scNotFoundRange + invalidateTextLine() + } + + /// 移除缓冲中的组词串并把插入符退回组词起点(无组词串时无副作用)。 + private func removeMarkedTextFromBuffer() { + guard textMarkedRange.location != NSNotFound, textMarkedRange.length > 0 else { return } + let ns = textBuf as NSString + let loc = max(0, min(textMarkedRange.location, ns.length)) + let len = min(textMarkedRange.length, ns.length - loc) + if len > 0 { + textBuf = ns.replacingCharacters(in: NSRange(location: loc, length: len), with: "") + } + textCaretPos = loc + textMarkedRange = scNotFoundRange + } + + /// 在插入符处插入文本(UTF-16 单元安全)。 + private func textInsertAtCaret(_ s: String) { + let ns = textBuf as NSString + let pos = max(0, min(textCaretPos, ns.length)) + textBuf = ns.replacingCharacters(in: NSRange(location: pos, length: 0), with: s) + textCaretPos = pos + (s as NSString).length + } + + // MARK: NSTextInputClient 查询(selectedRange/markedRange/firstRect 等) + + /// 当前选中/插入符区间(IME 候选定位用):有选择返回选择区间,否则插入符空区间。 + func textSelectedRange() -> NSRange { + if textSelStart >= 0 && textSelEnd >= 0 && textSelStart != textSelEnd { + let loc = min(textSelStart, textSelEnd) + return NSRange(location: loc, length: abs(textSelEnd - textSelStart)) + } + return NSRange(location: max(0, min(textCaretPos, (textBuf as NSString).length)), length: 0) + } + + /// 当前组词区间(无组词时为 NSNotFound 空区间)。 + /// (命名避开会话同名存储属性 textMarkedRange。) + func textCurrentMarkedRange() -> NSRange { + return textMarkedRange + } + + /// 是否有组词串。 + func textHasMarkedText() -> Bool { + return textMarkedRange.location != NSNotFound && textMarkedRange.length > 0 + } + + /// 取区间内的属性子串(部分 IME 查询用;带当前字号字体)。 + /// - Parameters: + /// - range: 请求区间(UTF-16 单元) + /// - actualRange: 回填实际可用区间(nil 可忽略) + /// - Returns: 属性子串;区间无效返回 nil + func textAttributedSubstring(for range: NSRange, actualRange: NSRangePointer?) -> NSAttributedString? { + let ns = textBuf as NSString + guard range.location != NSNotFound, range.length > 0, range.location < ns.length else { return nil } + let r = NSRange(location: range.location, + length: min(range.length, ns.length - range.location)) + actualRange?.pointee = r + return NSAttributedString(string: ns.substring(with: r), + attributes: [.font: NSFont.systemFont(ofSize: currentFontPx())]) + } + + /// 插入符矩形(CG 全局坐标):候选窗跟随与闪烁失效共用(宽 2px 对齐 Windows 2px 光标笔)。 + func textCaretRectGlobal() -> CGRect { + let m = scMeasureTextMetrics(textBuf, fontPx: currentFontPx()) + let idx = max(0, min(textCaretPos, m.widths.count - 1)) + let inkH = m.inkH > 0 ? m.inkH : currentFontPx() + return CGRect(x: textAnchorX + m.widths[idx], y: textAnchorY + m.offY, + width: 2, height: inkH.rounded(.up)) + } + + /// 命中点 → 字符索引(覆盖层视图 characterIndex(for:) 转发)。 + /// - Parameter point: 命中点(CG 全局坐标) + /// - Returns: 最近字符边界(UTF-16 单元偏移) + func textCharacterIndex(at point: CGPoint) -> Int { + return scCalcCaretPosFromMouse(textBuf, fontPx: currentFontPx(), + textX: textAnchorX, mouseX: point.x) + } + + // MARK: 闪烁 / 局部失效 / 渲染 + + /// 泵循环逐拍任务:编辑态插入符 500ms 闪烁(对齐 session_windows.cpp 空闲循环分支), + /// 仅失效光标附近区域。 + /// - Parameter now: 单调时钟(ProcessInfo.systemUptime) + func tickTextCaret(now: TimeInterval) { + guard state == .textEditing else { return } + if now - textCaretLastBlink >= SC.caretBlinkInterval { + textCaretVisible.toggle() + textCaretLastBlink = now + if !lastCaretRect.isNull { + invalidate(scInflate(lastCaretRect, 2)) + } else { + invalidateAll() + } + } + } + + /// 使文字行区域失效(对齐 InvalidateTextLine):选区宽度 × 上帧光标行高(选区宽度是 + /// 文字行宽的安全上界);无光标缓存时全屏失效兜底。 + func invalidateTextLine() { + if lastCaretRect.isNull { + invalidateAll() + return + } + let line = CGRect(x: selection.minX, y: lastCaretRect.minY, + width: selection.width, height: lastCaretRect.height) + invalidate(scInflate(line, 4)) + } + + /// 编辑态覆盖层渲染(对齐 OnPaint CS_TextEditing 分支;调用点在 paintConfirmedOverlay, + /// 已提交标注由 paintAnnotationsLayer 先行绘制): + /// 顺序 = 文字 → 输入框边框 → 选择高亮 → 组词下划线 → 插入符(对齐 Windows 绘制次序)。 + func paintTextEditingLayer(ctx: CGContext, view: OverlayScreenshotView) { + guard state == .textEditing else { return } + let fontPx = currentFontPx() + let c = SC.colorPresets[drawColorIdx] + let textColor = NSColor(srgbRed: CGFloat(c.r) / 255.0, green: CGFloat(c.g) / 255.0, + blue: CGFloat(c.b) / 255.0, alpha: 1.0) + let textX = textAnchorX - view.cgOrigin.x + let textY = textAnchorY - view.cgOrigin.y + let m = scMeasureTextMetrics(textBuf, fontPx: fontPx) + + // 1) 文字(与提交态 scDrawAnnotation 同一渲染函数,输入/提交视觉一致) + if !textBuf.isEmpty { + scDrawTextLine(ctx, text: textBuf, fontPx: fontPx, color: textColor, x: textX, y: textY) + } + + // 2) 输入框边框:字形紧凑包围盒 + padding 4(空缓冲最小宽 20,高度兜底字号; + // 与命中区 textEditingInputBox 同一公式,保证点击边框附近不误判框外) + let inkH = m.inkH > 0 ? m.inkH : fontPx + let glyphTop = textY + m.offY + let box = textEditingInputBox().offsetBy(dx: -view.cgOrigin.x, dy: -view.cgOrigin.y) + ctx.setStrokeColor(textColor.cgColor) + ctx.setLineWidth(1) + ctx.stroke(box) + + // 3) 选择高亮:区间 [widths[s], widths[e]] × 字形高(alpha 100 叠加,对齐 AlphaBlend) + if textSelStart >= 0 && textSelEnd >= 0 && textSelStart != textSelEnd, + textSelStart < m.widths.count, textSelEnd < m.widths.count { + let s = min(textSelStart, textSelEnd) + let e = max(textSelStart, textSelEnd) + let selRect = CGRect(x: textX + m.widths[s], y: glyphTop, + width: m.widths[e] - m.widths[s], height: inkH.rounded(.up)) + if selRect.width > 0 { + ctx.setFillColor(SC.textSelBg.cgColor) + ctx.fill(selRect) + } + } + + // 4) 组词串(marked text)下划线:区间宽度差画在字形底部下方(macOS 特有自绘, + // Windows 无组词显示——GCS_RESULTSTR 直接上屏) + if textHasMarkedText() { + let s = textMarkedRange.location + let e = s + textMarkedRange.length + if s >= 0, e < m.widths.count, m.widths[e] > m.widths[s] { + let underline = CGRect(x: textX + m.widths[s], y: glyphTop + inkH + 2, + width: m.widths[e] - m.widths[s], height: 1.5) + ctx.setFillColor(textColor.cgColor) + ctx.fill(underline) + } + } + + // 5) 插入符:x = 锚点 + widths[caret],高 = 字形高;几何始终缓存(含不可见帧, + // 对齐 lastCaretRect 语义)供闪烁/键系局部刷新 + let caretIdx = max(0, min(textCaretPos, m.widths.count - 1)) + let caretX = textX + m.widths[caretIdx] + lastCaretRect = CGRect(x: (caretX - 1) + view.cgOrigin.x, + y: glyphTop + view.cgOrigin.y, + width: 4, height: inkH.rounded(.up)) + if textCaretVisible { + ctx.setFillColor(textColor.cgColor) + ctx.fill(CGRect(x: caretX, y: glyphTop, width: 2, height: inkH.rounded(.up))) + } + } + + // MARK: 文字标注选中/拖动/回显(对齐 OnLButtonDown 文字命中分支) + + /// 选中文字标注的脏区矩形(清选中视觉时局部失效用;未选中返回 nil)。 + func selectedTextAnnotationDirtyRect() -> CGRect? { + guard selectedTextAnnotation >= 0 && selectedTextAnnotation < annotations.count else { return nil } + return scInflate(scMeasureTextAnnotationBox(annotations[selectedTextAnnotation]), SC.handleMargin) + } + + /// 选中文字标注并进入拖动(确认态点击命中文字):清非文字选中、文字工具回显 + ///(activeTool=文字、子菜单开、字号/颜色回显),随后拖动改锚点。 + /// - Parameters: + /// - index: 命中的文字标注索引 + /// - point: 按下点(CG 全局坐标) + func selectTextAnnotation(_ index: Int, at point: CGPoint) { + guard index >= 0 && index < annotations.count else { return } + let dirty = selectedTextAnnotationDirtyRect() ?? selectedAnnotationDirtyRect() + selectedAnnotation = -1 + hoveredAnnotation = -1 + selectedTextAnnotation = index + hoveredTextAnnotation = index + // 工具栏回显:文字工具高亮 + 字号/颜色子菜单回显该标注参数(对齐 EchoFontIdx/EchoColorIdx) + activeTool = .text + toolbar.openPopup(for: .text) + scEchoFontIdx(annotations[index].thickness) + scEchoColorIdx(annotations[index].color) + draggingTextAnnotation = index + textDragStartPoint = point + textDragStartAnchor = CGPoint(x: CGFloat(annotations[index].x1), y: CGFloat(annotations[index].y1)) + annotationOpHistoryPushed = false + lastAnnotationOpBox = .null // 进入文字标注拖拽:复位上帧脏区链(局部失效) + invalidate(dirty) + invalidateAll() + } + + /// 拖动文字标注:对按下时锚点快照做位移后写回;首次实际位移才入历史 + ///(对齐 OnMouseMove draggingTextAnnotation 分支)。 + /// - Parameter point: 鼠标 CG 全局坐标 + func applyTextAnnotationMoveDrag(_ point: CGPoint) { + guard draggingTextAnnotation >= 0 && draggingTextAnnotation < annotations.count else { return } + let idx = draggingTextAnnotation + let dx = Int((point.x - textDragStartPoint.x).rounded()) + let dy = Int((point.y - textDragStartPoint.y).rounded()) + if !annotationOpHistoryPushed && (dx != 0 || dy != 0) { + pushAnnotationHistory() + annotationOpHistoryPushed = true + } + annotations[idx].x1 = Int(textDragStartAnchor.x) + dx + annotations[idx].y1 = Int(textDragStartAnchor.y) + dy + // 局部脏区 = 上帧盒 ∪ 本帧盒,外扩手柄余量(性能审计:对齐 Windows + // InvalidateAnnotationOp 的局部失效,拖拽热路径不逐帧全屏重绘) + invalidateAnnotationOpLocal(newBox: scMeasureAnnotationBounds(annotations[idx])) + } + + // MARK: 编辑态子菜单点击(提交路径 1/2 的索引设置步) + + /// 编辑态点击字号/颜色格:仅设置索引(新属性将用于随后的提交,对齐 Windows 先设索引 + /// 再 CommitPendingTextAndExitEditing 的次序),提交由工具栏控制器紧接着触发。 + /// - Parameter hit: 子菜单命中码(>0 字号 / <0 颜色) + func applyTextEditingPopupSelection(_ hit: Int) { + guard state == .textEditing, toolbar.popupTool == .text else { return } + if hit > 0 && hit <= SC.fontSizes.count { + fontSizeIdx = hit - 1 + } else if hit < 0 && -hit <= SC.colorPresets.count { + drawColorIdx = -hit - 1 + } + } +} + +// MARK: - 参数回显(对齐 overlay_input_windows.cpp EchoFontIdx / EchoColorIdx / EchoThickIdx) + +extension ScreenshotOverlaySession { + /// 字号回显(会话内写回 fontSizeIdx;对齐 EchoFontIdx:顺序查找首个匹配,无匹配不变)。 + func scEchoFontIdx(_ fontSizePx: Int) { + for i in SC.fontSizes.indices where SC.fontSizes[i] == fontSizePx { + fontSizeIdx = i + break + } + toolbar.refresh() + } + + /// 颜色回显(会话内写回 drawColorIdx;对齐 EchoColorIdx)。 + func scEchoColorIdx(_ color: ScreenshotRGB) { + for i in SC.colorPresets.indices where SC.colorPresets[i] == color { + drawColorIdx = i + break + } + toolbar.refresh() + } + + /// 粗细回显(会话内写回 drawThickIdx;对齐 EchoThickIdx)。 + func scEchoThickIdx(_ thicknessPx: Int) { + for i in SC.thickPresets.indices where SC.thickPresets[i] == thicknessPx { + drawThickIdx = i + break + } + toolbar.refresh() + } +} + +// MARK: - NSTextInputClient(覆盖层视图承接系统输入法契约) + +/// 覆盖层视图的 NSTextInputClient 实现:AppKit 输入系统经 first responder(覆盖层视图) +/// 的 inputContext 与本会话交换文本。组词期间 IME 候选窗位置由 +/// firstRect(forCharacterRange:) 提供(返回插入符屏幕坐标)。 +extension OverlayScreenshotView: NSTextInputClient { + /// 已提交文本插入(直接键入字符与 IME 上屏共用入口;对齐 OnChar + GCS_RESULTSTR)。 + func insertText(_ aString: Any, replacementRange: NSRange) { + session.textInsertText(aString, replacementRange: replacementRange) + } + + /// 组词串更新(拼音/日文 IME 组合过程;会话自绘下划线高亮)。 + func setMarkedText(_ aString: Any, selectedRange: NSRange, replacementRange: NSRange) { + session.textSetMarkedText(aString, replacementRange: replacementRange) + } + + /// 组词终止:保留组词文本并清除 marked 标记。 + func unmarkText() { + session.textUnmark() + } + + /// 当前选中/插入符区间。 + func selectedRange() -> NSRange { + return session.textSelectedRange() + } + + /// 当前组词区间。 + func markedRange() -> NSRange { + return session.textCurrentMarkedRange() + } + + /// 是否有组词串。 + func hasMarkedText() -> Bool { + return session.textHasMarkedText() + } + + /// 取区间属性子串。 + func attributedSubstring(forProposedRange range: NSRange, + actualRange: NSRangePointer?) -> NSAttributedString? { + return session.textAttributedSubstring(for: range, actualRange: actualRange) + } + + /// 支持的组词属性(最小集:字体;IME 仅用于查询展示)。 + func validAttributesForMarkedText() -> [NSAttributedString.Key] { + return [.font] + } + + /// IME 候选窗定位:返回插入符矩形的屏幕坐标(视图本地 → 窗口基底 → convertToScreen)。 + /// 拼音组词时候选窗跟随插入符的关键回调。 + func firstRect(forCharacterRange range: NSRange, actualRange: NSRangePointer?) -> NSRect { + let cg = session.textCaretRectGlobal() + let local = CGRect(x: cg.minX - cgOrigin.x, y: cg.minY - cgOrigin.y, + width: cg.width, height: cg.height) + guard let window = window else { return NSRect.zero } + return window.convertToScreen(convert(local, to: nil)) + } + + /// 命中点 → 字符索引(点选定位;参数为窗口基底坐标,换算 CG 后按 x 就近取边界)。 + func characterIndex(for point: NSPoint) -> Int { + let local = convert(point, from: nil) + let cg = CGPoint(x: (local.x + cgOrigin.x).rounded(), y: (local.y + cgOrigin.y).rounded()) + return session.textCharacterIndex(at: cg) + } +} diff --git a/src/screenshot/macos/ScreenshotToolbarMac.swift b/src/screenshot/macos/ScreenshotToolbarMac.swift new file mode 100644 index 0000000..5079be4 --- /dev/null +++ b/src/screenshot/macos/ScreenshotToolbarMac.swift @@ -0,0 +1,1293 @@ +import Foundation +import AppKit + +// MARK: - 工具栏 / 子菜单 / tooltip(macOS) +// +// Windows 版把工具栏/子菜单/tooltip 画进覆盖层 backDC(overlay_ui_windows.cpp);macOS +// 覆盖层是每屏一个的独立 NSWindow(无法跨窗口绘制),故改为三个独立无边框面板窗口: +// - 工具栏窗口:白底圆角 16 格按钮条 + 最左 6 点拖拽把手(toolbarPlaced 语义) +// - 子菜单窗口:矢量 = [粗细×3]|[颜色×8];文字 = [字号×3]|[颜色×8]; +// 马赛克 = [涂抹|框选]|[块大小×3]|[涂抹半径×3](单行,按来源工具取布局) +// - tooltip 窗口:悬停 500ms 深色圆角气泡(pumpTick 轮询驱动,对齐 TickToolbarTooltip) +// +// 窗口规格(面板族):borderless、canJoinAllSpaces/fullScreenAuxiliary、 +// canBecomeKey=false(点击仍可接收 mouseDown,但不抢键盘焦点——键盘仍归覆盖层视图)。 +// 层级比覆盖层高 1~3 级:覆盖层窗口点击会抬到同层最前,浮层若同层会被整屏覆盖层 +// 视图盖住(覆盖层窗口铺满每块屏幕),故工具栏/子菜单/tooltip 依次抬高。 +// +// 坐标系:内部全部使用 CG 全局逻辑坐标,窗口定位时翻转为 NS 坐标。 +// 工具栏/子菜单窗口视图均 isFlipped,本地 (0,0) 即各自矩形左上角。 + +// MARK: - 常量(Windows 出处集中标注) + +extension SC { + // ---- 工具栏几何(internal.h: SC_TOOLBAR_*)---- + /// 按钮尺寸(正方形;SC_TOOLBAR_BTN = 32) + static let toolbarBtn: CGFloat = 32 + /// 按钮↔工具栏边缘内边距(四边一致;SC_TOOLBAR_PAD = 6) + static let toolbarPad: CGFloat = 6 + /// 工具栏高度 = 按钮 + 上下内边距(SC_TOOLBAR_H = 44) + static let toolbarH: CGFloat = toolbarBtn + toolbarPad * 2 + /// 按钮间距(SC_TOOLBAR_GAP = 1) + static let toolbarGap: CGFloat = 1 + /// 工具栏圆角(SC_TOOLBAR_RADIUS = 8) + static let toolbarRadius: CGFloat = 8 + /// 选区到工具栏间距(SC_TOOLBAR_MARGIN = 6) + static let toolbarMargin: CGFloat = 6 + /// 工具栏边框(SC_TOOLBAR_BORDER = 1) + static let toolbarBorderW: CGFloat = 1 + /// 图标绘制逻辑边长(CalcToolbarMetrics.iconSize = btn - 8 + 2 = 26) + static let toolbarIconSize: CGFloat = 26 + + // ---- 工具栏主题色(internal.h 主题色常量组 + icons/overlay_ui 各绘制点)---- + /// 工具栏选中态图标蓝(SC_THEME_TOOLBAR_BLUE = #3B8BF2,配浅蓝高亮底) + static let toolbarBlue = NSColor(srgbRed: 0x3B / 255.0, green: 0x8B / 255.0, blue: 0xF2 / 255.0, alpha: 1.0) + /// 图标普通态深灰(icons_windows.cpp darkColor = RGB(60,60,60)) + static let iconDark = NSColor(srgbRed: 60.0 / 255.0, green: 60.0 / 255.0, blue: 60.0 / 255.0, alpha: 1.0) + /// 选中态浅蓝高亮底(SC_THEME_SEL_BG = RGB(225,237,253),主题蓝叠白底 ~15% 预混合) + static let toolbarSelBg = NSColor(srgbRed: 225.0 / 255.0, green: 237.0 / 255.0, blue: 253.0 / 255.0, alpha: 1.0) + /// hover 态极浅蓝底(SC_THEME_HOVER_BG = RGB(235,243,255)) + static let toolbarHoverBg = NSColor(srgbRed: 235.0 / 255.0, green: 243.0 / 255.0, blue: 255.0 / 255.0, alpha: 1.0) + /// 工具栏/子菜单 1px 浅灰边框(DrawToolbar/DrawPopup borderPen = RGB(210,210,210)) + static let toolbarBorderCol = NSColor(srgbRed: 210.0 / 255.0, green: 210.0 / 255.0, blue: 210.0 / 255.0, alpha: 1.0) + /// 工具栏分隔线(gdi.toolbarSepPen = RGB(230,230,230)) + static let toolbarSepCol = NSColor(srgbRed: 230.0 / 255.0, green: 230.0 / 255.0, blue: 230.0 / 255.0, alpha: 1.0) + /// 把手 6 点圆点(DrawToolbarGrip dotBrush = RGB(165,165,165)) + static let gripDotCol = NSColor(srgbRed: 165.0 / 255.0, green: 165.0 / 255.0, blue: 165.0 / 255.0, alpha: 1.0) + + // ---- 子菜单几何(internal.h: SC_POPUP_*;单元格 = 工具栏按钮便于视觉对齐)---- + /// 单元格尺寸(SC_POPUP_CELL = SC_TOOLBAR_BTN = 32) + static let popupCell: CGFloat = 32 + /// 内边距(SC_POPUP_PAD = 4) + static let popupPad: CGFloat = 4 + /// 圆角(SC_POPUP_RADIUS = 8) + static let popupRadius: CGFloat = 8 + /// 颜色圆点直径(SC_POPUP_COLOR_DOT = 18) + static let popupColorDot: CGFloat = 18 + /// 分隔线两侧间距(SC_POPUP_SEP_GAP = 6) + static let popupSepGap: CGFloat = 6 + /// 分隔线高度(SC_POPUP_SEP_H = 20) + static let popupSepH: CGFloat = 20 + /// 边框(SC_POPUP_BORDER = 1) + static let popupBorderW: CGFloat = 1 + /// 工具栏与子菜单间距(SC_POPUP_MARGIN = 4) + static let popupMargin: CGFloat = 4 + /// 子菜单图标默认深灰(SC_THEME_ICON_DARK = #333333,与预设「黑」同值) + static let popupIconDark = NSColor(srgbRed: 0x33 / 255.0, green: 0x33 / 255.0, blue: 0x33 / 255.0, alpha: 1.0) + /// 分隔线颜色(DrawPopup sepPen = RGB(220,220,220)) + static let popupSepCol = NSColor(srgbRed: 220.0 / 255.0, green: 220.0 / 255.0, blue: 220.0 / 255.0, alpha: 1.0) + + // ---- 工具栏 tooltip(overlay_ui_windows.cpp: SC_TIP_*)---- + /// 悬停多久后显示(SC_TIP_DELAY_MS = 500,网页 title 同款节奏) + static let tipDelaySec: TimeInterval = 0.5 + /// 气泡与锚点按钮的间距(SC_TIP_GAP = 6) + static let tipGap: CGFloat = 6 + /// 气泡水平内边距(SC_TIP_PAD_X = 8) + static let tipPadX: CGFloat = 8 + /// 气泡垂直内边距(SC_TIP_PAD_Y = 5) + static let tipPadY: CGFloat = 5 + /// 气泡圆角半径(SC_TIP_RADIUS = 4) + static let tipRadius: CGFloat = 4 + /// 气泡深色底(DrawToolbarTooltip bg = RGB(41,41,41)) + static let tipBg = NSColor(srgbRed: 41.0 / 255.0, green: 41.0 / 255.0, blue: 41.0 / 255.0, alpha: 1.0) + /// 气泡定位的屏幕边距(TickToolbarTooltip 内 4px 钳制基准) + static let tipEdgeClamp: CGFloat = 4 +} + +// MARK: - 工具栏按钮枚举(对齐 Windows internal.h ToolButton) + +/// 工具栏按钮(对齐 Windows internal.h 的 ToolButton 枚举 raw 值;最左「6 点拖拽把手」 +/// 不占枚举位,命中码用负值区分,对齐 SC_TB_GRIP = -2)。 +enum ScreenshotToolButton: Int { + case drag = 0 // TB_Drag 拖拽 + case rect // TB_Rect 矩形 + case circle // TB_Circle 圆形(含椭圆) + case arrow // TB_Arrow 箭头 + case brush // TB_Brush 画笔 + case mosaic // TB_Mosaic 马赛克 + case text // TB_Text 文字 + case translate // TB_Translate 翻译(占位,无点击处理,与 Windows 一致) + case longCapture // TB_LongCapture 长截图 + case separator1 // TB_Separator1 分隔线 + case undo // TB_Undo 撤销 + case redo // TB_Redo 重做 + case separator2 // TB_Separator2 分隔线 + case save // TB_Save 保存到本地 + case cancel // TB_Cancel 取消 + case confirm // TB_Confirm 确定 + + /// 按钮总数(internal.h: TB_Count) + static let count = 16 + /// 「6 点拖拽把手」命中码(internal.h: SC_TB_GRIP = -2;取负值与按钮索引区分) + static let gripHit = -2 + + /// 是否为分隔线格(绘制竖线、无命中)。 + var isSeparator: Bool { self == .separator1 || self == .separator2 } + + /// 是否为可绘制矢量工具(对齐 IsVectorTool)。 + var isVectorTool: Bool { + self == .rect || self == .circle || self == .arrow || self == .brush + } + + /// 对应的标注类型(仅矢量工具有效;对齐 ToolToAnnotationType)。 + var annotationType: ScreenshotAnnotationType { + switch self { + case .rect: return .rect + case .circle: return .circle + case .arrow: return .arrow + case .brush: return .brush + default: return .rect + } + } +} + +/// 标注类型 → 工具按钮(对齐 AnnotationTypeToTool 的逆映射;马赛克不可选中、 +/// 无对应工具按钮返回 nil,等价 Windows 的 -1)。 +func scAnnotationTypeToTool(_ t: ScreenshotAnnotationType) -> ScreenshotToolButton? { + switch t { + case .rect: return .rect + case .circle: return .circle + case .arrow: return .arrow + case .brush: return .brush + case .text: return .text + case .mosaic: return nil + } +} + +extension ScreenshotToolButton { + /// 按钮 → 图标枚举的显式映射(分隔线无图标返回 nil)。注意 ToolButton raw 值含 + /// 分隔线占位(9/12)而 SCToolbarIcon raw 值连续,二者不能直接 rawValue 互查。 + var toolbarIcon: SCToolbarIcon? { + switch self { + case .drag: return .drag + case .rect: return .rect + case .circle: return .circle + case .arrow: return .arrow + case .brush: return .brush + case .mosaic: return .mosaic + case .text: return .text + case .translate: return .translate + case .longCapture: return .longCapture + case .undo: return .undo + case .redo: return .redo + case .save: return .save + case .cancel: return .cancel + case .confirm: return .confirm + default: return nil // 分隔线 + } + } +} + +// MARK: - 面板窗口与视图 + +/// 浮层面板窗口(工具栏/子菜单/tooltip 共用):无边框、不可 key/main——点击仍可接收 +/// mouseDown(AppKit 把点击派发给该窗口),但不抢键盘焦点(键盘仍归覆盖层视图)。 +final class ScreenshotPanelWindow: NSWindow { + override var canBecomeKey: Bool { return false } + override var canBecomeMain: Bool { return false } +} + +/// 工具栏自绘视图:把事件换算为 CG 全局坐标转发给控制器,绘制委托控制器完成。 +final class ToolbarPanelView: NSView { + unowned let controller: ScreenshotToolbarController + + init(controller: ScreenshotToolbarController, frame: NSRect) { + self.controller = controller + super.init(frame: frame) + } + + required init?(coder: NSCoder) { + fatalError("ToolbarPanelView is created programmatically only") + } + + override var isFlipped: Bool { return true } // 本地坐标与 CG 同向(左上原点、Y 向下) + + // 首击穿透:与 OverlayScreenshotView.acceptsFirstMouse 同因——App 未激活(协作式激活 + // 失败)时,非 key 浮层窗口的首次点击会被 AppKit 当"激活点击"吞掉,工具栏按钮 + // 第一次点按无响应;覆写后首击直达本视图(浮层自身不抢 key,仅放行鼠标事件)。 + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { return true } + + override func draw(_ dirtyRect: NSRect) { + guard let ctx = NSGraphicsContext.current?.cgContext else { return } + controller.drawToolbar(ctx) + } + + /// 本地坐标 → CG 全局坐标(视图 (0,0) = 工具栏矩形左上角)。 + private func cgPoint(from event: NSEvent) -> CGPoint { + let local = convert(event.locationInWindow, from: nil) + return CGPoint(x: (local.x + controller.toolbarRect.minX).rounded(), + y: (local.y + controller.toolbarRect.minY).rounded()) + } + + override func mouseDown(with event: NSEvent) { + controller.handleToolbarMouseDown(cgPoint(from: event)) + } + + override func mouseDragged(with event: NSEvent) { + controller.handleToolbarMouseDragged(cgPoint(from: event)) + } + + override func mouseUp(with event: NSEvent) { + controller.handleToolbarMouseUp() + } +} + +/// 子菜单自绘视图:绘制委托控制器完成;点击换算 CG 坐标做命中码测试。 +final class PopupPanelView: NSView { + unowned let controller: ScreenshotToolbarController + + init(controller: ScreenshotToolbarController, frame: NSRect) { + self.controller = controller + super.init(frame: frame) + } + + required init?(coder: NSCoder) { + fatalError("PopupPanelView is created programmatically only") + } + + override var isFlipped: Bool { return true } + + // 首击穿透:同 ToolbarPanelView——App 未激活时子菜单首次点击会被当"激活点击"吞掉。 + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { return true } + + override func draw(_ dirtyRect: NSRect) { + guard let ctx = NSGraphicsContext.current?.cgContext else { return } + controller.drawPopup(ctx) + } + + override func mouseDown(with event: NSEvent) { + let local = convert(event.locationInWindow, from: nil) + let cg = CGPoint(x: (local.x + controller.popupRect.minX).rounded(), + y: (local.y + controller.popupRect.minY).rounded()) + controller.handlePopupMouseDown(cg) + } +} + +/// tooltip 气泡自绘视图:深色圆角底 + 白色居中文本(对齐 DrawToolbarTooltip)。 +final class TooltipPanelView: NSView { + let text: String + + init(text: String, frame: NSRect) { + self.text = text + super.init(frame: frame) + } + + required init?(coder: NSCoder) { + fatalError("TooltipPanelView is created programmatically only") + } + + override var isFlipped: Bool { return true } + + override func draw(_ dirtyRect: NSRect) { + guard let ctx = NSGraphicsContext.current?.cgContext else { return } + let path = CGPath(roundedRect: bounds, cornerWidth: SC.tipRadius, + cornerHeight: SC.tipRadius, transform: nil) + ctx.addPath(path) + ctx.setFillColor(SC.tipBg.cgColor) + ctx.fillPath() + + // 白色居中文本(12px 系统字体,对齐 DrawToolbarTooltip 的 12*ds 字号) + let attr = NSAttributedString(string: text, attributes: [ + .font: NSFont.systemFont(ofSize: SC.fontPx), + .foregroundColor: NSColor.white, + ]) + let size = attr.size() + attr.draw(at: NSPoint(x: (bounds.width - ceil(size.width)) / 2, + y: (bounds.height - ceil(size.height)) / 2)) + } +} + +// MARK: - 浮层位置计算(对齐 overlay_ui_windows.cpp Calc* 系列) + +/// 取包含参考矩形的显示器 CG 边界(GetMonitorBoundsForRect 移植):按相交面积取最大, +/// 无相交退化为包含参考矩形中心的屏。多屏异分辨率下工具栏/子菜单的上下翻转与左右 +/// 钳制必须以此为界(整虚拟屏包络会被高分屏拉大,低分屏误判"下方放得下")。 +/// - Parameter rect: 参考矩形(CG 全局坐标) +/// - Returns: 显示器 CG 边界;查询失败返回 nil(调用方回退虚拟屏) +func scMonitorBounds(for rect: CGRect) -> CGRect? { + var best: (CGRect, CGFloat)? + for screen in NSScreen.screens { + let cg = ScreenshotGeometry.cgFrame(of: screen) + let inter = cg.intersection(rect) + let area = inter.isNull ? 0 : inter.width * inter.height + if area > 0 && (best == nil || area > best!.1) { + best = (cg, area) + } + } + if let b = best { return b.0 } + let center = CGPoint(x: rect.midX, y: rect.midY) + for screen in NSScreen.screens { + let cg = ScreenshotGeometry.cgFrame(of: screen) + if cg.contains(center) { return cg } + } + return nil +} + +/// 计算工具栏位置(CalcToolbarPosition 移植):选区下方 6px 居中 → 放不下上方 → +/// 再放不下选区内底边;垂直/水平边界取「选区所在显示器」而非整虚拟屏。 +/// - Parameters: +/// - selection: 选区(CG 全局坐标) +/// - virtual: 虚拟屏并集(回退边界) +/// - monitor: 选区所在显示器边界(nil 时回退 virtual) +/// - Returns: 工具栏矩形(CG 全局坐标) +func scCalcToolbarRect(selection: CGRect, virtual: CGRect, monitor: CGRect?) -> CGRect { + let tw = ScreenshotToolbarController.toolbarWidth + let th = SC.toolbarH + let margin = SC.toolbarMargin + let bounds = monitor ?? virtual + + // 默认水平居中于选区,下方 + var x = selection.minX + (selection.width - tw) / 2 + var y = selection.maxY + margin + // 下方放不下 -> 上方(选区触到所在显示器底边即触发) + if y + th > bounds.maxY { + y = selection.minY - margin - th + } + // 上方也放不下(选区纵向占满屏幕),贴近底部(选区内底边) + if y < bounds.minY { + y = selection.maxY - margin - th + if y < selection.minY { y = selection.minY + margin } + // 极端兜底:钳回显示器范围内(选区+工具栏高过屏幕等病态情形) + if y + th > bounds.maxY { y = bounds.maxY - th } + if y < bounds.minY { y = bounds.minY } + } + // 水平边界约束(同显示器内;跨屏时钳制到选区所在屏) + if x + tw > bounds.maxX { x = bounds.maxX - tw - margin } + if x < bounds.minX { x = bounds.minX + margin } + return CGRect(x: x, y: y, width: tw, height: th) +} + +/// 子菜单总尺寸(CalcPopupSize / CalcMosaicPopupSize 移植;按子菜单来源工具取布局): +/// - 矢量/文字:单行 [粗细×3 或 字号×3]|分隔线|[颜色×8] +/// - 马赛克:单行 [涂抹|框选]|分隔线|[块大小×3]|分隔线|[涂抹半径×3] +/// 单元格间距沿用工具栏按钮间距,cell == SC_POPUP_CELL 时 PopupCellGap = 1。 +func scCalcPopupSize(for tool: ScreenshotToolButton?) -> CGSize { + let cellGap = SC.toolbarGap + let sepW = SC.popupSepGap * 2 + 1 // 每组分隔线宽 = sepGap*2 + 1 + let contentW: CGFloat + switch tool { + case .mosaic: + let modeW = CGFloat(SC.mosaicModeCount) * SC.popupCell + CGFloat(SC.mosaicModeCount - 1) * cellGap + let sizeW = CGFloat(SC.mosaicSizes.count) * SC.popupCell + CGFloat(SC.mosaicSizes.count - 1) * cellGap + let radiusW = CGFloat(SC.mosaicRadii.count) * SC.popupCell + CGFloat(SC.mosaicRadii.count - 1) * cellGap + contentW = modeW + sepW + sizeW + sepW + radiusW + default: + let firstCount = (tool == .text) ? CGFloat(SC.fontSizes.count) : CGFloat(SC.thickPresets.count) + let colorCount = CGFloat(SC.colorPresets.count) + let firstW = firstCount * SC.popupCell + (firstCount - 1) * cellGap + let colorW = colorCount * SC.popupCell + (colorCount - 1) * cellGap + contentW = firstW + sepW + colorW + } + let w = contentW + SC.popupPad * 2 + SC.popupBorderW * 2 + let h = SC.popupCell + SC.popupPad * 2 + SC.popupBorderW * 2 + return CGSize(width: w, height: h) +} + +/// 计算子菜单位置(CalcPopupPlacement 移植):贴工具栏下方,放不下翻上方, +/// 左右钳制到工具栏所在显示器。尺寸按子菜单来源工具取布局。 +/// - Parameters: +/// - toolbarRect: 工具栏矩形(CG 全局坐标) +/// - virtual: 虚拟屏并集(回退边界) +/// - monitor: 工具栏所在显示器边界(nil 时回退 virtual) +/// - tool: 子菜单来源工具(决定总尺寸) +/// - Returns: 子菜单矩形(CG 全局坐标) +func scCalcPopupRect(toolbarRect: CGRect, virtual: CGRect, monitor: CGRect?, + tool: ScreenshotToolButton?) -> CGRect { + let size = scCalcPopupSize(for: tool) + let margin = SC.popupMargin + let bounds = monitor ?? virtual + // 水平:与工具栏左对齐;垂直:优先工具栏下方 + var x = toolbarRect.minX + var y = toolbarRect.maxY + margin + if y + size.height > bounds.maxY { + y = toolbarRect.minY - margin - size.height + } + if x + size.width > bounds.maxX { x = bounds.maxX - size.width - margin } + if x < bounds.minX { x = bounds.minX + margin } + return CGRect(origin: CGPoint(x: x, y: y), size: size) +} + +// MARK: - 工具栏控制器 + +/// 工具栏/子菜单/tooltip 面板族控制器:持有三个独立 NSWindow 与全部 UI 状态, +/// 按钮动作回话给会话(ScreenshotOverlaySession)。生命周期挂会话 start/finish: +/// 首次进入确认态时创建窗口,finish 统一销毁。 +/// 状态字段命名对齐 CaptureContext:toolbarRect/toolbarPlaced/toolbarDragging/ +/// hoverToolbarBtn/tipBtn/tipShown/popupOpen/popupTool/popupRect。 +final class ScreenshotToolbarController { + /// 控制器可访问的会话(会话持有控制器,控制器反向 weak 防环)。 + private weak var session: ScreenshotOverlaySession? + + // ---- 三个浮层窗口(懒创建;finish 统一销毁)---- + private var toolbarWindow: ScreenshotPanelWindow? + private var toolbarView: ToolbarPanelView? + private var popupWindow: ScreenshotPanelWindow? + private var popupView: PopupPanelView? + private var tipWindow: ScreenshotPanelWindow? + // 保存对话框模态期间被降级的浮层窗口层级快照(恢复用,见 setPanelLevelBelowModal) + private var savedPanelLevels: [ObjectIdentifier: NSWindow.Level] = [:] + + // ---- 工具栏状态(CG 全局坐标)---- + /// 工具栏矩形(CG 全局坐标;绘制/命中/窗口定位共用) + var toolbarRect: CGRect = .null + /// 用户按住把手拖动过后置位:此后不再随选区自动重算位置(对齐 toolbarPlaced 语义) + var toolbarPlaced = false + /// 正在拖动工具栏(把手按下未松开) + var toolbarDragging = false + private var toolbarDragStartPoint: CGPoint = .zero // 按下时鼠标位置(CG) + private var toolbarDragStartRect: CGRect = .null // 按下时的工具栏矩形 + /// hover 按钮(ScreenshotToolButton.rawValue;-1 无;SC_TB_GRIP 码 = 把手) + var hoverBtn = -1 + /// activeTool 高亮(会话状态的镜像引用入口,直接读 session) + + // ---- 子菜单状态 ---- + /// 子菜单是否打开 + var popupOpen = false + /// 子菜单对应的工具来源(对齐 popupTool;仅矢量工具) + var popupTool: ScreenshotToolButton? + /// 子菜单矩形(CG 全局坐标) + var popupRect: CGRect = .null + + // ---- tooltip 状态(对齐 tipBtn/tipDwellSince/tipShown/tipBubbleRect/tipText)---- + private var tipBtn = -1 // 当前停顿目标(-1 无;把手用 gripHit 码) + private var tipDwellSince: TimeInterval = 0 + private var tipShown = false + private var tipText = "" + + /// 工具栏总宽(CalcToolbarWidth 移植):把手格 + 全部按钮格 + 间距 + 内边距 + 边框。 + /// 最左为把手单元格(与按钮同宽),各按钮自第 1 格起排布。 + static var toolbarWidth: CGFloat { + return CGFloat(ScreenshotToolButton.count + 1) * (SC.toolbarBtn + SC.toolbarGap) + - SC.toolbarGap + SC.toolbarPad * 2 + SC.toolbarBorderW * 2 + } + + init(session: ScreenshotOverlaySession) { + self.session = session + } + + /// 会话是否进行中(事件入口守卫,收束后残余事件不写状态)。 + private var isRunning: Bool { session?.isRunning ?? false } + + // MARK: 窗口创建/销毁/显隐 + + /// 确保三个浮层窗口已创建(首次进入确认态调用)。窗口规格见文件头注释。 + private func ensureWindows() { + guard toolbarWindow == nil, session != nil else { return } + + let toolbarWin = ScreenshotPanelWindow( + contentRect: NSRect(origin: .zero, size: toolbarRect.size), + styleMask: .borderless, backing: .buffered, defer: false) + toolbarWin.level = NSWindow.Level(rawValue: NSWindow.Level.screenSaver.rawValue + 2) + toolbarWin.isOpaque = false + toolbarWin.backgroundColor = .clear + toolbarWin.hasShadow = false + toolbarWin.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + toolbarWin.isReleasedWhenClosed = false + let tView = ToolbarPanelView(controller: self, frame: NSRect(origin: .zero, size: toolbarRect.size)) + toolbarWin.contentView = tView + toolbarWindow = toolbarWin + toolbarView = tView + + let popupSize = scCalcPopupSize(for: popupTool) + let popupWin = ScreenshotPanelWindow( + contentRect: NSRect(origin: .zero, size: popupSize), + styleMask: .borderless, backing: .buffered, defer: false) + popupWin.level = NSWindow.Level(rawValue: NSWindow.Level.screenSaver.rawValue + 3) + popupWin.isOpaque = false + popupWin.backgroundColor = .clear + popupWin.hasShadow = false + popupWin.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + popupWin.isReleasedWhenClosed = false + let pView = PopupPanelView(controller: self, frame: NSRect(origin: .zero, size: popupSize)) + popupWin.contentView = pView + popupWindow = popupWin + popupView = pView + } + + /// 销毁全部浮层窗口(会话 finish 调用;orderOut + 置 nil 释放内容视图)。 + func destroy() { + for win in [toolbarWindow, popupWindow, tipWindow] { + win?.orderOut(nil) + win?.contentView = nil + } + toolbarWindow = nil + toolbarView = nil + popupWindow = nil + popupView = nil + tipWindow = nil + toolbarPlaced = false + toolbarDragging = false + hoverBtn = -1 + popupOpen = false + popupTool = nil + popupRect = .null + tipBtn = -1 + tipShown = false + toolbarRect = .null + } + + /// 工具栏/子菜单/tooltip 浮层族窗口层级临时降级/恢复(保存对话框弹出前/关闭后调用; + /// 由会话 duckOverlayLevelsForSaveModal 统一调度)。对齐 Windows 摘除 TOPMOST 语义: + /// Windows 工具栏画在覆盖层窗口内随之失去 TOPMOST;macOS 浮层为独立窗口需同步降级, + /// 否则保存面板(NSModalPanelWindowLevel)会被 screenSaver+n 层的浮层遮挡。 + /// - Parameter lowered: true 降级到 SC.saveModalDuckLevel(记录原层级); + /// false 恢复(窗口已销毁时安全 no-op) + func setPanelLevelBelowModal(_ lowered: Bool) { + let panels = [toolbarWindow, popupWindow, tipWindow].compactMap { $0 } + if lowered { + savedPanelLevels.removeAll() + for panel in panels { + savedPanelLevels[ObjectIdentifier(panel)] = panel.level + panel.level = SC.saveModalDuckLevel + } + } else { + for panel in panels { + if let level = savedPanelLevels[ObjectIdentifier(panel)] { + panel.level = level + } + } + savedPanelLevels.removeAll() + } + } + + /// 按会话状态同步浮层显隐(pumpTick 逐拍调用,等价 OnPaint 的状态分支): + /// 确认/整体拖动/绘制态显示工具栏(对齐 Windows Confirmed/Moving/Drawing 显示), + /// 调整态隐藏(避免手柄附近抖动,对齐 OnPaint CS_Resizing 不绘制工具栏)。 + /// 首次显示时先定位再懒创建窗口(生命周期挂进会话确认态入口)。 + /// - Parameter visible: 是否应显示 + func syncVisibility(_ visible: Bool) { + if visible { + if toolbarWindow == nil { + syncPlacement() // 先算出工具栏矩形(窗口尺寸取自该矩形) + ensureWindows() + } + guard let toolbarWindow = toolbarWindow, !toolbarRect.isNull else { return } + if !toolbarWindow.isVisible { + placeWindow(toolbarWindow, at: toolbarRect) + toolbarWindow.orderFrontRegardless() + } + } else { + if let toolbarWindow = toolbarWindow, toolbarWindow.isVisible { + toolbarWindow.orderOut(nil) + } + closePopup() + hideTip() + } + } + + /// 同步工具栏自动跟随位置(!toolbarPlaced 时随选区重算,对齐 OnPaint 的 + /// CalcToolbarPosition 分支);子菜单锚定工具栏同步移动。位置无变化时不动窗口。 + func syncPlacement() { + guard let session = session, !toolbarPlaced, !toolbarDragging, + let monitor = scMonitorBounds(for: session.selection) else { return } + let target = scCalcToolbarRect(selection: session.selection, virtual: session.virtualBounds, + monitor: monitor) + if target != toolbarRect { + let wasOpen = popupOpen + toolbarRect = target + if let win = toolbarWindow { + placeWindow(win, at: toolbarRect) + toolbarWindow?.orderFrontRegardless() // 覆盖层点击后同层抬升的防御 + } + if wasOpen { syncPopupPlacement() } + } + } + + /// CG 全局矩形 → NS 窗口 frame(Y 翻转:NS 原点在主屏左下)。 + private func placeWindow(_ window: NSWindow, at rect: CGRect) { + let top = ScreenshotGeometry.primaryScreenHeight() - rect.minY + window.setFrame(NSRect(x: rect.minX, y: top - rect.height, + width: rect.width, height: rect.height), display: true) + } + + // MARK: 子菜单 + + /// 打开子菜单并锚定当前工具栏(对齐「工具切换打开子菜单」路径)。 + /// 支持矢量(粗细/颜色)、文字(字号/颜色)、马赛克(三段)三类子菜单。 + /// - Parameter tool: 子菜单来源工具 + func openPopup(for tool: ScreenshotToolButton) { + guard tool.isVectorTool || tool == .text || tool == .mosaic else { return } + popupTool = tool + popupOpen = true + ensureWindows() + syncPopupPlacement() + } + + /// 关闭子菜单。 + func closePopup() { + guard popupOpen || popupTool != nil else { return } + popupOpen = false + popupTool = nil + popupWindow?.orderOut(nil) + } + + /// 重新计算子菜单位置并移动窗口(工具栏移动/打开时调用);总尺寸随子菜单类型取布局 + ///(马赛克三段更宽,打开/切换时需重设窗口 frame)。 + private func syncPopupPlacement() { + guard popupOpen, let monitor = scMonitorBounds(for: toolbarRect) else { return } + popupRect = scCalcPopupRect(toolbarRect: toolbarRect, + virtual: session?.virtualBounds ?? .zero, + monitor: monitor, tool: popupTool) + if let popupWindow = popupWindow { + placeWindow(popupWindow, at: popupRect) + popupWindow.orderFrontRegardless() + } + } + + // MARK: 命中测试(对齐 HitTestToolbar / HitTestPopup) + + /// 命中测试工具栏(HitTestToolbar 移植)。 + /// - Parameter point: 鼠标 CG 全局坐标 + /// - Returns: -1 未命中;ScreenshotToolButton.gripHit 把手;>=0 按钮 raw 值 + func hitTestToolbar(_ point: CGPoint) -> Int { + guard scPointInRect(point, toolbarRect) else { return -1 } + let idx = Int((point.x - toolbarRect.minX - SC.toolbarBorderW - SC.toolbarPad) + / (SC.toolbarBtn + SC.toolbarGap)) + if idx < 0 || idx > ScreenshotToolButton.count { return -1 } + if idx == 0 { return ScreenshotToolButton.gripHit } // 第 0 格 = 拖拽把手 + return idx - 1 // 其后依次为各工具按钮 + } + + /// 第 N 格(0=把手,按钮自 1 起)的按钮区矩形(CG 全局坐标;tooltip 锚点用)。 + private func cellRect(_ cell: Int) -> CGRect { + let bx = toolbarRect.minX + SC.toolbarBorderW + SC.toolbarPad + + CGFloat(cell) * (SC.toolbarBtn + SC.toolbarGap) + let by = toolbarRect.minY + (SC.toolbarH - SC.toolbarBtn) / 2 + return CGRect(x: bx, y: by, width: SC.toolbarBtn, height: SC.toolbarBtn) + } + + /// 命中测试子菜单(HitTestPopup / HitTestMosaicPopup 移植,按子菜单来源工具分支)。 + /// 矢量/文字(字号)命中码约定: + /// +1..+3 = 第 N 个粗细(文字工具时为第 N 个字号);-1..-8 = 第 N 个颜色(负为索引+1); + /// 0 = 未命中(含点在分隔线上)。 + /// 马赛克命中码约定(HitTestMosaicPopup): + /// +1 = 涂抹模式;+2 = 框选模式;+101.. = 第 N 个块大小;+201.. = 第 N 个涂抹半径。 + /// - Parameter point: 鼠标 CG 全局坐标 + /// - Returns: 命中码 + func hitTestPopup(_ point: CGPoint) -> Int { + guard scPointInRect(point, popupRect) else { return 0 } + let contentLeft = popupRect.minX + SC.popupBorderW + SC.popupPad + let contentTop = popupRect.minY + SC.popupBorderW + SC.popupPad + let cellGap = SC.toolbarGap // PopupCellGap(cell == SC_POPUP_CELL 时 = 工具栏间距) + if point.y < contentTop || point.y >= contentTop + SC.popupCell { return 0 } + + // ---- 马赛克三段子菜单:[涂抹|框选]|分隔线|[块大小×3]|分隔线|[涂抹半径×3] ---- + if popupTool == .mosaic { + // 模式组 + for i in 0..= ix && point.x < ix + SC.popupCell { return i + 1 } // +1 涂抹 +2 框选 + } + let modeEndX = contentLeft + CGFloat(SC.mosaicModeCount) * SC.popupCell + + CGFloat(SC.mosaicModeCount - 1) * cellGap + let sizeStartX = modeEndX + SC.popupSepGap * 2 + 1 + if point.x < sizeStartX { return 0 } // 第一条分隔线区域 + // 块大小组 + let sizeEndX = sizeStartX + CGFloat(SC.mosaicSizes.count) * SC.popupCell + + CGFloat(SC.mosaicSizes.count - 1) * cellGap + for i in SC.mosaicSizes.indices { + let ix = sizeStartX + CGFloat(i) * (SC.popupCell + cellGap) + if point.x >= ix && point.x < ix + SC.popupCell { + return SC.mosaicHitSizeBase + i + 1 + } + } + // 涂抹半径组 + let radiusStartX = sizeEndX + SC.popupSepGap * 2 + 1 + if point.x < radiusStartX { return 0 } // 第二条分隔线区域 + for i in SC.mosaicRadii.indices { + let ix = radiusStartX + CGFloat(i) * (SC.popupCell + cellGap) + if point.x >= ix && point.x < ix + SC.popupCell { + return SC.mosaicHitRadiusBase + i + 1 + } + } + return 0 + } + + // ---- 矢量/文字单行子菜单:[粗细×3 或 字号×3]|分隔线|[颜色×8] ---- + // 第一组(文字工具时为字号索引,矢量工具时为粗细索引) + let firstCount = (popupTool == .text) ? SC.fontSizes.count : SC.thickPresets.count + let firstX0 = contentLeft + for i in 0..= ix && point.x < ix + SC.popupCell { return i + 1 } + } + let firstEndX = firstX0 + CGFloat(firstCount) * SC.popupCell + + CGFloat(firstCount - 1) * cellGap + // 分隔线区域(不命中) + let colorStartX = firstEndX + SC.popupSepGap * 2 + 1 + if point.x < colorStartX { return 0 } + // 颜色组 + for i in SC.colorPresets.indices { + let ix = colorStartX + CGFloat(i) * (SC.popupCell + cellGap) + if point.x >= ix && point.x < ix + SC.popupCell { return -(i + 1) } + } + return 0 + } + + // MARK: 工具栏鼠标事件(ToolbarPanelView 转发) + + /// 工具栏左键按下:文字编辑态先提交文字并退出编辑(提交路径 3;对齐 Windows + /// 编辑态工具栏分支——只提交退出,不处理本次按钮动作),随后按常规流程处理; + /// 命中把手进入整体拖拽(置 toolbarPlaced,此后不再自动跟随选区); + /// 命中可用按钮 → 动作回话会话。按下左键期间 tooltip 收起(对齐 TickToolbarTooltip)。 + /// - Parameter point: 鼠标 CG 全局坐标 + func handleToolbarMouseDown(_ point: CGPoint) { + guard isRunning else { return } + // 文字编辑态:先提交当前文字(如有)并回确认态,本次按钮动作不执行 + //(用户需再次点击才能触发按钮,对齐 overlay_input_windows.cpp 编辑态工具栏分支) + if session?.state == .textEditing { + session?.commitPendingTextAndExitEditing() + refresh() // 提交文字入撤销栈:撤销按钮可用态变化需重绘 + return + } + let hit = hitTestToolbar(point) + hideTip() + if hit == ScreenshotToolButton.gripHit { + // 最左「6 点把手」:进入工具栏整体拖拽(对齐 OnLButtonDown 的 SC_TB_GRIP 分支); + // 拖拽期间保持把手 hover 底(Windows 的 hover 缓存同款行为) + toolbarDragging = true + toolbarPlaced = true + hoverBtn = ScreenshotToolButton.gripHit + toolbarDragStartPoint = point + toolbarDragStartRect = toolbarRect + toolbarView?.needsDisplay = true + return + } + guard hit >= 0, let button = ScreenshotToolButton(rawValue: hit), + !button.isSeparator, isButtonEnabled(button) else { return } + session?.handleToolbarButton(button) + } + + /// 工具栏拖动中:按「按下矩形 + 鼠标位移」平移并钳制在虚拟屏内(对齐 OnMouseMove + /// 的 toolbarDragging 分支;显式拖放允许跨屏,不受单显示器边界约束), + /// 子菜单锚定工具栏同步移动。 + /// - Parameter point: 鼠标 CG 全局坐标 + func handleToolbarMouseDragged(_ point: CGPoint) { + guard isRunning, toolbarDragging else { return } + let dx = point.x - toolbarDragStartPoint.x + let dy = point.y - toolbarDragStartPoint.y + var n = toolbarDragStartRect + n.origin.x += dx + n.origin.y += dy + let virtual = session?.virtualBounds ?? .zero + if n.minX < virtual.minX { n.origin.x = virtual.minX } + if n.minY < virtual.minY { n.origin.y = virtual.minY } + if n.maxX > virtual.maxX { n.origin.x = virtual.maxX - n.width } + if n.maxY > virtual.maxY { n.origin.y = virtual.maxY - n.height } + toolbarRect = n + if let win = toolbarWindow { placeWindow(win, at: toolbarRect) } + if popupOpen { syncPopupPlacement() } + toolbarView?.needsDisplay = true + } + + /// 工具栏左键抬起:退出把手拖拽态(位置已在拖拽中固化,对齐 OnLButtonUp)。 + func handleToolbarMouseUp() { + guard toolbarDragging else { return } + toolbarDragging = false + toolbarView?.needsDisplay = true + } + + // MARK: 子菜单鼠标事件(PopupPanelView 转发) + + /// 子菜单左键按下:文字编辑态 = 提交路径 1/2(点字号/颜色格先设置索引再提交并退出 + /// 编辑态,新属性将用于随后的提交——对齐 Windows 先设索引再 + /// CommitPendingTextAndExitEditing 的次序);确认态 = 命中码测试后交会话应用 + /// (有选中标注时就地修改,无选中作用于后续绘制)。 + /// - Parameter point: 鼠标 CG 全局坐标 + func handlePopupMouseDown(_ point: CGPoint) { + guard isRunning, popupOpen else { return } + let hit = hitTestPopup(point) + guard hit != 0 else { return } + if session?.state == .textEditing { + // 编辑态:仅字号/颜色两路径(编辑态子菜单必为文字工具;马赛克子菜单 + // 不会在编辑态出现,防御性忽略) + session?.applyTextEditingPopupSelection(hit) + session?.commitPendingTextAndExitEditing() + refresh() // 字号/颜色选中格 + 撤销按钮可用态变化需重绘 + return + } + session?.applyPopupSelection(hit) + } + + // MARK: 按钮可用态 + + /// 按钮是否可用(马赛克/文字可用;保存可用;长截图仍置灰; + /// 撤销/重做按快照栈状态;翻译占位无点击处理但保留 hover/tooltip 与 Windows 一致)。 + /// 保存按钮启用规则对齐 Windows:overlay_input_windows.cpp 的 TB_Save 分支无任何 + /// 禁用判定(工具栏仅在确认态出现,编辑态恒可用),故此处恒返回 true。 + func isButtonEnabled(_ button: ScreenshotToolButton) -> Bool { + switch button { + case .longCapture: return true // 长截图入口(编辑态点击进入长截图模式) + case .save: return true // 保存对话框(Windows 编辑态恒可用) + case .undo: return !(session?.undoStack.isEmpty ?? true) + case .redo: return !(session?.redoStack.isEmpty ?? true) + default: return true // mosaic/text/cancel/confirm/drag/矢量工具/translate(占位) + } + } + + /// 刷新工具栏与子菜单绘制(选中高亮/可用态/子菜单选中项等任何依赖会话状态的 + /// 视觉变化后调用;轻量局部重绘。子菜单选中格、马赛克半径置灰等均画在 popupView, + /// 漏刷会表现为「点击选项高亮不动、看似无反应」;子菜单未创建/未打开时为无害 no-op)。 + func refresh() { + toolbarView?.needsDisplay = true + popupView?.needsDisplay = true + } + + // MARK: pumpTick 轮询(hover + tooltip + 光标;对齐 TickToolbarTooltip 轮询模型) + + /// 当前鼠标 CG 全局坐标。 + private func currentMouseCG() -> CGPoint { + return ScreenshotGeometry.cgPoint(fromNS: NSEvent.mouseLocation) + } + + /// 泵循环逐拍轮询(对齐 TickToolbarTooltip:覆盖层不接收稳定鼠标流,停顿判定靠轮询): + /// 1) hover 高亮变化重绘工具栏;2) 停顿满 500ms 显示 tooltip 气泡; + /// 3) 工具栏/子菜单区域接管光标(箭头/把手四向/子菜单手型)。 + /// - Parameter now: 单调时钟(ProcessInfo.systemUptime) + func tick(now: TimeInterval) { + guard isRunning, let toolbarWindow = toolbarWindow else { return } + let mouse = currentMouseCG() + var target = -1 + // 确认态与文字编辑态 hover 均有意义(对齐 TickToolbarTooltip 的 + // CS_Confirmed/CS_TextEditing 门);按下左键期间视为无目标 + //(NSEvent.pressedMouseButtons bit0 = 左键) + let hoverEligible = (session?.state == .confirmed || session?.state == .textEditing) + if hoverEligible && toolbarWindow.isVisible && NSEvent.pressedMouseButtons & 1 == 0 { + target = hitTestToolbar(mouse) + } + if target != tipBtn { + hideTip() // 目标切换/离开:收起并重新停顿 + tipBtn = target + tipDwellSince = now + } else if target >= 0 && !tipShown && now - tipDwellSince >= SC.tipDelaySec { + showTip(for: target) // 同一目标停顿满延时:计算气泡并显示 + } + + // hover 高亮(仅可用按钮与把手响应;禁用格不铺 hover 底)。把手拖拽中冻结在 + // 把手位(对齐 Windows:拖拽分支提前返回,不重算 hoverToolbarBtn) + if !toolbarDragging { + var hover = -1 + if hoverEligible && toolbarWindow.isVisible + && NSEvent.pressedMouseButtons & 1 == 0 { + let hit = hitTestToolbar(mouse) + if hit == ScreenshotToolButton.gripHit { + hover = hit + } else if hit >= 0, let b = ScreenshotToolButton(rawValue: hit), + !b.isSeparator, isButtonEnabled(b) { + hover = hit + } + } + if hover != hoverBtn { + hoverBtn = hover + toolbarView?.needsDisplay = true + } + } + + // 光标接管:工具栏/子菜单区域独立于覆盖层光标逻辑(对齐 OnSetCursor 的工具栏分支); + // 仅在对应窗口在屏时接管,避免隐藏期间残留矩形错误覆盖 resize 光标 + if toolbarDragging { + NSCursor.closedHand.set() // Windows IDC_SIZEALL(把手拖拽中) + } else if toolbarWindow.isVisible && scPointInRect(mouse, toolbarRect) { + if hitTestToolbar(mouse) == ScreenshotToolButton.gripHit { + NSCursor.openHand.set() // Windows IDC_SIZEALL(把手可拖动) + } else { + NSCursor.arrow.set() + } + } else if popupOpen, let popupWindow = popupWindow, popupWindow.isVisible, + scPointInRect(mouse, popupRect) { + NSCursor.pointingHand.set() // Windows IDC_HAND(子菜单) + } else { + session?.updateCursor() + } + } + + /// 显示目标按钮的 tooltip 气泡(对齐 TickToolbarTooltip 的显示分支): + /// 锚点 = 目标单元格;水平居中并钳制虚拟屏;优先上方,放不下转下方。 + /// - Parameter btn: 目标(按钮 raw 值或 gripHit 码) + private func showTip(for btn: Int) { + guard let text = tooltipText(for: btn), !text.isEmpty, let session = session else { return } + let cell = (btn == ScreenshotToolButton.gripHit) ? 0 : btn + 1 + let anchor = cellRect(cell) + // 按文本测量气泡尺寸(12px 系统字体 + 内边距,对齐 MeasureTipBubbleSize) + let attr = NSAttributedString(string: text, attributes: [ + .font: NSFont.systemFont(ofSize: SC.fontPx), + ]) + let textSize = attr.size() + let w = ceil(textSize.width) + SC.tipPadX * 2 + let h = ceil(textSize.height) + SC.tipPadY * 2 + let virtual = session.virtualBounds + var x = anchor.midX - w / 2 + if x < SC.tipEdgeClamp { x = SC.tipEdgeClamp } + if x + w > virtual.maxX - SC.tipEdgeClamp { x = virtual.maxX - SC.tipEdgeClamp - w } + // 优先上方,放不下转下方(工具栏在选区上方时) + var y = anchor.minY - SC.tipGap - h + if y < SC.tipEdgeClamp { y = anchor.maxY + SC.tipGap } + + // 复用/创建 tooltip 窗口并按气泡尺寸定位 + if tipWindow == nil { + let win = ScreenshotPanelWindow( + contentRect: NSRect(origin: .zero, size: CGSize(width: w, height: h)), + styleMask: .borderless, backing: .buffered, defer: false) + win.level = NSWindow.Level(rawValue: NSWindow.Level.screenSaver.rawValue + 4) + win.isOpaque = false + win.backgroundColor = .clear + win.hasShadow = false + win.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + win.isReleasedWhenClosed = false + tipWindow = win + } + let win = tipWindow! + win.contentView = TooltipPanelView(text: text, + frame: NSRect(origin: .zero, size: CGSize(width: w, height: h))) + placeWindow(win, at: CGRect(x: x, y: y, width: w, height: h)) + win.orderFrontRegardless() + tipShown = true + tipText = text + } + + /// 收起 tooltip 气泡(目标变化/离开/按下左键/工具栏隐藏时调用)。 + private func hideTip() { + guard tipShown else { return } + tipShown = false + tipText = "" + tipWindow?.orderOut(nil) + } + + /// 按钮的 tooltip 文案(ToolbarButtonTip 移植,全中文照搬;分隔线返回 nil)。 + /// - Parameter btn: 按钮 raw 值或 gripHit 码 + /// - Returns: tooltip 文本;分隔线/未知返回 nil + private func tooltipText(for btn: Int) -> String? { + if btn == ScreenshotToolButton.gripHit { return "拖动工具栏" } + guard let b = ScreenshotToolButton(rawValue: btn) else { return nil } + switch b { + case .drag: return "拖拽" + case .rect: return "矩形" + case .circle: return "圆形" + case .arrow: return "箭头" + case .brush: return "画笔" + case .mosaic: return "马赛克" + case .text: return "文字" + case .translate: return "翻译" + case .longCapture: return "长截图" + case .undo: return "撤销" + case .redo: return "重做" + case .save: return "保存到本地" + case .cancel: return "取消" + case .confirm: return "确定" + default: return nil + } + } + + // MARK: 绘制(对齐 DrawToolbar / DrawPopup) + + /// 绘制工具栏(DrawToolbar 移植;视图本地坐标 = 工具栏矩形相对坐标): + /// 第一遍白底圆角背景 + hover/active 圆角高亮,再绘制把手 6 点、分隔线与图标。 + /// - Parameter ctx: 工具栏视图 CG 上下文(已翻转,左上原点) + func drawToolbar(_ ctx: CGContext) { + let w = toolbarRect.width + let h = toolbarRect.height + let btn = SC.toolbarBtn + let btnPad = (SC.toolbarH - btn) / 2 + let activeToolRaw = session?.activeTool?.rawValue ?? -1 + + // 白色圆角背景 + 1px 浅灰边框(圆角外保持透明,露出后方截图) + let bgPath = roundedRectPath(CGRect(x: 0, y: 0, width: w, height: h), SC.toolbarRadius) + ctx.addPath(bgPath) + ctx.setFillColor(NSColor.white.cgColor) + ctx.fillPath() + ctx.addPath(bgPath) + ctx.setStrokeColor(SC.toolbarBorderCol.cgColor) + ctx.setLineWidth(SC.toolbarBorderW) + ctx.strokePath() + + // 各按钮圆角高亮(hover 浅蓝 / active 选中浅蓝;禁用格不响应 hover) + let hlRadius = btn / 8 + let hlInset: CGFloat = 2 + let hlSize = btn - hlInset * 2 + for i in 0.. CGPath { + let r = min(radius, min(rect.width, rect.height) / 2) + return CGPath(roundedRect: rect, cornerWidth: max(1, r), cornerHeight: max(1, r), transform: nil) + } +} diff --git a/src/screenshot/macos/lc_bridge_mac.cpp b/src/screenshot/macos/lc_bridge_mac.cpp new file mode 100644 index 0000000..b0e70d5 --- /dev/null +++ b/src/screenshot/macos/lc_bridge_mac.cpp @@ -0,0 +1,491 @@ +// 长截图算法层 C ABI shim 实现(契约见 lc_bridge_mac.h)。 +// +// 本文件只做参数打包/解包转发,不含业务逻辑;唯一的例外是「镜像实现段」—— +// 三个纯数据函数/段原本定义在 Windows 侧 IO/UI 文件(lc_frame_io_windows.cpp / +// lc_toolbar_ui_windows.cpp)中,这些文件携带 GDI 依赖无法在 macOS 编译,而 macOS 算法层 +// 链接又必需它们,故在此提供逐字等价镜像(Windows 侧不编译本文件,仍以原文件为 +// 唯一权威;原文件修改时必须同步本处,各镜像处均有显著标注)。 +// +// 编译归属:仅 macOS 构建链(build-swift.sh / CI / test-lc-mac.sh)编译本文件并链入 +// libZToolsNative.dylib;Windows 侧 binding.gyp 不包含本文件(算法层经 internal.h +// 原生编译,行为零变化)。 +#include "lc_bridge_mac.h" +#include "long_capture_internal.h" + +#include +#include +#include + +// C ABI 结构体与算法层结构体的维度对齐守卫(漂移即编译失败)。 +// 注:重试梯常量(LC_RETRY_DELAY_* 等)为 extern 定义于 lc_match_core.cpp,本 TU +// 仅见 extern 声明、无法参与编译期断言,lc_get_algo_consts 内改用运行时钳制拷贝。 +static_assert(LC_ROI_BANDS == 3, "LCMatchEvidence.bandOffsets 固定 3 桶,必须与 LC_ROI_BANDS 一致"); + +namespace { + +// LongMatchOutcome → C 证据结构打包(LCMatchEvidence / LCDetectResult 字段同构, +// 模板统一填充;两结构任一字段更名/增删时本函数编译失败,天然防漂移)。 +template +void LcBridgeFillEvidence(T& e, const LongMatchOutcome& m) { + e.status = (int32_t)m.status; + e.mode = m.mode == LongCaptureMatchMode::WeakOverlap ? LC_MODE_WEAK_OVERLAP : LC_MODE_NORMAL; + e.failReason = (int32_t)m.reason; + e.offset = m.offset; + e.overlap = m.overlap; + e.overall = m.overall; + e.seam = m.seam; + e.top = m.top; + e.middle = m.middle; + e.bottom = m.bottom; + e.spatial = m.spatial; + e.continuity = m.continuity; + e.profileScore = m.profileScore; + e.edgeCorrelation = m.edgeCorrelation; + e.peakGap = m.peakGap; + e.roiWeighted = m.roiWeighted; + e.confidence = m.confidence; + e.textureRatio = m.textureRatio; + e.validBandCount = m.validBandCount; + e.agreeCount = m.agreeCount; + for (int i = 0; i < LC_ROI_BANDS; i++) { + e.bandOffsets[i] = m.bandOffsets[i]; + e.bandValid[i] = m.bandValid[i] ? 1 : 0; + } +} + +// C 证据结构清零(hasRejectEvidence=0 / 参数非法路径的确定值)。 +template +void LcBridgeZeroEvidence(T& e) { + for (size_t i = 0; i < sizeof(T); i++) reinterpret_cast(&e)[i] = 0; +} + +} // namespace + +// ==================== 镜像实现段(纯数据函数;Windows 侧原文件为唯一权威) ==================== +// +// 【镜像 1/4】lc_frame_io_windows.cpp 的 LongCaptureDownscaleRow:纯像素行面积平均缩列, +// 无任何平台 API。lc_stitch_state.cpp 的 CommitStitch 缩略图增量维护引用它,而其 +// 唯一 Windows 侧定义所在的 lc_frame_io_windows.cpp 无法在 macOS 编译——此处以同名同签名 +// (外部链接)提供逐字等价定义,闭合算法层对象的链接;Windows 侧不编译本文件, +// 不存在重复定义。 + +// 将一列物理像素行缩为一行(列方向整数面积平均,AVG 通道忽略),供面板缩略图增量维护。 +void LongCaptureDownscaleRow(const uint32_t* src, uint32_t* dst, int srcW, int dstW) { + if (srcW <= 0 || dstW <= 0) return; + if (srcW == dstW) { memcpy(dst, src, (size_t)dstW * 4); return; } + for (int c = 0; c < dstW; c++) { + int s0 = (int)((long long)c * srcW / dstW); + int s1 = (int)((long long)(c + 1) * srcW / dstW); + if (s1 <= s0) s1 = s0 + 1; + if (s1 > srcW) s1 = srcW; + unsigned r = 0, g = 0, b = 0; + for (int s = s0; s < s1; s++) { + uint32_t px = src[s]; + b += px & 0xFF; + g += (px >> 8) & 0xFF; + r += (px >> 16) & 0xFF; + } + int n = s1 - s0; + dst[c] = 0xFF000000u | ((r / n) << 16) | ((g / n) << 8) | (b / n); + } +} + +// ==================== C ABI:版本 / 会话生命周期 ==================== + +int32_t lc_abi_version(void) { + return LC_ABI_VERSION; +} + +// 对齐 lc_session_windows.cpp BeginLongCapture 构造段:new LongCaptureContext + 会话级配置 +// 字段拷贝(interval/physW/physH/thumbW/horizontal;thumbW 按 BeginLongCapture +// 同式钳到 [0, physW])。不含任何窗口/抓帧动作(归 macOS 会话层)。 +lc_handle_t lc_session_create(const LCSessionConfig* config) { + if (!config || config->physW < 1 || config->physH < 1) return nullptr; + LongCaptureContext* c = new (std::nothrow) LongCaptureContext(); + if (!c) return nullptr; + c->interval = config->interval; + c->physW = config->physW; + c->physH = config->physH; + c->horizontal = config->horizontal != 0; + c->thumbW = config->thumbW > 0 ? (std::min)(config->thumbW, config->physW) : 0; + return c; +} + +void lc_session_destroy(lc_handle_t h) { + delete static_cast(h); +} + +// ==================== C ABI:帧管线 ==================== + +// 逐字镜像 lc_frame_io_windows.cpp 的 LongCaptureInitBaseline(纯数据初始化,不抓屏; +// 【镜像 2/4】Windows 侧以 lc_frame_io_windows.cpp 为唯一权威,修改需同步本处)。 +int32_t lc_init_baseline(lc_handle_t h, const uint32_t* bgra, int32_t w, int32_t height) { + LongCaptureContext* c = static_cast(h); + if (!c || !bgra || w < 1 || height < 1 || w != c->physW || height != c->physH) return 0; + std::vector frame(bgra, bgra + (size_t)w * (size_t)height); + c->lastFrame.swap(frame); // lastFrame = 首帧;frame 复用旧缓冲 + c->body = c->lastFrame; + c->bodyRows = c->physH; + c->stitchH = c->physH; + LongCaptureBuildMatchData(c->lastFrame, c->physW, c->physH, c->lastMatch); + // 跟踪/历史基准初始化:首帧即已提交基准(内容坐标 0),tentative 与 committed 对齐; + // 首帧同时作为第 0 条历史条目(已提交、位置精确),供失败后的多跳回溯起步。 + c->committedContentTop = 0; + c->tentativeContentTop = 0; + c->tentativeValid = true; + c->tentativeConfidence = 1.0f; + c->trackUnreliableStreak = 0; + c->lastCommittedFrameId = 0; + c->frameHistory.clear(); + c->weakCandidateOffsets.clear(); + LongCaptureHistoryPush(c, 0, LongMatchData(c->lastMatch), 0, true); + if (c->thumbW > 0) { + c->thumbBody.resize((size_t)c->physH * c->thumbW); + uint32_t* dst = c->thumbBody.data(); + for (int r = 0; r < c->physH; r++) { + LongCaptureDownscaleRow(c->lastFrame.data() + (size_t)r * c->physW, + dst, c->physW, c->thumbW); + dst += c->thumbW; + } + c->thumbH = c->physH; + c->thumbDirty = true; + } + return 1; +} + +// 转发 LongCaptureTryStitch(单帧「识别→offset 校验→(Weak 档)延迟确认→提交」管线), +// 并打包七值结局、拒绝分类、证据快照与提交差分(committedContentTop/stitchH 前后差分, +// 等价 RunLongCapture 在 Stitched 后读取的状态变化)。 +int32_t lc_try_stitch(lc_handle_t h, const uint32_t* bgra, int32_t dir, + int32_t allowStabilityGate, LCTryStitchResult* out) { + LongCaptureContext* c = static_cast(h); + if (!c || !bgra || !out || c->physW < 1 || c->physH < 1) return 0; + // 帧缓冲拷贝:TryStitch 的 curr 按引用传入且提交成功时被 swap 进 lastFrame(所有权 + // 语义),输入指针为只读,故先拷入本帧缓冲(单帧 ≤ 数 MB,远低于匹配开销)。 + std::vector curr(bgra, bgra + (size_t)c->physW * (size_t)c->physH); + const int64_t committedBefore = c->committedContentTop; + const int stitchHBefore = c->stitchH; + LCSampleOutcome oc = LongCaptureTryStitch(c, curr, dir, allowStabilityGate != 0); + out->outcome = (int32_t)oc; + out->failReason = (int32_t)c->lastFailReason; + out->addedRows = c->stitchH - stitchHBefore; // 仅 STITCHED > 0;失败路径恒 0 + out->committedDelta = (int32_t)(c->committedContentTop - committedBefore); + out->stitchH = c->stitchH; + out->sampleIndex = c->sampleIndex; + // 拒绝证据(lastReject)仅在本帧拒绝路径被写入(TryStitch 入口先重置 lastFailReason); + // Unstable 路径只写分类不写证据,与 Windows 侧可观测面一致。 + out->hasRejectEvidence = + (oc == LCSampleOutcome::Failed || oc == LCSampleOutcome::WeakRejected) ? 1 : 0; + if (out->hasRejectEvidence) { + LcBridgeFillEvidence(out->evidence, c->lastReject); + } else { + LcBridgeZeroEvidence(out->evidence); + } + out->pendingValid = c->pendingMatch.valid ? 1 : 0; + out->pendingOffset = c->pendingMatch.offset; + out->pendingConfidence = c->pendingMatch.confidence; + return 1; +} + +// ==================== C ABI:纯识别查询 ==================== + +// 转发 lc_match_core.cpp LongCaptureDetectMatch(双档识别主入口,纯函数不触碰状态)。 +int32_t lc_detect_match(const uint32_t* prevBgra, const uint32_t* currBgra, + int32_t w, int32_t height, int32_t dir, + int32_t priorValid, int32_t priorExpectedAbsOffset, + LCDetectResult* out) { + if (!prevBgra || !currBgra || !out || w < 1 || height < 2) return 0; + std::vector prev(prevBgra, prevBgra + (size_t)w * (size_t)height); + std::vector curr(currBgra, currBgra + (size_t)w * (size_t)height); + LongMatchData prevM, currM; + LongCaptureBuildMatchData(prev, w, height, prevM); + LongCaptureBuildMatchData(curr, w, height, currM); + LongCaptureOffsetPrior prior; // 先验由参数打包(对齐 LongCaptureBuildOffsetPrior 产物) + if (priorValid) { + prior.valid = true; + prior.expectedAbsOffset = priorExpectedAbsOffset; + } + LongMatchOutcome m = LongCaptureDetectMatch(prevM, currM, dir, prior, LCWeakTemporal(), 0); + LcBridgeFillEvidence(*out, m); + return 1; +} + +float lc_weak_required_confidence(int32_t viewportH, int32_t overlap) { + return LongCaptureWeakRequiredConfidence(viewportH, overlap); +} + +const char* lc_fail_reason_name(int32_t failReason) { + return LcFailReasonName(static_cast(failReason)); +} + +// ==================== C ABI:状态读取 ==================== + +int32_t lc_get_state(lc_handle_t h, LCStateSnapshot* out) { + LongCaptureContext* c = static_cast(h); + if (!c || !out) return 0; + out->physW = c->physW; + out->physH = c->physH; + out->horizontal = c->horizontal ? 1 : 0; + out->stitchH = c->stitchH; + out->headRows = c->headRows; + out->bodyRows = c->bodyRows; + out->committedContentTop = c->committedContentTop; + out->tentativeContentTop = c->tentativeContentTop; + out->tentativeValid = c->tentativeValid ? 1 : 0; + out->tentativeConfidence = c->tentativeConfidence; + out->trackUnreliableStreak = c->trackUnreliableStreak; + out->trackingRevision = c->trackingRevision; + out->sampleIndex = c->sampleIndex; + out->pendingValid = c->pendingMatch.valid ? 1 : 0; + out->pendingOffset = c->pendingMatch.offset; + out->pendingConfidence = c->pendingMatch.confidence; + out->pendingMode = c->pendingMatch.mode == LongCaptureMatchMode::WeakOverlap + ? LC_MODE_WEAK_OVERLAP : LC_MODE_NORMAL; + out->lastFailReason = (int32_t)c->lastFailReason; + out->offsetHistoryLen = (int32_t)c->offsetHistory.size(); + out->wheelAccumDelta = c->wheelAccumDelta; + out->pixelsPerWheelNotch = c->pixelsPerWheelNotch; + out->thumbW = c->thumbW; + out->thumbHeadH = c->thumbHeadH; + out->thumbH = c->thumbH; + out->cropTopY = c->cropTopY; + out->cropBottomY = c->cropBottomY; + out->cropPendTop = c->cropPendTop ? 1 : 0; + out->cropPendBottom = c->cropPendBottom ? 1 : 0; + out->cropPendTopLo = c->cropPendTopLo; + out->cropPendTopHi = c->cropPendTopHi; + out->cropPendBottomLo = c->cropPendBottomLo; + out->cropPendBottomHi = c->cropPendBottomHi; + out->cropped = c->cropped ? 1 : 0; + out->interval = c->interval; + return 1; +} + +int32_t lc_get_offset_history(lc_handle_t h, int32_t* out, int32_t cap) { + LongCaptureContext* c = static_cast(h); + if (!c || !out || cap <= 0) return 0; + const int32_t n = (int32_t)c->offsetHistory.size(); + const int32_t take = n < cap ? n : cap; + for (int32_t i = 0; i < take; i++) out[i] = c->offsetHistory[(size_t)i]; + return take; +} + +// 逐字镜像 lc_frame_io_windows.cpp 的 LongCaptureOutputRows(当前输出行窗口; +// 【镜像 3/4】纯数据段,Windows 侧以 lc_frame_io_windows.cpp 为唯一权威,修改需同步本处)。 +int32_t lc_get_output_rows(lc_handle_t h, int64_t* outTop, int64_t* outBottom) { + const LongCaptureContext* c = static_cast(h); + if (!c || !outTop || !outBottom) return 0; + int outTopVal = 0; + int outBottomVal = c->stitchH; + if (c->cropped) { + // 哨兵值显式短路:INT64_MIN/MAX 直接参与 +headRows 加法是有符号溢出(UB); + // 开放侧保持哨兵、由区间钳制自然落到界外,与「该侧未设边界」语义一致。 + int64_t t = c->cropTopY == INT64_MIN ? INT64_MIN : c->cropTopY + c->headRows; + if (t > 0 && t < c->stitchH) outTopVal = (int)t; + int64_t b = c->cropBottomY == INT64_MAX ? INT64_MAX : c->cropBottomY + c->headRows; + if (b > outTopVal && b < c->stitchH) outBottomVal = (int)b; + } + *outTop = outTopVal; + *outBottom = outBottomVal; + return 1; +} + +// 拼接缓冲显示行读取:headRev(倒序头部段)+ body(正序主体段)的双段映射, +// 取行序逐字对齐 lc_frame_io_windows.cpp LongCaptureBuildResultBitmap 内的 copyRows 合并 +//(显示行 r < headRows ↔ headRev[headRows-1-r];r ≥ headRows ↔ body[r-headRows])。 +// 完整合并/横向回转由 Swift 侧组装(对齐 Windows 侧 BuildResultBitmap 的缓冲段职责)。 +int64_t lc_read_rows(lc_handle_t h, int64_t rowStart, int64_t rowCount, + uint32_t* outBuf, int64_t outBufRows) { + const LongCaptureContext* c = static_cast(h); + if (!c || !outBuf || rowCount <= 0 || outBufRows < rowCount || c->physW < 1) return 0; + if (rowStart < 0 || rowStart + rowCount > c->stitchH) return 0; + const size_t rowW = (size_t)c->physW; + int64_t written = 0; + for (int64_t r = rowStart; r < rowStart + rowCount; r++) { + const uint32_t* srcRow = r < c->headRows + ? c->headRev.data() + (size_t)(c->headRows - 1 - r) * rowW + : c->body.data() + (size_t)(r - c->headRows) * rowW; + memcpy(outBuf + (size_t)written * rowW, srcRow, rowW * 4); + written++; + } + return written; +} + +// 缩略图双段读取(thumbHeadRev 倒序头部段 + thumbBody 正序主体段),合并序对齐 +// lc_frame_io_windows.cpp LongCaptureRebuildThumb;供小地图面板两级缩略列绘制。 +int64_t lc_read_thumb_rows(lc_handle_t h, int64_t rowStart, int64_t rowCount, + uint32_t* outBuf, int64_t outBufRows) { + const LongCaptureContext* c = static_cast(h); + if (!c || !outBuf || rowCount <= 0 || outBufRows < rowCount || c->thumbW < 1) return 0; + if (rowStart < 0 || rowStart + rowCount > c->thumbH) return 0; + const size_t rowW = (size_t)c->thumbW; + int64_t written = 0; + for (int64_t r = rowStart; r < rowStart + rowCount; r++) { + const uint32_t* srcRow = r < c->thumbHeadH + ? c->thumbHeadRev.data() + (size_t)(c->thumbHeadH - 1 - r) * rowW + : c->thumbBody.data() + (size_t)(r - c->thumbHeadH) * rowW; + memcpy(outBuf + (size_t)written * rowW, srcRow, rowW * 4); + written++; + } + return written; +} + +// ==================== C ABI:裁剪(#44 延迟剔除语义) ==================== + +// 逐字镜像 lc_toolbar_ui_windows.cpp LongCaptureApplyCrop 的纯状态段(【镜像 4/4】, +// 与上方 LongCaptureOutputRows / LongCaptureInitBaseline / LongCaptureDownscaleRow +// 同性质—— +// 尾部 LongCapturePanelUpdate / InvalidateRect / LongCaptureToolbarRepaint 三个 +// Windows UI 刷新调用不在镜像内,归 macOS 会话层;Windows 侧以 lc_toolbar_ui_windows.cpp +// 为唯一权威,修改需同步本处)。物理删行仍由算法层 CommitStitch 入口的 +// LongCaptureExecuteCropPurge 在下一次朝该方向成功提交时触发,本函数只登记。 +int32_t lc_apply_crop(lc_handle_t h, int32_t row) { + LongCaptureContext* c = static_cast(h); + if (!c || c->stitchH <= 0) return 0; + int n = c->cropped ? 3 : 2; // 裁剪菜单行数(末行=重置,仅已裁剪时存在) + if (row < 0 || row >= n) return 0; + if (row == n - 1 && c->cropped) { + // 重置裁剪:清空全部锚点与待剔除区间(尚未执行的剔除随之作废、内容完整恢复; + // 已触发执行的删除不可逆) + c->cropPendTop = false; + c->cropPendBottom = false; + c->cropTopY = INT64_MIN; + c->cropBottomY = INT64_MAX; + c->cropped = false; + } else { + // 裁剪线锚定在当前视口边沿(内容坐标);反向有效边界 = 已设置的锚点或捕获 + // 外沿中的更紧者,保证窗口至少保留一行 + int64_t vpTopY = c->committedContentTop; + int64_t vpBottomY = vpTopY + c->physH; + int64_t capTopY = -(int64_t)c->headRows; + int64_t capBottomY = (int64_t)c->bodyRows; + if (row == 0) { + // 丢弃上方(横向 = 左侧):窗口顶收到当前视口顶(越靠下 = 收得越紧) + int64_t botEff = (std::min)(c->cropBottomY, capBottomY); + int64_t cut = vpTopY < botEff - 1 ? vpTopY : botEff - 1; + if (c->cropTopY == INT64_MIN || cut > c->cropTopY) c->cropTopY = cut; + int64_t pendHi = c->cropTopY; + c->cropPendTop = pendHi > capTopY; + if (c->cropPendTop) { + c->cropPendTopLo = capTopY; + c->cropPendTopHi = pendHi; + } + } else { + // 丢弃下方(横向 = 右侧):窗口底收到当前视口底(越靠上 = 收得越紧) + int64_t topEff = (std::max)(c->cropTopY, capTopY); + int64_t cut = vpBottomY > topEff + 1 ? vpBottomY : topEff + 1; + if (c->cropBottomY == INT64_MAX || cut < c->cropBottomY) c->cropBottomY = cut; + int64_t pendLo = c->cropBottomY; + c->cropPendBottom = capBottomY > pendLo; + if (c->cropPendBottom) { + c->cropPendBottomLo = pendLo; + c->cropPendBottomHi = capBottomY; + } + } + c->cropped = true; + } + return 1; +} + +int32_t lc_has_crop_constraint(lc_handle_t h) { + const LongCaptureContext* c = static_cast(h); + if (!c) return 0; + return LongCaptureHasCropConstraint(c) ? 1 : 0; +} + +// ==================== C ABI:滚轮先验 / Weak 时间一致性 ==================== + +void lc_update_wheel_estimate(lc_handle_t h, int32_t d) { + LongCaptureContext* c = static_cast(h); + if (!c) return; + LongCaptureUpdateWheelEstimate(c, d); +} + +// 对齐 lc_session_windows.cpp 面板 WM_INPUT 的滚轮累计(wheelAccumDelta += delta)。 +void lc_accumulate_wheel_delta(lc_handle_t h, int32_t delta) { + LongCaptureContext* c = static_cast(h); + if (!c) return; + c->wheelAccumDelta += delta; +} + +void lc_push_weak_candidate(lc_handle_t h, int32_t offset) { + LongCaptureContext* c = static_cast(h); + if (!c) return; + LongCapturePushWeakCandidate(c, offset); +} + +// 镜像 lc_session_windows.cpp 主循环 Weak 预算耗尽分支的状态段(weakTries 重置归会话层, +// 此处只作废候选链:pendingMatch.valid = false + weakCandidateOffsets.clear())。 +void lc_abandon_weak_chain(lc_handle_t h) { + LongCaptureContext* c = static_cast(h); + if (!c) return; + c->pendingMatch.valid = false; // 放弃当前候选链,从干净基准重新观察 + c->weakCandidateOffsets.clear(); // 候选链作废:时间一致性样本一并过期 +} + +int32_t lc_offset_plausible(lc_handle_t h, int32_t d, int32_t mode) { + LongCaptureContext* c = static_cast(h); + if (!c) return -1; + return LongCaptureOffsetPlausible(c, d, + mode == LC_MODE_WEAK_OVERLAP ? LongCaptureMatchMode::WeakOverlap + : LongCaptureMatchMode::Normal) ? 1 : 0; +} + +// ==================== C ABI:Tentative 视觉跟踪 ==================== + +void lc_tracking_set_visual(lc_handle_t h, int64_t contentY, float confidence) { + LongCaptureContext* c = static_cast(h); + if (!c) return; + LongCaptureTrackingSetVisual(c, contentY, confidence); +} + +void lc_tracking_reset_to_committed(lc_handle_t h) { + LongCaptureContext* c = static_cast(h); + if (!c) return; + LongCaptureTrackingResetToCommitted(c); +} + +void lc_tracking_advance_predicted(lc_handle_t h, int32_t direction, double magnitude) { + LongCaptureContext* c = static_cast(h); + if (!c) return; + LongCaptureTrackingAdvancePredicted(c, direction, magnitude); +} + +int32_t lc_build_tracking_estimate(lc_handle_t h, int32_t dir, LCTrackingEstimate* out) { + LongCaptureContext* c = static_cast(h); + if (!c || !out) return 0; + LongCaptureTrackingEstimate e = LongCaptureBuildTrackingEstimate(c, dir, + LongCaptureOffsetPrior()); + out->valid = e.valid ? 1 : 0; + out->direction = e.direction; + out->predictedOffset = e.predictedOffset; + out->confidence = e.confidence; + return 1; +} + +// ==================== C ABI:常量导出 ==================== + +// lc_session_windows.cpp 采样主循环所需常量(重试梯/节拍/到底确认/采样内缩); +// macOS 会话经此取用,禁止在 Swift 侧硬编码同名数值造成漂移。 +// 数组拷贝按「语义数量与 C 结构体维度取较小者」钳制,杜绝常量定义变化时的越界读。 +void lc_get_algo_consts(LCAlgoConsts* out) { + if (!out) return; + out->sampleAttempts = LC_SAMPLE_ATTEMPTS; + for (int i = 0; i < 5; i++) + out->retryDelayNormal[i] = i < LC_SAMPLE_ATTEMPTS - 1 ? LC_RETRY_DELAY_NORMAL[i] : 0; + out->weakRetryAttempts = LC_WEAK_RETRY_ATTEMPTS; + for (int i = 0; i < 6; i++) + out->retryDelayWeak[i] = i < LC_WEAK_RETRY_ATTEMPTS ? LC_RETRY_DELAY_WEAK[i] : 0; + out->stableMaxWaits = LC_STABLE_MAX_WAITS; + for (int i = 0; i < 3; i++) + out->stableRetryDelay[i] = i < LC_STABLE_MAX_WAITS ? LC_STABLE_RETRY_DELAY[i] : 0; + out->quickResamples = LC_QUICK_RESAMPLES; + for (int i = 0; i < 2; i++) + out->resampleDelayQuick[i] = i < LC_QUICK_RESAMPLES ? LC_RESAMPLE_DELAY_QUICK[i] : 0; + out->scrollSampleMaxGap = LC_SCROLL_SAMPLE_MAX_GAP; + out->bottomConfirmSamples = LC_BOTTOM_CONFIRM_SAMPLES; + out->weakMaxTries = LC_WEAK_MAX_TRIES; + out->stableRefMaxGapMs = (int32_t)LC_STABLE_REF_MAX_GAP; + out->cropInsetLogical = LC_CROP_INSET_LOGI; + out->trackMinStep = LC_TRACK_MIN_STEP; +} diff --git a/src/screenshot/macos/lc_bridge_mac.h b/src/screenshot/macos/lc_bridge_mac.h new file mode 100644 index 0000000..cefacfd --- /dev/null +++ b/src/screenshot/macos/lc_bridge_mac.h @@ -0,0 +1,326 @@ +// 长截图算法层 C ABI shim。 +// +// 定位:把 lc_match_core.cpp / lc_stitch_state.cpp(纯算法层,仅依赖 uint32 BGRA +// 帧缓冲)的 C++ 接口打包为固定布局的 C 接口,供 macOS 侧 Swift(经 @_silgen_name) +// 链入 libZToolsNative.dylib 调用。本层不含任何业务逻辑——只做参数打包/解包转发, +// 以及少量「定义在 Windows 侧 IO/UI 文件中的纯数据函数」的逐字镜像(见 lc_bridge_mac.cpp +// 内的镜像说明段)。本文件仅在 macOS 构建链(build-swift.sh / CI / test-lc-mac.sh) +// 编译;Windows 侧不参与编译(binding.gyp win 分支不变,算法层经 internal.h 原生编译)。 +// +// 跨边界纪律: +// · 结构体全部使用固定宽度标量(int32_t/int64_t/float/double/指针),禁用 C++ STL +// 与平台句柄;字段顺序即内存布局(自然对齐),Swift 侧按同序镜像。 +// · 句柄 lc_handle_t 不透明,由 lc_session_create 产出、lc_session_destroy 销毁; +// 其余函数对 NULL 句柄一律安全返回失败值,不解引用。 +// · 所有帧缓冲按 BGRA 物理像素(与 Windows 算法层内存序一致)行主序传入。 +#ifndef LC_BRIDGE_H +#define LC_BRIDGE_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// 不透明会话句柄 = 算法层 LongCaptureContext*(可写累计拼接状态对象)。 +// 由 lc_session_create 产出、lc_session_destroy 销毁;其余函数对 NULL 句柄 +// 一律安全返回失败值,不解引用。 +typedef void* lc_handle_t; + +// ==================== 枚举镜像(与 internal.h 逐值对齐,C 侧用宏保证固定宽度) ==================== + +// LCSampleOutcome:单帧采样七值结局(LongCaptureTryStitch 返回语义)。 +#define LC_OUTCOME_STITCHED 0 // 已提交且新增拼接行 +#define LC_OUTCOME_REPOSITIONED 1 // 已提交但仅视口重定位(反向回滚,无新增行) +#define LC_OUTCOME_NO_CHANGE 2 // 内容未滚动(全同帧 / 匹配成功 d=0) +#define LC_OUTCOME_WEAK_PENDING 3 // Weak 候选首次成立:只登记待复核,未提交 +#define LC_OUTCOME_WEAK_REJECTED 4 // Weak 候选被复核否决(复现不一致 / 置信度跌破 / offset 异常) +#define LC_OUTCOME_UNSTABLE 5 // 稳定性闸门未过(仅 allowStabilityGate 时可能出现) +#define LC_OUTCOME_FAILED 6 // 硬失败(无候选 / 验证崩塌 / offset 异常);状态未被触碰 + +// LongMatchStatus:匹配三值结论。 +#define LC_MATCH_STATUS_FAILED 0 +#define LC_MATCH_STATUS_LOW_CONFIDENCE 1 +#define LC_MATCH_STATUS_SUCCESS 2 + +// LongCaptureMatchMode:匹配档位。 +#define LC_MODE_NORMAL 0 +#define LC_MODE_WEAK_OVERLAP 1 + +// LCFailReason:失败分类(顺序与 internal.h 的 enum class 逐项一致)。 +#define LC_FAIL_NONE 0 +#define LC_FAIL_NO_CANDIDATE 1 +#define LC_FAIL_CANDIDATE_WEAK 2 +#define LC_FAIL_PEAK_AMBIGUOUS 3 +#define LC_FAIL_GLOBAL_MISMATCH 4 +#define LC_FAIL_SEAM_MISMATCH 5 +#define LC_FAIL_SPATIAL_MISMATCH 6 +#define LC_FAIL_CONTINUITY_MISMATCH 7 +#define LC_FAIL_PROFILE_MISMATCH 8 +#define LC_FAIL_ROI_INCONSISTENT 9 +#define LC_FAIL_OFFSET_IMPLAUSIBLE 10 +#define LC_FAIL_DIRECTION_CONFLICT 11 +#define LC_FAIL_FRAME_UNSTABLE 12 + +// 裁剪登记动作(lc_apply_crop 的 row 参数,语义对齐 lc_toolbar_ui_windows.cpp 的裁剪 popover 行)。 +#define LC_CROP_DISCARD_TOP 0 // 丢弃上方(横向模式 = 丢弃左侧) +#define LC_CROP_DISCARD_BOTTOM 1 // 丢弃下方(横向模式 = 丢弃右侧) +#define LC_CROP_RESET 2 // 重置裁剪(仅已裁剪时存在该行;未裁剪时调用返回 0) + +// ABI 版本(结构体布局或语义变更时递增;Swift 侧启动时可校验)。 +// 2:LCSessionConfig / LCStateSnapshot 移除 maxFrames 字段(长截图取消拼接帧数上限)。 +#define LC_ABI_VERSION 2 + +// ==================== C 布局结构体 ==================== + +// 会话创建配置(对应 LongCaptureContext 的构造参数中「会话级配置」子集, +// 对齐 lc_session_windows.cpp BeginLongCapture 的赋值面;虚拟屏/选区/DPI 等纯 IO 簿记字段 +// 算法层不读取,不进 C ABI,由 macOS 会话层自行持有)。 +typedef struct LCSessionConfig { + int32_t interval; // 滚轮停稳防抖 ms(对齐 lc->interval;算法层不读取,随快照回读) + int32_t physW; // 帧缓冲宽(物理像素;横向模式为转置后宽度,对齐 lc->physW) + int32_t physH; // 帧缓冲高(物理像素,对齐 lc->physH) + int32_t thumbW; // 缩略图列宽(对齐 lc->thumbW;0 = 关闭缩略图;> physW 时被钳到 physW) + int32_t horizontal; // 0 = 纵向(默认);1 = 横向(帧缓冲转置复用纵向管线) +} LCSessionConfig; + +// 匹配证据快照(对齐 internal.h LongMatchOutcome 字段;来自拒绝帧的 lastReject +// 或独立识别调用 lc_detect_match 的返回)。 +typedef struct LCMatchEvidence { + int32_t status; // LC_MATCH_STATUS_* + int32_t mode; // LC_MODE_* + int32_t failReason; // LC_FAIL_* + int32_t offset; // 候选位移 d(>0 向下滚,<0 向上滚) + int32_t overlap; // 候选位移对应重叠行数 = h − |d| + float overall; // 全宽重叠区加权匹配率 + float seam; // 接缝窗加权匹配率 + float top; // 重叠区上 1/3 匹配率 + float middle; // 重叠区中 1/3 匹配率 + float bottom; // 重叠区下 1/3 匹配率 + float spatial; // 三段空间一致性综合分 + float continuity; // 匹配分布连续性 + float profileScore; // 4/8 行聚合 profile 多尺度结构一致度 + float edgeCorrelation; // 行边缘结构相关度(Weak 档强证据) + float peakGap; // 峰值分离度(归一化) + float roiWeighted; // 跨 ROI 加权证据 + float confidence; // 综合置信度 + float textureRatio; // 重叠区有效纹理行占比 + int32_t validBandCount; // 产出候选的 ROI 数 + int32_t agreeCount; // 最终候选聚类支持 ROI 数 + int32_t bandOffsets[3]; // 各 ROI 独立求得的位移 + int32_t bandValid[3]; // 各 ROI 是否产出候选 +} LCMatchEvidence; + +// 单帧喂入结果(lc_try_stitch 的返回打包)。 +typedef struct LCTryStitchResult { + int32_t outcome; // LC_OUTCOME_*(七值结局) + int32_t failReason; // LC_FAIL_*(本帧拒绝分类;成功/NoChange/WeakPending 为 NONE) + int32_t addedRows; // 新增拼接行数(仅 STITCHED 时 > 0;由 stitchH 前后差分得出) + int32_t committedDelta; // committedContentTop 推进量(STITCHED/REPOSITIONED = 本帧位移 d,其余 0) + int32_t stitchH; // 调用后的拼接总行数 + int32_t sampleIndex; // 本帧采样序号(对齐 lc->sampleIndex) + int32_t hasRejectEvidence; // 1 = evidence 为本次拒绝的 lastReject 快照(FAILED/WEAK_REJECTED) + LCMatchEvidence evidence; // 拒绝证据快照(hasRejectEvidence=0 时内容无效) + int32_t pendingValid; // 调用后待复核 Weak 候选是否有效(WEAK_PENDING 时为 1) + int32_t pendingOffset; // 待复核候选位移 + float pendingConfidence; // 待复核候选置信度 +} LCTryStitchResult; + +// 独立识别结果(lc_detect_match 的返回打包 = LongMatchOutcome 全量)。 +typedef struct LCDetectResult { + int32_t status; // LC_MATCH_STATUS_* + int32_t mode; // LC_MODE_* + int32_t failReason; // LC_FAIL_* + int32_t offset; // 候选位移 d + int32_t overlap; // 重叠行数 + float overall; + float seam; + float top; + float middle; + float bottom; + float spatial; + float continuity; + float profileScore; + float edgeCorrelation; + float peakGap; + float roiWeighted; + float confidence; + float textureRatio; + int32_t validBandCount; + int32_t agreeCount; + int32_t bandOffsets[3]; + int32_t bandValid[3]; +} LCDetectResult; + +// 失败帧的跟踪估计(lc_build_tracking_estimate 的返回,对齐 LongCaptureTrackingEstimate: +// 只服务 tentative 跟踪与下一帧先验,绝不进入提交)。 +typedef struct LCTrackingEstimate { + int32_t valid; // 1 = 可用于预测推进 + int32_t direction; // +1 向下滚 / -1 向上滚 + double predictedOffset; // 预计 |位移|(px) + double confidence; // 纯预测置信度(低,LC_TRACK_PREDICT_CONFIDENCE) +} LCTrackingEstimate; + +// 状态快照(LongCaptureContext 会话可读字段的只读打包;会话侧簿记字段—— +// noChangeCount / reachedBottom / weakTries / lastDir / wheelPending / 各 tick / +// frameCount / autoFailStreak / abort / finish / save 标志——按 lc_session_windows.cpp 的 +// 归属属会话层,macOS 由 Swift 侧自持,不进快照)。 +typedef struct LCStateSnapshot { + int32_t physW; // 帧缓冲宽(物理像素) + int32_t physH; // 帧缓冲高(物理像素) + int32_t horizontal; // 0/1 + int32_t stitchH; // 拼接总行数(headRows + bodyRows) + int32_t headRows; // 头部段行数(向上滚前插,倒序存储) + int32_t bodyRows; // 主体段行数(向下滚追加,正序存储) + int64_t committedContentTop;// 正式拼接基准视口顶(内容坐标) + int64_t tentativeContentTop;// 预计当前视口顶(内容坐标,小地图虚线框) + int32_t tentativeValid; // 0/1 + float tentativeConfidence; + int32_t trackUnreliableStreak; + int32_t trackingRevision; // 跟踪状态变更计数(面板按变化刷新) + int32_t sampleIndex; + int32_t pendingValid; // 待复核 Weak 候选 0/1 + int32_t pendingOffset; + float pendingConfidence; + int32_t pendingMode; // LC_MODE_* + int32_t lastFailReason; // LC_FAIL_*(最近一次拒绝分类) + int32_t offsetHistoryLen; // 成功位移历史长度(内容见 lc_get_offset_history) + int32_t wheelAccumDelta; // 自上次成功提交累计的滚轮增量(session 侧累计的只读回读) + float pixelsPerWheelNotch;// px/notch 在线估计(0 = 尚无估计) + int32_t thumbW; // 缩略图列宽(0 = 关闭) + int32_t thumbHeadH; // 缩略图头部段行数 + int32_t thumbH; // 缩略图总行数 + int64_t cropTopY; // 输出窗口上界(内容坐标;INT64_MIN = 开放) + int64_t cropBottomY; // 输出窗口下界(内容坐标;INT64_MAX = 开放) + int32_t cropPendTop; // 待剔除上方区间 0/1 + int32_t cropPendBottom; // 待剔除下方区间 0/1 + int64_t cropPendTopLo; // 待删内容区间 [lo, hi) + int64_t cropPendTopHi; + int64_t cropPendBottomLo; + int64_t cropPendBottomHi; + int32_t cropped; // 是否存在任何生效裁剪约束 0/1 + int32_t interval; +} LCStateSnapshot; + +// 算法层常量导出(lc_session_windows.cpp 采样主循环所需的重试梯 / 节拍 / 到底确认参数; +// 数组维度由算法层常量决定,见 lc_bridge_mac.cpp 的 static_assert)。 +typedef struct LCAlgoConsts { + int32_t sampleAttempts; // LC_SAMPLE_ATTEMPTS(单次采样轮总尝试数) + int32_t retryDelayNormal[5]; // LC_RETRY_DELAY_NORMAL(长度 = LC_SAMPLE_ATTEMPTS - 1) + int32_t weakRetryAttempts; // LC_WEAK_RETRY_ATTEMPTS + int32_t retryDelayWeak[6]; // LC_RETRY_DELAY_WEAK + int32_t stableMaxWaits; // LC_STABLE_MAX_WAITS(稳定性等待预算) + int32_t stableRetryDelay[3]; // LC_STABLE_RETRY_DELAY + int32_t quickResamples; // LC_QUICK_RESAMPLES(瞬态快重采样次数上限) + int32_t resampleDelayQuick[2]; // LC_RESAMPLE_DELAY_QUICK + int32_t scrollSampleMaxGap; // LC_SCROLL_SAMPLE_MAX_GAP(滚动中主动采样节拍上限 ms) + int32_t bottomConfirmSamples; // LC_BOTTOM_CONFIRM_SAMPLES(连续 NoChange 确认到底) + int32_t weakMaxTries; // LC_WEAK_MAX_TRIES(Weak 候选独立采样轮数上限) + int32_t stableRefMaxGapMs; // LC_STABLE_REF_MAX_GAP(稳定性参考帧最大有效间隔 ms) + int32_t cropInsetLogical; // LC_CROP_INSET_LOGI(采样裁剪每边内缩,逻辑像素) + int32_t trackMinStep; // LC_TRACK_MIN_STEP(tentative 推进最小位移 px;小地图 + // 虚线框显示判定用,对齐 lc_panel_ui_windows.cpp 的引用点) +} LCAlgoConsts; + +// ==================== C ABI 函数(注释标注对应算法层 API) ==================== + +// ABI 版本查询(返回 LC_ABI_VERSION;无句柄依赖,供 Swift 侧启动校验)。 +int32_t lc_abi_version(void); + +// —— 会话生命周期 —— +// 对齐 lc_session_windows.cpp BeginLongCapture 的 LongCaptureContext 构造段(new + 配置字段 +// 拷贝;不含任何窗口/抓帧动作)。失败(参数非法)返回 NULL。 +lc_handle_t lc_session_create(const LCSessionConfig* config); +// 销毁会话(对齐 Windows 侧 delete lc;DIB 段归 IO 层所有,macOS 不涉及)。 +void lc_session_destroy(lc_handle_t h); + +// —— 帧管线(喂帧 → 识别/校验/提交一步)—— +// 以首帧初始化基准:镜像 lc_frame_io_windows.cpp LongCaptureInitBaseline(纯数据初始化, +// 不抓屏;lastFrame/body/lastMatch/跟踪基准/第 0 条历史/缩略图首段)。调用后 frame +// 内容即首屏。bgra 长度必须为 w*h 且 w/h 与创建配置一致。返回 1 成功 / 0 参数非法。 +int32_t lc_init_baseline(lc_handle_t h, const uint32_t* bgra, int32_t w, int32_t height); +// 单帧「识别 → offset 校验 →(Weak 档)延迟确认 → 提交」管线:转发 +// LongCaptureTryStitch(internal.h 声明,lc_stitch_state.cpp 定义)。dir = 滚动方向 +//(+1 下 / -1 上 / 0 未知);allowStabilityGate = 启用稳定性闸门(对齐 RunLongCapture +// 传参 !autoScroll && stableWaits < LC_STABLE_MAX_WAITS)。结果含七值结局、拒绝分类、 +// 证据快照与提交差分。返回 1 = 已调用算法层(out 有效)/ 0 = 参数非法(out 不变)。 +int32_t lc_try_stitch(lc_handle_t h, const uint32_t* bgra, int32_t dir, + int32_t allowStabilityGate, LCTryStitchResult* out); + +// —— 纯识别查询(不触碰任何累计状态;诊断与单测用)—— +// 对齐 lc_match_core.cpp LongCaptureDetectMatch(双档识别主入口):两帧 BGRA 各建 +// LongMatchData 后识别;prior 由参数打包(对齐 LongCaptureBuildOffsetPrior 的产物; +// WeakTemporal 用空值,与无候选历史场景一致)。返回 1 成功 / 0 参数非法。 +int32_t lc_detect_match(const uint32_t* prevBgra, const uint32_t* currBgra, + int32_t w, int32_t height, int32_t dir, + int32_t priorValid, int32_t priorExpectedAbsOffset, + LCDetectResult* out); +// 对齐 lc_match_core.cpp LongCaptureWeakRequiredConfidence(Weak 档动态置信度门槛)。 +float lc_weak_required_confidence(int32_t viewportH, int32_t overlap); +// 对齐 lc_match_core.cpp LcFailReasonName(失败分类 → 日志字符串;指针常驻有效)。 +const char* lc_fail_reason_name(int32_t failReason); + +// —— 状态读取 —— +// LongCaptureContext 会话可读字段只读快照(见 LCStateSnapshot 注释)。 +int32_t lc_get_state(lc_handle_t h, LCStateSnapshot* out); +// 成功位移历史(|d| 列表,对齐 lc->offsetHistory;cap 不足时截断,返回实际写入数)。 +int32_t lc_get_offset_history(lc_handle_t h, int32_t* out, int32_t cap); +// 当前输出行窗口(内容坐标 → 拼接行坐标):镜像 lc_frame_io_windows.cpp +// LongCaptureOutputRows(未裁剪 = [0, stitchH);裁剪只收紧输出窗口,不动拼接缓冲)。 +// 返回 1 成功 / 0 参数非法。 +int32_t lc_get_output_rows(lc_handle_t h, int64_t* outTop, int64_t* outBottom); +// 读取拼接缓冲的显示行区间 [rowStart, rowStart+rowCount):headRev(倒序头部段)+ +// body(正序主体段)的双段映射(对齐 LongCaptureBuildResultBitmap 的合并取行序)。 +// outBuf 容量须 ≥ rowCount*physW(按行写入)。返回写入行数 / 0 参数非法。 +int64_t lc_read_rows(lc_handle_t h, int64_t rowStart, int64_t rowCount, + uint32_t* outBuf, int64_t outBufRows); +// 缩略图双段读取(同 lc_read_rows,行宽 = thumbW,头部段行数 = thumbHeadH; +// 供小地图面板两级缩略列绘制,对齐 LongCaptureRebuildThumb 的合并序)。 +int64_t lc_read_thumb_rows(lc_handle_t h, int64_t rowStart, int64_t rowCount, + uint32_t* outBuf, int64_t outBufRows); + +// —— 裁剪(#44 延迟剔除语义)—— +// 裁剪登记:镜像 lc_toolbar_ui_windows.cpp LongCaptureApplyCrop 的纯状态段(UI 刷新调用除外)。 +// row = LC_CROP_DISCARD_TOP / LC_CROP_DISCARD_BOTTOM / LC_CROP_RESET。登记只收紧输出 +// 窗口并记录「待剔除区间」;物理删行由下一次朝该方向的成功提交触发(CommitStitch 入口 +// 的 LongCaptureExecuteCropPurge)。返回 1 已应用 / 0 未应用(参数或行号非法)。 +int32_t lc_apply_crop(lc_handle_t h, int32_t row); +// 对齐 lc_stitch_state.cpp LongCaptureHasCropConstraint。 +int32_t lc_has_crop_constraint(lc_handle_t h); + +// —— 滚轮先验 / Weak 时间一致性 —— +// 对齐 lc_stitch_state.cpp LongCaptureUpdateWheelEstimate(成功提交后折算 px/notch)。 +void lc_update_wheel_estimate(lc_handle_t h, int32_t d); +// 滚轮增量累计(对齐 lc_session_windows.cpp 面板 WM_INPUT 的 wheelAccumDelta += delta; +// 成功提交时由算法层经 lc_update_wheel_estimate 消化清零)。 +void lc_accumulate_wheel_delta(lc_handle_t h, int32_t delta); +// 对齐 lc_stitch_state.cpp LongCapturePushWeakCandidate(登记弱候选时间一致性样本)。 +void lc_push_weak_candidate(lc_handle_t h, int32_t offset); +// 放弃当前 Weak 候选链:镜像 lc_session_windows.cpp 主循环 weakTries 耗尽分支的状态段 +//(pendingMatch.valid = false + weakCandidateOffsets.clear();不计失败、不触碰拼接)。 +void lc_abandon_weak_chain(lc_handle_t h); +// 对齐 lc_stitch_state.cpp LongCaptureOffsetPlausible(历史跳变合理性校验; +// mode = LC_MODE_*)。返回 1 合理 / 0 异常 / -1 参数非法。 +int32_t lc_offset_plausible(lc_handle_t h, int32_t d, int32_t mode); + +// —— Tentative 视觉跟踪(外围状态层入口,绝不触碰正式拼接状态)—— +// 对齐 lc_stitch_state.cpp LongCaptureTrackingSetVisual(视觉依据直接设定,可解冻)。 +void lc_tracking_set_visual(lc_handle_t h, int64_t contentY, float confidence); +// 对齐 lc_stitch_state.cpp LongCaptureTrackingResetToCommitted(候选否决后回退基准)。 +void lc_tracking_reset_to_committed(lc_handle_t h); +// 对齐 lc_stitch_state.cpp LongCaptureTrackingAdvancePredicted(纯预测推进: +// 冻结计数 / 漂移上限 / 历史范围约束由算法层内部保证)。 +void lc_tracking_advance_predicted(lc_handle_t h, int32_t direction, double magnitude); +// 对齐 lc_stitch_state.cpp LongCaptureBuildTrackingEstimate(失败帧的跟踪估计)。 +int32_t lc_build_tracking_estimate(lc_handle_t h, int32_t dir, LCTrackingEstimate* out); + +// —— 常量导出 —— +// 算法层重试梯 / 节拍 / 到底确认常量(lc_session_windows.cpp 主循环所需;Windows 侧这些 +// 常量定义于 lc_match_core.cpp,会话直接引用,macOS 会话经本函数取用避免硬编码漂移)。 +void lc_get_algo_consts(LCAlgoConsts* out); + +#ifdef __cplusplus +} // extern "C" +#endif + +#endif // LC_BRIDGE_H diff --git a/src/screenshot/annotations_windows.cpp b/src/screenshot/windows/annotations_windows.cpp similarity index 100% rename from src/screenshot/annotations_windows.cpp rename to src/screenshot/windows/annotations_windows.cpp diff --git a/src/screenshot/windows/capture_context.h b/src/screenshot/windows/capture_context.h new file mode 100644 index 0000000..0ba891e --- /dev/null +++ b/src/screenshot/windows/capture_context.h @@ -0,0 +1,195 @@ +// 截图模块会话上下文(Windows):CaptureContext 完整定义——区域截图编辑态的全部 +// 状态机字段(选区/工具栏/标注/文字输入/马赛克/GDI+ 会话资源)。 +// 由 internal.h 二次拆分而来(纯移动不改逻辑);依赖同目录 sc_types.h / sc_annotations.h, +// 可独立包含,亦经 internal.h 伞头获得。 +#pragma once + +#include + +// GDI+ 需要 min/max(与 internal.h 伞头的同款注入;重复 using 声明合法) +namespace Gdiplus { + using std::min; + using std::max; +} +#include + +#include "sc_types.h" +#include "sc_annotations.h" + +#include +#include +#include + +// 截图上下文 + +struct CaptureContext { + CaptureState state = CS_Idle; + // 自动确认模式:选区确定后直接提取并完成截图,不进入编辑态(工具栏/标注)。 + // 仅在 WM_LBUTTONUP 的 CS_Selecting 分支生效。 + bool autoConfirm = false; + int virtualX = 0, virtualY = 0, virtualW = 0, virtualH = 0; + int startX = 0, startY = 0, endX = 0, endY = 0; + int mouseX = 0, mouseY = 0; + COLORREF currentColor = 0; + std::vector windows; + int hoveredWindow = -1; // -1 = none + // 预截屏 + HBITMAP screenBitmap = NULL; + HDC memDC = NULL; + // 双缓冲 + HDC backDC = NULL; + HBITMAP backBitmap = NULL; + // 脏区域追踪 + RECT lastPanelRect = {}; + RECT lastSelectionRect = {}; + RECT lastLabelRect = {}; + RECT lastHighlightRect = {}; + RECT lastToolbarRect = {}; + RECT lastPopupRect = {}; + // P1 局部刷新用:上帧光标/被操作标注/正在绘制标注的包围盒(供 InvalidateRect 计算旧位置) + RECT lastCaretRect = {}; // 上帧文字光标矩形(backDC 坐标),hasLastCaret=false 表示无效 + bool hasLastCaret = false; + RECT lastAnnotationBox = {}; // 上帧被拖拽/缩放标注的包围盒(绝对虚拟屏幕坐标) + bool hasLastAnnotationBox = false; + RECT lastDrawingBox = {}; // 上帧 curDrawing 包围盒(绝对虚拟屏幕坐标) + bool hasLastDrawingBox = false; + bool needFullRedraw = false; + // DPI 缩放因子(逻辑像素 → 物理像素 = 乘以 dpiScale;物理 → 逻辑 = 除以)。 + // 【已知限制 —— 单一 scale 模型】CaptureVirtualScreen 把所有显示器的物理并集 + // BitBlt 进一张连续物理位图,再令 dpiScale = physVw / vw(物理并集宽 / 逻辑并集宽)。 + // 单显示器(含系统级统一 DPI 缩放)下该值精确;混合 DPI 多显示器下它是各屏 scale + // 的加权混合值,无法还原为任一具体显示器——选区跨越不同 DPI 的屏幕时,按此单一 + // scale 做逻辑↔物理换算会产生系统性像素偏移(越界采样、坐标错位)。 + // 正确修复需升级为 per-monitor 模型:每个逻辑↔物理换算点按鼠标/选区所在显示器 + // 用 MonitorFromPoint+GetMonitorInfo+GetDpiForMonitor 取各自 scale,并改造 + // CaptureVirtualScreen 的整屏 BitBlt 与位图布局以保留各屏原始 DPI 采样(而非混合 + // 进单一连续网格)。该改动触及捕获/存储/渲染全链路且必须经真实多屏异 DPI 环境验证, + // 当前任务禁运行时测试约束下无法安全实施,故仅标注已知限制 + 统一各换算点的写法。 + double dpiScale = 1.0; + // GDI 资源 + SCGdiResources gdi; + SCPanelMetrics panelMetrics; + + // ---- 确认态:可调整选区 ---- + // 已确认的选区(绝对屏幕坐标) + RECT selection = {}; + // 当前正在拖拽的手柄(CS_Resizing 时有效),CS_Confirmed 下表示 hover 手柄 + int resizeHandle = RH_None; + // 整体拖动/调整起点(绝对屏幕坐标) + int dragStartX = 0, dragStartY = 0; + RECT dragStartSelection = {}; + // 选区圆角半径(0=直角;上限=min(w,h)/2,由 ClampCornerRadius 保证) + int selectionCornerRadius = 0; + // 圆角手柄拖拽起始半径(RH_CornerRadiusTL/TR/BL/BR 拖拽用,增量映射) + int dragStartRadius = 0; + // 当前"靠近/拖拽"的倒角手柄角(RH_CornerRadiusTL/TR/BL/BR 之一;RH_None=未靠近)。 + // 仅鼠标靠近某角或正拖拽某角时显示该角一个倒角手柄,其余时刻隐藏。 + int hoveredCornerHandle = RH_None; + // 键盘方向键微调累计位移(CS_Resizing 时叠加到鼠标位移上,松开时一并固化) + int kbDX = 0; + int kbDY = 0; + + // ---- 悬浮工具栏 ---- + // 工具栏矩形(相对虚拟屏幕坐标,绘制用) + RECT toolbarRect = {}; + // 用户按住最左「6 点把手」拖动过后置位:此后 OnPaint 直接沿用 toolbarRect, + // 不再随选区自动重算(会话内拖动/缩放选区时工具栏保持用户放置的位置) + bool toolbarPlaced = false; + // 正在拖动工具栏(把手左键按下未松开):MOUSEMOVE 平移 toolbarRect 并局部刷新 + bool toolbarDragging = false; + int toolbarDragStartX = 0, toolbarDragStartY = 0; // 按下时鼠标位置(绝对屏幕坐标) + RECT toolbarDragStartRect = {}; // 按下时的工具栏矩形(相对坐标) + // 工具栏 hover 按钮,-1 = none(SC_TB_GRIP = 悬停在拖拽把手上) + int hoverToolbarBtn = -1; + // ---- 工具栏 title 式 tooltip(网页 title 同款:悬停停顿出现深色圆角气泡)---- + // 由会话空闲循环轮询维护(TickToolbarTooltip),气泡画进 backDC(DrawToolbarTooltip) + int tipBtn = -1; // 当前停顿目标按钮(-1 = 无;分隔线无 tooltip) + DWORD tipDwellSince = 0; // 光标进入目标按钮的起始时刻(毫秒) + bool tipShown = false; // 气泡当前是否在屏(负责自身矩形的失效重绘) + RECT tipBubbleRect = {}; // 气泡矩形(backDC 相对坐标) + std::wstring tipText; // 气泡文本 + // 当前激活的工具(高亮显示,仅界面) + int activeTool = -1; + // 当前子菜单/参数面板对应的工具来源;拖拽工具下选中覆盖物时可继续回显其参数。 + int popupTool = -1; + // 工具栏图标位图缓存(按 DPI 预渲染,dark/white 双色) + SCIconCache iconCache; + // 当前 DPI 下的工具栏几何(缓存,避免每次绘制重算) + SCToolbarMetrics toolbarMetrics; + // 当前 DPI 下的手柄几何(缓存:选区/标注 resize 手柄 + 圆角手柄) + SCHandleMetrics handleMetrics; + + // ---- 标注绘制 ---- + std::vector annotations; // 已提交标注 + // 撤销/重做快照栈(队首=最老)。undoStack 由 PushAnnotationHistory 写入并限深 SC_UNDO_MAX_DEPTH; + // 重做路径(UndoAnnotations→redoStack / RedoAnnotations→undoStack)每次入栈前必有一次对应的 + // 出栈,数学上不会超过同一上限。 + std::deque> undoStack; + std::deque> redoStack; + Annotation curDrawing; // CS_Drawing 中正在绘制的标注 + bool hasCurDrawing = false; // curDrawing 是否有效 + int drawColorIdx = 0; // 当前选中颜色索引 + int drawThickIdx = 0; // 当前选中粗细索引(矢量工具) + int fontSizeIdx = 0; // 当前选中字号索引(文字工具) + // 马赛克工具属性 + int mosaicSizeIdx = 0; // 当前选中马赛克块大小索引 + int mosaicRadiusIdx = 0; // 当前选中涂抹半径索引 + bool mosaicRectMode = false; // true=框选区域模式;false=涂抹模式 + // 涂抹模式光标:用系统光标机制(SetCursor)显示半径圆,由 OS 跟随鼠标, + // 无 WM_PAINT 重绘延迟(之前的 overlay 圆走 MOUSEMOVE→InvalidateRect→WM_PAINT 链路, + // 全屏重绘开销大导致不跟手)。按半径预设预生成彩色光标并缓存。 + HCURSOR mosaicBrushCursors[SC_MOSAIC_RADIUS_COUNT] = {}; // 对应 SC_MOSAIC_RADIUS_COUNT 个半径预设的光标 + bool mosaicBrushCursorsInited = false; + // ---- 马赛克渲染(reveal-mask 模型,消除不连续感)---- + // 预先把整张截图按当前块大小马赛克化得到 mosaicBase(逻辑像素,与 backDC 同尺寸)。 + // 马赛克标注只是「蒙版」:涂抹=路径圆形区域、框选=矩形区域,揭示其背后的 mosaicBase。 + // 这样任意区域、任意顺序叠加都连续无缝;切换块大小时只需重建 base,已揭示区域自动更新。 + // mosaicBase 覆盖整虚拟屏幕(绝对坐标),与选区无关,resize/move 无需重建。 + HDC mosaicBaseDC = NULL; + HBITMAP mosaicBaseBitmap = NULL; + int mosaicBaseW = 0, mosaicBaseH = 0; // base 尺寸(= 虚拟屏幕逻辑尺寸) + int mosaicBaseBlockPx = 0; // 生成 base 时的块大小(检测变更触发重建) + // 涂抹模式增量绘制:记录上一帧最后绘制的路径点索引(reveal 模型下未使用,保留扩展)。 + int mosaicDrawLastIdx = 0; + // 粗细/颜色子菜单 + bool popupOpen = false; + RECT popupRect = {}; + SCPopupMetrics popupMetrics; + + // ---- 文字输入(CS_TextEditing)---- + std::wstring textBuf; // 正在输入的文字缓冲 + int textAnchorX = 0, textAnchorY = 0; // 文字锚点(绝对虚拟屏幕坐标) + int textCaretPos = 0; // 插入符在 textBuf 中的 wchar 位置 + bool textCaretVisible = false; // 光标是否可见(闪烁控制) + DWORD textCaretLastBlink = 0; // 上次光标闪烁时间(毫秒) + int textSelStart = -1; // 文字选择起始位置(-1 表示无选择) + int textSelEnd = -1; // 文字选择结束位置 + bool textDraggingSelection = false; // 是否正在拖动选择文字 + int hoveredTextAnnotation = -1; // 悬浮命中的文字标注索引(-1 表示无,仅用于光标/即时反馈) + int selectedTextAnnotation = -1; // 已选中的文字标注索引(-1 表示无,持久保持直到点空白) + int draggingTextAnnotation = -1; // 正在拖动的文字标注索引(-1 表示无) + int textDragStartX = 0, textDragStartY = 0; // 文字拖动起始位置 + // ---- 非文字标注的选中/拖拽/缩放(与文字机制互斥:选中非文字时清文字选中,反之亦然)---- + int hoveredAnnotation = -1; // 悬浮命中的非文字标注索引(-1=无,用于虚线框/光标即时反馈) + int selectedAnnotation = -1; // 已选中的非文字标注索引(-1=无,持久保持直到点空白/进入其他操作) + int draggingAnnotation = -1; // 正在拖拽的非文字标注索引(-1=无) + int resizingAnnotation = -1; // 正在缩放的非文字标注索引(-1=无) + int annotationResizeHandle = RH_None; // 当前缩放手柄(RH_None=无;CS_Resizing 时为四角之一) + int annotationDragStartX = 0, annotationDragStartY = 0; // 鼠标按下位置(绝对坐标,拖拽/缩放共用) + Annotation dragStartAnnotation; // 按下时标注快照(拖拽时还原+平移) + bool annotationOpHistoryPushed = false; + RECT annotationResizeStartBox = {}; // 按下时包围盒(缩放时基准) + + // ---- GDI+ 会话级资源(性能优化:会话内单次 Startup/Shutdown)---- + // 原实现每个绘制/测量函数各自 GdiplusStartup/Shutdown,每帧 WM_PAINT 触发 6~10 次昂贵的 + // GDI+ 初始化,是拖拽卡顿的主因。由于所有 GDI+ 调用均在 ScreenshotCaptureThread 单线程内, + // 改为会话开始 Startup 一次、结束 Shutdown 一次。FontFamily(SC_FONT_FACE) 与 + // StringFormat(总是 Near/Near) 为常量;Font 仅依赖 fontPx(文字字号仅 SC_FONT_SIZES 三档), + // 均缓存复用。Graphics 仍每次按 hdc 新建(必须,因为绑定不同 DC)。 + ULONG_PTR gdipToken = 0; // GDI+ 启动令牌(0 = 未初始化) + Gdiplus::GdiplusStartupInput gdipStartupInput; + bool gdipInited = false; // GDI+ 是否已 Startup + Gdiplus::FontFamily* gdipFontFamily = nullptr; // SC_FONT_FACE,会话内唯一 + Gdiplus::StringFormat* gdipStrFmt = nullptr; // Near/Near,会话内唯一 + Gdiplus::Font* gdipFonts[3] = {}; // 按 SC_FONT_SIZES 预建的 Font(索引对齐 SC_FONT_COUNT) +}; diff --git a/src/screenshot/capture_windows.cpp b/src/screenshot/windows/capture_windows.cpp similarity index 100% rename from src/screenshot/capture_windows.cpp rename to src/screenshot/windows/capture_windows.cpp diff --git a/src/screenshot/icons_windows.cpp b/src/screenshot/windows/icons_windows.cpp similarity index 96% rename from src/screenshot/icons_windows.cpp rename to src/screenshot/windows/icons_windows.cpp index 04777fa..40f12d9 100644 --- a/src/screenshot/icons_windows.cpp +++ b/src/screenshot/windows/icons_windows.cpp @@ -4,12 +4,12 @@ // ---- nanosvg:SVG 光栅化(单文件库,宏实例化)---- // 两个 .h 必须在同一编译单元用宏实例化一次;本文件(icons_windows.cpp)为唯一实例化编译单元。 #define NANOSVG_IMPLEMENTATION -#include "../third_party/nanosvg.h" +#include "../../third_party/nanosvg.h" #define NANOSVGRAST_IMPLEMENTATION -#include "../third_party/nanosvgrast.h" +#include "../../third_party/nanosvgrast.h" // ---- 截图工具栏图标 SVG 文本(构建期由 scripts/gen-icons.js 从 src/assets 生成)---- -#include "../generated/icon_svgs.h" +#include "../../generated/icon_svgs.h" // ---- SVG 图标光栅化 + 缓存 ---- @@ -127,7 +127,7 @@ void SCIconCache::Cleanup() { inited = false; } -// CR-018: 防常量漂移。kIconSvgs(generated/icon_svgs.h,按 ToolButton 枚举顺序) +// 防常量漂移。kIconSvgs(generated/icon_svgs.h,按 ToolButton 枚举顺序) // 元素数必须等于 TB_Count:Init/Cleanup 均以 TB_Count 为循环上界索引 kIconSvgs[i], // 漂移将越界读 nullptr 或漏绘图标。 static_assert(sizeof(kIconSvgs) / sizeof(kIconSvgs[0]) == TB_Count, diff --git a/src/screenshot/windows/internal.h b/src/screenshot/windows/internal.h new file mode 100644 index 0000000..ad3f1f0 --- /dev/null +++ b/src/screenshot/windows/internal.h @@ -0,0 +1,272 @@ +// 截图模块共享内部头(Windows 侧唯一总入口):系统包含、拆分类型头伞包含、 +// 长截图 Windows 会话机制(WM_LONGCAPTURE_RUN)、可变全局变量与跨文件函数声明。 +// 由 screenshot_windows.cpp 拆分而来;2026-08 二次拆分:类型定义下沉至本目录 +// sc_types.h / sc_annotations.h / sc_theme.h / capture_context.h,长截图跨平台纯数据 +// 类型迁至 ../algo/long_capture_internal.h(双平台唯一权威)。既有 .cpp 只包含本头的 +// 用法保持不变。 +#pragma once + +#include +#include +#include +#include // For GET_X_LPARAM, GET_Y_LPARAM +#include +// 冷门头 // 不在此集中包含(本头类型声明均不依赖其符号), +// 仅由唯一使用方 .cpp 自行引入:imm.h → overlay_input_windows.cpp(IME 输入法)、 +// commdlg.h → output_windows.cpp(GetSaveFileNameW 保存对话框)、 +// shlobj.h → output_windows.cpp(SHGetKnownFolderPath 已知文件夹路径)。 +#include +#include +#include // For std::min, std::max +#include +#include +#include +#include // For std::sqrt, std::fabs +#include +#include +#include // For INT64_MIN / INT64_MAX(长截图裁剪内容坐标哨兵) +#include // For std::move(最近帧历史的匹配数据转移) + +// DWMWA_CLOAKED 在较新的 Windows SDK 中定义,为了兼容性手动定义 +#ifndef DWMWA_CLOAKED +#define DWMWA_CLOAKED 14 +#endif + +// GDI+ 需要 min/max +namespace Gdiplus { + using std::min; + using std::max; +} +#include + + +#include "screenshot_windows.h" + +// ---- 拆分类型头(各自自包含;此处伞包含使既有 .cpp 的单头用法不变)---- +#include "sc_types.h" // 基础类型与常量(状态枚举 / 几何 / metrics / GDI 资源 / 结果结构) +#include "sc_annotations.h" // 标注类型与绘制参数预设 +#include "sc_theme.h" // 主题色常量 +#include "capture_context.h" // CaptureContext 会话上下文 +#include "long_capture_internal.h" // 长截图跨平台类型与子系统声明(../algo/,经构建 include_dirs 检索) + +// ==================== 长截图(手动滚动捕获) ==================== + +// 进入方式:在编辑态(CS_Confirmed)点工具栏「长截图」按钮 → BeginLongCapture +// 隐式切换到 CS_LongCapturing,通过 PostMessage(WM_LONGCAPTURE_RUN) 在覆盖层 +// 窗口过程中执行 RunLongCapture。 +// +// 快照选区 → 隐藏全屏覆盖层 → 创建侧边预览面板(缩略小地图 + 完成/取消)→ +// 滚轮停稳后采样一帧并增量拼接到小地图;向下滚追加到底部,向上滚前插到头部。 +// +// 交互形态为手动模式:用户自己在选区上滚动鼠标滚轮(Raw Input 被动观察,不拦截输入), +// +// 独立于 CaptureContext 编辑态状态机的轻量上下文。复用截图会话的线程与 +// threadsafe 回调(g_screenshotTsfn),但滚动/拼接逻辑全部在此处实现, +// 不触碰 CaptureContext 的标注/工具栏机制。 + +#define WM_LONGCAPTURE_RUN (WM_APP + 200) + +// ---- 可变全局变量(定义见各归属 .cpp)---- +// 访问规则(此前线程归属散落在各字段注释,现集中声明): +// 线程模型——本模块仅两条线程访问这些全局:JS 线程(NAPI 导出 start/abort 等)与 +// 截图捕获线程(ScreenshotCaptureThread,串行创建所有覆盖层窗口并派发其消息, +// 各窗口 wndproc 与绘制函数均在该线程内执行,互斥天然串行)。 + +// 覆盖层主窗口句柄:捕获线程独占。start() 时在捕获线程创建,消息循环内读写, +// WM_DESTROY 末尾由捕获线程置 NULL(wndproc_windows.cpp)。JS 线程不直接访问。 +extern HWND g_screenshotOverlayWindow; + +// 截图进行中标志(atomic):跨线程。捕获线程 start 入口置 true、各退出路径置 false; +// JS 线程 start() 入口读它拒绝重入(已进行中则直接返回)。无锁,依赖 atomic 可见性。 +extern std::atomic g_isCapturing; + +// 截图结果回传 JS 的 threadsafe function:跨线程但写时序确定。JS 线程在 +// StartRegionCaptureWithPrimedFrame 创建并初始化引用计数;此后由捕获线程独占 +// 调用/释放(EmitScreenshotResult 唯一调用点 + ReleaseScreenshotTsfn 唯一释放点, +// 均在捕获线程内),JS 线程不再触碰。收口后访问点唯一。 +extern napi_threadsafe_function g_screenshotTsfn; + +// 截图捕获线程对象:JS 线程独占。start() 创建后立即 detach,此后不再访问 +// (依赖 g_isCapturing 而非 join 判定会话状态,故无需 join;线程自然退出)。 +extern std::thread g_screenshotThread; + +// 自动确认模式(atomic):跨线程。JS 线程 start() 写入;捕获线程在会话开始时 +// 一次性 load 进 CaptureContext.autoConfirm,会话期内不再读此全局。 +extern std::atomic g_autoConfirm; + +// 预截首帧(及保护其读写的互斥锁):跨线程,受 g_primedScreenshotFrameMutex 保护。 +// JS 线程(PrimeScreenshotFrameNow/PrimeScreenshotFrame)与捕获线程(会话开始消费) +// 均在锁内读写 bitmap 与 valid 等字段;bitmap 句柄所有权在锁内转移(写方创建、 +// 读方消费后置 invalid)。锁外不得解引用 bitmap。 +extern PrimedScreenshotFrame g_primedScreenshotFrame; +extern std::mutex g_primedScreenshotFrameMutex; + +// 截图上下文指针:捕获线程独占。会话开始置 &ctx(栈上局部变量)、结束置 nullptr; +// 窗口过程/绘制函数读它取 GDI+ 会话级资源(仅捕获线程内执行)。会话期外恒为 nullptr, +// 各读点均带 nullptr 守卫。JS 线程不访问。 +extern CaptureContext* g_captureCtx; + +// 长截图上下文指针(atomic):跨线程,受 g_longCtxMutex 保护指针生命周期。 +extern std::atomic g_longCtx; +// 保护 g_longCtx 指针的生命周期:JS 线程 LongCaptureAbort 的 load/abortFlag 写, +// 与捕获线程 WM_LONGCAPTURE_RUN 清理段的 store(nullptr)/delete 互斥(定义见 +// session_windows.cpp)。锁内只做指针读写与原子标志写,禁止 SendMessage 等 +// 可能死锁的调用;abortFlag 本身保持 atomic,由锁保证写入时对象仍存活。 +extern std::mutex g_longCtxMutex; + +// 长截图会话窗口句柄(控制面板/工具栏/蒙版):捕获线程独占。BeginLongCapture +// 时由捕获线程创建,wndproc 清理段(捕获线程)销毁并置 NULL。JS 线程不访问。 +extern HWND g_longControlWindow; +extern HWND g_longToolbarWindow; +extern HWND g_longMaskWindow; + +// 长截图参数:跨线程,但时序天然串行——JS 线程 start() 写入(先按默认重置再按 JS +// 覆盖,消除跨会话粘滞),捕获线程 BeginLongCapture 一次性读入 LongCaptureContext。 +// start() 写入发生在 g_screenshotThread 创建之前,捕获线程读在创建之后,happens-before +// 由线程创建建立,无需额外同步。 +extern int g_lcInterval; + +// ---- 跨文件函数声明 ---- +bool IsCornerRadiusHandle(int h); +bool IsVectorTool(int btn); +bool IsDragTool(int btn); +bool CanShowStylePopupTool(int btn); +AnnotationType ToolToAnnotationType(int btn); +int AnnotationTypeToTool(AnnotationType t); +SCPanelMetrics CalcPanelMetrics(double dpiScale); +SCToolbarMetrics CalcToolbarMetrics(double dpiScale); +SCHandleMetrics CalcHandleMetrics(double dpiScale); +HBITMAP RenderSvgToBitmap(const char* svgText, COLORREF color, int px); +int CalcToolbarWidth(const SCToolbarMetrics& m); +SCPopupMetrics CalcPopupMetrics(double dpiScale); +// 多屏异分辨率布局基准:取包含参考矩形(绝对坐标)的显示器物理边界。 +// 工具栏/子菜单的上下翻转判定必须用所在显示器自身的边界——整个虚拟屏幕包围盒 +// 会被高分屏拉大,低分屏上选区已触本屏底边仍会被误判为"下方放得下"而不翻转。 +bool GetMonitorBoundsForRect(const RECT& refAbs, RECT& out); +void CalcPopupPlacement(const RECT& toolbarRect, int virtualX, int virtualY, + int virtualW, int virtualH, const SCPopupMetrics& m, int pw, int ph, RECT& out); +void CalcPopupPosition(const RECT& toolbarRect, int virtualX, int virtualY, + int virtualW, int virtualH, const SCPopupMetrics& m, RECT& out); +void CalcMosaicPopupSize(const SCPopupMetrics& m, int& outW, int& outH); +int HitTestMosaicPopup(int x, int y, const RECT& popupRect, const SCPopupMetrics& m); +void DrawMosaicPopup(HDC hdc, const RECT& popupRect, int modeIdx, int sizeIdx, int radiusIdx, const SCPopupMetrics& m); +int HitTestPopup(int x, int y, const RECT& popupRect, const SCPopupMetrics& m); +void DrawPopup(HDC hdc, const RECT& popupRect, int colorIdx, int firstIdx, bool isTextTool, const SCPopupMetrics& m); +void PushAnnotationHistory(CaptureContext* ctx); +bool UndoAnnotations(CaptureContext* ctx); +bool RedoAnnotations(CaptureContext* ctx); +bool InitGdipResources(CaptureContext* ctx); +Gdiplus::Font* GetGdipFont(CaptureContext* ctx, int fontPx); +double GetDpiScaleFactor(); +BOOL CALLBACK MonitorEnumProc(HMONITOR hMonitor, HDC hdcMonitor, LPRECT lprcMonitor, LPARAM dwData); +bool CreateBackBuffer(HDC& outDC, HBITMAP& outBmp, int w, int h); +bool PrimeScreenshotFrameNow(); +bool AcquireScreenshotBase(HDC& outMemDC, HBITMAP& outBitmap, int& vx, int& vy, int& vw, int& vh, double& dpiScale); +COLORREF GetPixelColorFromBitmap(HDC memDC, int x, int y, int vx, int vy, double dpiScale); +void ColorrefToStrings(COLORREF color, char* hexBuf, char* rgbBuf); +std::vector EnumWindowsForCapture(); +int FindWindowAtPoint(const std::vector& windows, int x, int y); +void CalcPanelPosition(int mx, int my, int vx, int vy, int vw, int vh, const SCPanelMetrics& m, int& px, int& py); +void CalcResizePanelPosition(int handle, const RECT& sel, int vx, int vy, int vw, int vh, const SCPanelMetrics& m, int& px, int& py); +void RestoreDirtyRegion(HDC backDC, HDC memDC, const RECT& dirty, double dpiScale); +RECT InflateRectBy(const RECT& r, int margin); +bool IsValidRect(const RECT& r); +RECT UnionRectSafe(const RECT& a, const RECT& b); +void DrawInfoPanel(HDC hdc, int panelX, int panelY, COLORREF color, HDC memDC, int vx, int vy, int mx, int my, double dpiScale, const SCGdiResources& gdi, const SCPanelMetrics& m, int virtualW, int virtualH); +RECT DrawSizeLabel(HDC hdc, int width, int height, int refLeft, int refTop, int refRight, int refBottom, int virtualW, int virtualH, const SCGdiResources& gdi, const SCPanelMetrics& m); +RECT DrawSelection(HDC hdc, int x1, int y1, int x2, int y2, int vx, int vy, int vw, int vh, const SCGdiResources& gdi, const SCPanelMetrics& m); +void DrawWindowHighlight(HDC hdc, const RECT& rect, int vx, int vy, const SCGdiResources& gdi); +void DrawDimMask(HDC backDC, const SCGdiResources& gdi, int selLeft, int selTop, int selRight, int selBottom, int virtualW, int virtualH, int radius); +RECT NormalizeRect(const RECT& r); +bool PointInRect(int x, int y, const RECT& r); +int HitTestCornerRadiusHandle(int x, int y, const RECT& sel, int handleSize, int inset, int radius); +int FindNearestCornerRadiusHandle(int x, int y, const RECT& sel, int handleSize, int inset, int radius, int proximityMargin); +RECT CornerHandleDirtyRect(const CaptureContext* ctx, int corner); +int HitTestHandle(int x, int y, const RECT& sel, int handleSize); +LPCWSTR HandleCursor(int handle); +void CalcToolbarPosition(const RECT& selRel, int virtualX, int virtualY, + int virtualW, int virtualH, const SCToolbarMetrics& m, RECT& out); +int HitTestToolbar(int x, int y, const RECT& toolbarRect, const SCToolbarMetrics& m); +void DrawResizeHandles(HDC hdc, const RECT& selRel, int handleSize); +void DrawCornerRadiusHandle(HDC hdc, const RECT& selRel, int handleSize, int inset, int radius, int corner); +void DrawConfirmedBorder(HDC hdc, const RECT& selRel, const SCGdiResources& gdi, int radius); +void AddRoundedRect(Gdiplus::GraphicsPath& outPath, int x, int y, int w, int h, int radius); +void DrawToolbar(HDC hdc, const RECT& toolbarRect, int hoverBtn, int activeTool, const SCGdiResources& gdi, const SCToolbarMetrics& m, const SCIconCache& icons); +// 工具栏 title 式 tooltip:Tick 由会话空闲循环轮询(维护停顿/显示状态并失效气泡矩形), +// Draw 在 OnPaint 工具栏之后调用(气泡画进 backDC,盖在工具栏/子菜单之上)。 +void TickToolbarTooltip(CaptureContext* ctx, HWND overlayWnd); +void DrawToolbarTooltip(HDC hdc, CaptureContext* ctx); +bool MosaicBlitRect(HDC targetDC, HDC srcDC, int dstX0, int dstY0, int dstW, int dstH, int srcAbsX0, int srcAbsY0, int blockPx, int virtualX, int virtualY, double dpiScale); +void FreeMosaicBase(CaptureContext* ctx); +void InitMosaicBrushCursors(CaptureContext* ctx); +void FreeMosaicBrushCursors(CaptureContext* ctx); +bool RebuildMosaicBase(CaptureContext* ctx); +bool MosaicBaseNeedsRebuild(const CaptureContext* ctx); +bool HasMosaicToRender(const std::vector& annotations, const Annotation* curDrawing); +void RevealMosaicToTarget(HDC targetDC, HDC mosaicBase, const std::vector& annotations, const Annotation* curDrawing, const RECT& contentBounds, float ox, float oy); +void DrawAnnotations(HDC hdc, const RECT& selRel, int virtualX, int virtualY, const std::vector& annotations, const Annotation* curDrawing); +void CompositeAnnotations(HDC finalDC, HDC srcDC, const std::vector& annotations, const RECT& rect, int virtualX, int virtualY, double dpiScale, int mosaicBlockPx); +std::string BitmapToBase64Png(HBITMAP hBitmap); +bool SaveBitmapToClipboard(HBITMAP hBitmap); +// 将 HBITMAP 编码为 PNG:可选产出 base64 / 原始字节,可选直接写入文件(单次编码)。 +bool EncodeHBitmapPng(HBITMAP hBitmap, std::string* base64Out, std::string* rawOut, + const wchar_t* filePath); +// 将 HALFTONE 缩放模式设置到目标 DC(output_windows.cpp / long_capture_windows.cpp 共用)。 +// HALFTONE 在做下采样缩放时比默认 COLORONCOLOR 质量更好,但 BrushOrg 会被 StretchBlt 用到, +// 因此同时把画刷原点复位到 (0,0) 避免抖动(MSDN 推荐配套调用)。 +void SetHalftoneStretchMode(HDC dc); + +ScreenshotResult* ExtractRegionResult(HDC memDC, const RECT& rect, int vx, int vy, + double dpiScale, const std::vector& anns, int radius, int mosaicSizeIdx); +// 统一的 ScreenshotResult 发射口:字段参数化构造结果并经截图会话 TSFN 回传 JS。 +// 内部统一处理守卫:TSFN 未就绪或 napi_tsfn_nonblocking 因队列满返回非 napi_ok 时, +// 自行 delete 分配的 result 防泄漏(CallScreenshotJs 只在成功入队时才 delete)。 +// 仅允许在截图线程内调用。各发射点(确认/取消/保存/ESC/长截图完成等)统一走此函数。 +// success=false 时坐标/尺寸/base64 全置 0/空(与取消路径语义一致)。 +void EmitScreenshotResult(bool success, int x = 0, int y = 0, int x2 = 0, int y2 = 0, + int width = 0, int height = 0, const std::string& base64 = ""); +// 会话初始化失败快速回传:构造 {success:false} 结果并经 EmitScreenshotResult 回传 JS, +// 唤醒 await 方避免永久挂起(早退路径统一收口点)。 +void FailFast(); +void BeginLongCapture(CaptureContext* ctx, HWND overlayHwnd); +bool RunLongCapture(LongCaptureContext* c); +void LongCaptureAbort(); +std::wstring PromptSaveFilePath(HWND hwndOwner); +bool SaveRegionToPngFile(HDC memDC, const RECT& rect, int vx, int vy, double dpiScale, + const std::vector& anns, const std::wstring& filePath, int radius, int mosaicSizeIdx); +void ClampCornerRadius(CaptureContext* ctx); +bool CalcAnnotationsBounds(std::vector& anns, RECT& out, HDC hdc); +RECT MeasureTextAnnotation(HDC hdc, Annotation& a); +int HitTestTextAnnotations(std::vector& anns, int x, int y, HDC hdc); +RECT MeasureAnnotationBounds(Annotation& a, HDC hdc); +int HitTestAnnotation(std::vector& anns, int x, int y, HDC hdc); +int HitTestAnnotationResizeHandle(const Annotation& a, int x, int y, HDC hdc, int handleSize); +void TransformAnnotationByBox(Annotation& a, const RECT& oldBox, const RECT& newBox); +void MeasureTextGdip(HDC hdc, const std::wstring& text, int fontPx, float& outOffsetX, float& outOffsetY, float& outW, float& outH); +int CalcCaretPosFromMouse(HDC hdc, const std::wstring& text, int fontPx, int textX, int mouseX); +void InvalidateAnnotationOp(HWND hwnd, CaptureContext* ctx, const RECT& curBox); +RECT CalcSelectionDirty(CaptureContext* ctx, bool includeToolbar); +void InvalidateTextLine(HWND hwnd, CaptureContext* ctx); + +// ---- 选区状态机辅助(实现在 wndproc_windows.cpp,消息处理共用)---- +void EnterConfirmed(CaptureContext* ctx, const RECT& sel); +RECT ResizeSelectionFromHandle(const RECT& startSelection, int handle, int dx, int dy, + const RECT& virtualBounds, + bool hasContent, const RECT& contentBounds, + bool enforceMinSize); +void GetResizeHandleAnchor(int handle, const RECT& sel, int& ax, int& ay); +void ApplyResizeSelection(HWND hwnd, CaptureContext* ctx); +bool HandleSelectionNudgeKey(HWND hwnd, CaptureContext* ctx, WPARAM vk); + +// ---- 覆盖层消息处理(实现在 overlay_paint/overlay_input,由 WndProc 分发)---- +LRESULT OnPaint(HWND hwnd, CaptureContext* ctx); +LRESULT OnLButtonDown(HWND hwnd, CaptureContext* ctx); +LRESULT OnMouseMove(HWND hwnd, CaptureContext* ctx); +LRESULT OnLButtonUp(HWND hwnd, CaptureContext* ctx); +LRESULT OnKeyDown(HWND hwnd, WPARAM wParam, CaptureContext* ctx); +LRESULT OnImeComposition(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam, CaptureContext* ctx); +LRESULT OnChar(HWND hwnd, WPARAM wParam, CaptureContext* ctx); +LRESULT OnSetCursor(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam, CaptureContext* ctx); + +LRESULT CALLBACK ScreenshotOverlayWndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam); +void ScreenshotCaptureThread(); diff --git a/src/screenshot/lc_frame_io.cpp b/src/screenshot/windows/lc_frame_io_windows.cpp similarity index 99% rename from src/screenshot/lc_frame_io.cpp rename to src/screenshot/windows/lc_frame_io_windows.cpp index 876002e..aeb54a8 100644 --- a/src/screenshot/lc_frame_io.cpp +++ b/src/screenshot/windows/lc_frame_io_windows.cpp @@ -1,5 +1,5 @@ // 长截图子系统:抓帧 / DIB / 缩略图 / 位图构建 / 消息泵。 -// CR-021 拆分自 long_capture_windows.cpp 的「滚轮观察 / 逐帧采样」与 +// 拆分自 long_capture_windows.cpp 的「滚轮观察 / 逐帧采样」与 // 「缩略图 / 输出行窗口 / 结果位图 / 消息泵 / 首帧初始化」段。 #include "internal.h" #include "long_capture_internal.h" diff --git a/src/screenshot/lc_panel_ui.cpp b/src/screenshot/windows/lc_panel_ui_windows.cpp similarity index 99% rename from src/screenshot/lc_panel_ui.cpp rename to src/screenshot/windows/lc_panel_ui_windows.cpp index bc0cd57..bdef65d 100644 --- a/src/screenshot/lc_panel_ui.cpp +++ b/src/screenshot/windows/lc_panel_ui_windows.cpp @@ -1,5 +1,5 @@ // 长截图子系统:侧边小地图面板 + 全屏灰蒙版 UI。 -// CR-021 拆分自 long_capture_windows.cpp 的「预览面板 / 全屏蒙版」段。 +// 拆分自 long_capture_windows.cpp 的「预览面板 / 全屏蒙版」段。 // 面板分层渲染(UpdateLayeredWindow 原子提交)+ 滚轮观察目标窗口 + 蒙版取景窗。 // EnsureArgbSurface/FreeArgbSurface 是面板/工具栏/tooltip 共用的 32bpp 预乘 ARGB // 后备 DIB 辅助,在此定义(long_capture_internal.h 声明)。 @@ -42,7 +42,7 @@ static RECT LongCapturePanelPreviewRect(LongCaptureContext* c, int cw, int ch) { if (rows <= 0) return r; // 显示空间(物理像素)宽高:固定轴 = physW(= cropRect 的物理对应,纵向=宽/横向=高), // 滚动轴 = rows。输出为面板物理矩形,故用物理量;逻辑标签版见 LongCaptureOutputSizeLabel, - // 高度生长版见 LongCapturePanelUpdate,三者同一换算口径(CR-023)。 + // 高度生长版见 LongCapturePanelUpdate,三者同一换算口径。 double dispW = c->horizontal ? (double)rows : (double)c->physW; double dispH = c->horizontal ? (double)c->physW : (double)rows; double scale = (std::min)((double)availW / dispW, (double)availH / dispH); @@ -395,7 +395,7 @@ void LongCapturePanelUpdate(LongCaptureContext* c) { LongCaptureOutputRows(c, rowStart, rowEnd); int rows = rowEnd - rowStart; if (rows <= 0) return; - // 显示空间逻辑尺寸统一公式(CR-023):固定轴取 cropRect 逻辑尺寸(纵向=宽/横向=高, + // 显示空间逻辑尺寸统一公式:固定轴取 cropRect 逻辑尺寸(纵向=宽/横向=高, // 无 /ds 舍入往返误差),滚动轴取 rows / ds。与 LongCaptureOutputSizeLabel、 // LongCapturePanelPreviewRect 同一换算口径。 double dispWLogical = c->horizontal ? rows / ds : (double)(c->cropRect.right - c->cropRect.left); diff --git a/src/screenshot/lc_session.cpp b/src/screenshot/windows/lc_session_windows.cpp similarity index 95% rename from src/screenshot/lc_session.cpp rename to src/screenshot/windows/lc_session_windows.cpp index 06476fc..8bf3f30 100644 --- a/src/screenshot/lc_session.cpp +++ b/src/screenshot/windows/lc_session_windows.cpp @@ -1,5 +1,5 @@ // 长截图子系统:会话主循环 / 生命周期 / 全局状态。 -// CR-021 拆分自 long_capture_windows.cpp 的「主循环 + 生命周期」段。 +// 拆分自 long_capture_windows.cpp 的「主循环 + 生命周期」段。 // 本文件定义长截图会话级全局状态(g_longCtx / 控制窗口句柄 / 参数默认值), // 手动滚动捕获主循环 RunLongCapture,以及会话初始化 BeginLongCapture、 // JS 线程中止 LongCaptureAbort 与清理入口 DestroyLongCaptureContext。 @@ -23,16 +23,10 @@ HWND g_longToolbarWindow = NULL; // 选区底部工具栏(宽高/方向/自动 HWND g_longMaskWindow = NULL; // 长截图全屏灰色蒙版(半透明 + 点击穿透) // 长截图参数(初值即默认值;每次 start() 会先重置为默认再按 JS 覆盖,避免跨会话粘滞): -int g_lcMaxFrames = 100; // 默认最大拼接帧数(1~200,start() 的 longCapture.maxFrames 可配; - // 滚动中主动高频采样使帧数消耗更快,默认值随之调高) -int g_lcInterval = 250; // 默认滚轮防抖间隔 ms(50~2000,start() 的 longCapture.interval 可配) +int g_lcInterval = 250; // 默认滚轮停止后等待内容稳定的毫秒数(采样防抖) -// 拼接总像素上限:超过即自动完成,防止超大选区/超多帧下内存与编码雪崩(约 800MB BGRA) - -const long long LONG_CAPTURE_MAX_PIXELS = 200000000LL; - // 构造失败结果并经截图会话回调回传 JS(success=false)。 // 统一走 EmitScreenshotResult:TSFN 空 / nonblocking 失败均自动释放防泄漏。 @@ -49,7 +43,6 @@ void LongCaptureEmitFailure() { void BeginLongCapture(CaptureContext* ctx, HWND overlayHwnd) { LongCaptureContext* lc = new LongCaptureContext(); - lc->maxFrames = g_lcMaxFrames; lc->interval = g_lcInterval; lc->vx = ctx->virtualX; lc->vy = ctx->virtualY; lc->vw = ctx->virtualW; lc->vh = ctx->virtualH; @@ -251,9 +244,6 @@ bool RunLongCapture(LongCaptureContext* c) { c->frameCount.fetch_add(1); LongCapturePanelUpdate(c); LongCaptureToolbarRepaint(); // 宽×高标签刷新 - if (c->frameCount.load() >= c->maxFrames) break; // 帧数上限:自动完成 - if ((long long)c->physW * c->stitchH >= LONG_CAPTURE_MAX_PIXELS) - break; // 内存上限:自动完成 } else if (oc == LCSampleOutcome::Repositioned) { // 反向回滚未越出已捕获边界:视口基准已确认推进(小地图当前区域标注随 // committedContentTop 移动),无新增行——不计帧数、不重算面板尺寸; @@ -349,7 +339,7 @@ void LongCaptureAbort() { // 释放长截图上下文的采样 DIB 段(dibDC/dibBmp/dibBits)。 -// CR-021:原 wndproc_windows.cpp 的 WM_LONGCAPTURE_RUN 清理段手动释放 lc->dibDC/dibBmp +// 原 wndproc_windows.cpp 的 WM_LONGCAPTURE_RUN 清理段手动释放 lc->dibDC/dibBmp // 的跨界所有权收进此处统一释放——调用方(wndproc 清理段)改为调用本函数, // 释放语义、顺序、守卫(if (lc->dibDC) DeleteDC ...)与原实现逐字一致。 void DestroyLongCaptureContext(LongCaptureContext* lc) { diff --git a/src/screenshot/lc_toolbar_ui.cpp b/src/screenshot/windows/lc_toolbar_ui_windows.cpp similarity index 99% rename from src/screenshot/lc_toolbar_ui.cpp rename to src/screenshot/windows/lc_toolbar_ui_windows.cpp index 4b47da4..a79d1e5 100644 --- a/src/screenshot/lc_toolbar_ui.cpp +++ b/src/screenshot/windows/lc_toolbar_ui_windows.cpp @@ -1,10 +1,10 @@ // 长截图子系统:选区底部工具栏 UI(工具栏/popover/tooltip/图标缓存/自动滚动/裁剪菜单)。 -// CR-021 拆分自 long_capture_windows.cpp 的「长截图工具栏」段。 +// 拆分自 long_capture_windows.cpp 的「长截图工具栏」段。 // 工具栏是独立 TOPMOST 分层弹窗(WS_EX_LAYERED),消息由 RunLongCapture 泵循环分发, // 全部视觉内容由 LongCaptureToolbarRender 整幅渲染后 UpdateLayeredWindow 原子提交。 #include "internal.h" #include "long_capture_internal.h" -#include "../generated/icon_svgs.h" // 工具栏图标 SVG 文本(构建期由 scripts/gen-icons.js 生成) +#include "../../generated/icon_svgs.h" // 工具栏图标 SVG 文本(构建期由 scripts/gen-icons.js 生成) // ==================== 长截图工具栏(选区底部悬浮窗口) ==================== // 小地图面板只保留预览;全部操作集中到本工具栏,从左到右(图标按钮): @@ -159,7 +159,7 @@ static bool LongCaptureMenuRowEnabled(const LongCaptureContext* c, int row) { // 预览宽×高标签(逻辑像素,含裁剪窗口;横向模式宽高已回转) // 显示空间逻辑尺寸换算统一为:固定轴取 cropRect(逻辑源,无 /ds 舍入往返误差), // 滚动轴取 rows / ds(物理行数 ÷ scale)。与 LongCapturePanelUpdate/PanelPreviewRect -// 同一公式,消除原先三种写法并存(CR-023)。 +// 同一公式,消除原先三种写法并存。 static void LongCaptureOutputSizeLabel(const LongCaptureContext* c, wchar_t* buf, size_t n) { double ds = c ? c->dpiScale : 1.0; diff --git a/src/screenshot/mosaic_windows.cpp b/src/screenshot/windows/mosaic_windows.cpp similarity index 100% rename from src/screenshot/mosaic_windows.cpp rename to src/screenshot/windows/mosaic_windows.cpp diff --git a/src/screenshot/output_windows.cpp b/src/screenshot/windows/output_windows.cpp similarity index 99% rename from src/screenshot/output_windows.cpp rename to src/screenshot/windows/output_windows.cpp index 7b61e4c..d043c93 100644 --- a/src/screenshot/output_windows.cpp +++ b/src/screenshot/windows/output_windows.cpp @@ -37,7 +37,7 @@ static size_t GetStreamLength(IStream* stream) { return (size_t)st.cbSize.QuadPart; } -// 获取 PNG 编码器 CLSID(从 binding_windows.cpp 迁入截图目录,CR-019)。 +// 获取 PNG 编码器 CLSID(从 binding_windows.cpp 迁入截图目录)。 // 进程内 PNG 编码器 CLSID 不变,首次查找后用 static 缓存,后续直接返回缓存值, // 避免每次编码都分配并遍历编码器表。返回值 >= 0 表示成功(返回的是匹配到的编码器索引)。 // 仍返回 int 索引以保持原有调用契约(调用方判 >= 0 / == -1);CLSID 经 pClsid 带出。 @@ -460,7 +460,7 @@ static HBITMAP BuildRoundedArgbFinal(HDC memDC, const RECT& rect, int vx, int vy // 不透明位图路径(radius==0)的公共合成:从预截屏位图按选区提取区域,若有 DPI 缩放则 // 缩放回逻辑尺寸,再合成标注。ExtractRegionResult(base64+剪贴板)与 SaveRegionToPngFile -// (落盘)的 radius==0 分支此前为近逐行两份重复,CR-019 收口至此单一实现。 +// (落盘)的 radius==0 分支此前为近逐行两份重复,收口至此单一实现。 // 成功时经 outFinalDC/outFinalBmp 带出已合成的位图(调用方负责 DeleteDC/DeleteObject); // 失败时内部已清理全部 GDI 资源、out* 保持 NULL,返回 false。 // mosaicSizeIdx:马赛克块大小索引(显式传参,消除对全局 g_captureCtx 的穿透耦合)。 @@ -571,7 +571,7 @@ ScreenshotResult* ExtractRegionResult(HDC memDC, const RECT& rect, return result; } - // radius==0:原有不透明位图路径(公共合成,CR-019 收口至 ComposeSelectedBitmap) + // radius==0:原有不透明位图路径(公共合成,收口至 ComposeSelectedBitmap) HDC finalDC = NULL; HBITMAP finalBmp = NULL; if (ComposeSelectedBitmap(memDC, rect, vx, vy, dpiScale, anns, mosaicSizeIdx, finalDC, finalBmp)) { @@ -693,7 +693,7 @@ bool SaveRegionToPngFile(HDC memDC, const RECT& rect, int vx, int vy, return ok; } - // radius==0:原有不透明位图路径(公共合成,CR-019 收口至 ComposeSelectedBitmap) + // radius==0:原有不透明位图路径(公共合成,收口至 ComposeSelectedBitmap) HDC finalDC = NULL; HBITMAP finalBmp = NULL; bool ok = false; if (ComposeSelectedBitmap(memDC, rect, vx, vy, dpiScale, anns, mosaicSizeIdx, diff --git a/src/screenshot/overlay_input_windows.cpp b/src/screenshot/windows/overlay_input_windows.cpp similarity index 100% rename from src/screenshot/overlay_input_windows.cpp rename to src/screenshot/windows/overlay_input_windows.cpp diff --git a/src/screenshot/overlay_paint_windows.cpp b/src/screenshot/windows/overlay_paint_windows.cpp similarity index 100% rename from src/screenshot/overlay_paint_windows.cpp rename to src/screenshot/windows/overlay_paint_windows.cpp diff --git a/src/screenshot/overlay_ui_windows.cpp b/src/screenshot/windows/overlay_ui_windows.cpp similarity index 100% rename from src/screenshot/overlay_ui_windows.cpp rename to src/screenshot/windows/overlay_ui_windows.cpp diff --git a/src/screenshot/windows/sc_annotations.h b/src/screenshot/windows/sc_annotations.h new file mode 100644 index 0000000..471e499 --- /dev/null +++ b/src/screenshot/windows/sc_annotations.h @@ -0,0 +1,119 @@ +// 截图模块标注类型与绘制参数预设(Windows):统一标注结构(矩形/圆/箭头/画笔/文字/ +// 马赛克)、粗细/字号/马赛克/颜色预设及其档数约束、撤销栈深度上限。 +// 由 internal.h 二次拆分而来(纯移动不改逻辑);可独立包含,亦经 internal.h 伞头获得。 +#pragma once + +#include +#include +#include + +// ==================== 标注绘制(矩形/圆/箭头/画笔) ==================== + +// 所有标注统一用「绝对虚拟屏幕坐标」存储(与 ctx->mouseX/selection 同坐标系): +// - 用绝对坐标而非选区相对,保证选区缩放/移动时标注位置固定不动 +// - 实时渲染时:backDC 局部坐标 = 绝对坐标 + ox/oy,ox/oy = -virtualX/-virtualY +// - 合成进 PNG 时:finalDC 局部坐标 = 绝对坐标 + ox/oy,ox/oy = -rect.left/-rect.top + +enum AnnotationType { + AT_Rect, + AT_Circle, + AT_Arrow, + AT_Brush, + AT_Text, + AT_Mosaic // 马赛克(框选区域 或 鼠标涂抹) +}; + +struct Annotation { + AnnotationType type; + COLORREF color; + int thickness; // 逻辑像素(矢量=线宽;文字=字号) + // 绝对虚拟屏幕坐标(与 ctx->mouseX/selection 同坐标系)。 + // 用绝对坐标而非选区相对,保证选区缩放/移动时标注位置固定不动。 + int x1, y1, x2, y2; // Rect / Circle / Arrow 的起止(绝对坐标);AT_Text 的 x1/y1 为文字锚点; + // AT_Mosaic 框选模式的矩形起止(绝对坐标) + std::vector pts; // Brush 自由路径(绝对坐标);AT_Mosaic 涂抹模式的路径(绝对坐标) + std::wstring text; // AT_Text 的文字内容 + // ---- AT_Mosaic 专用 ---- + bool mosaicRect; // true=框选区域马赛克;false=鼠标涂抹马赛克 + int mosaicSize; // 马赛克块大小(逻辑像素) + int brushRadius; // 涂抹半径(逻辑像素,仅涂抹模式有效) + + // ---- 文字测量缓存(仅 AT_Text 有效)---- + // 缓存"相对锚点的字形偏移与尺寸"(与 GDI+ MeasureString 同源)。 + // 有效性条件 = (text, fontPx) 未变;锚点(x1,y1)变化不影响缓存值(外部加偏移即可), + // 故 TransformAnnotationByBox 的 AT_Text 分支(仅平移锚点)无需失效缓存。 + // textCacheValid=false 表示未计算或已失效,下次 MeasureTextAnnotation 会重算并回填。 + bool textCacheValid; + int textCacheFontPx; // 生成缓存时的 fontPx(= thickness),用于校验 + float textCacheOffX, textCacheOffY; // 字形左上角相对锚点的偏移 + float textCacheW, textCacheH; // 字形紧凑宽高 +}; + +// 粗细预设(逻辑像素,实际绘制粗细,渲染时乘 dpiScale) +// inline constexpr 数组:跨 TU 唯一实例(替代 static const 每 TU 一份的拷贝),值与原一致。 + +inline constexpr int SC_THICK_PRESETS[] = { 1, 2, 4 }; + +inline constexpr int SC_THICK_COUNT = sizeof(SC_THICK_PRESETS) / sizeof(SC_THICK_PRESETS[0]); + +static const int SC_DEFAULT_THICK_IDX = 1; // 默认中粗 + +// 子菜单圆点预览直径(逻辑像素,仅用于界面显示,与实际绘制粗细解耦) + +inline constexpr int SC_THICK_DOT_SIZES[] = { 5, 10, 16 }; + +inline constexpr int SC_THICK_DOT_COUNT = sizeof(SC_THICK_DOT_SIZES) / sizeof(SC_THICK_DOT_SIZES[0]); + +// 文字字号预设(逻辑像素),文字工具激活时子菜单第一组显示 + +inline constexpr int SC_FONT_SIZES[] = { 16, 24, 36 }; + +inline constexpr int SC_FONT_COUNT = sizeof(SC_FONT_SIZES) / sizeof(SC_FONT_SIZES[0]); + +// 粗细档数与字号档数必须一致:子菜单第一组共用单元格,绘制/命中按 isTextTool 在两套预设 +// 间二选一(overlay_ui/overlay_input 均以 SC_THICK_COUNT/SC_FONT_COUNT 为循环上界)。 +static_assert(SC_THICK_COUNT == SC_FONT_COUNT, + "SC_THICK_COUNT must match SC_FONT_COUNT: submenu 第一组共享单元格"); + +static const int SC_DEFAULT_FONT_IDX = 1; // 默认中号 + +static const wchar_t* SC_FONT_FACE = L"微软雅黑"; + +// 马赛克块大小预设(逻辑像素),马赛克工具子菜单显示 + +inline constexpr int SC_MOSAIC_SIZES[] = { 6, 10, 16 }; + +inline constexpr int SC_MOSAIC_COUNT = sizeof(SC_MOSAIC_SIZES) / sizeof(SC_MOSAIC_SIZES[0]); + +static const int SC_DEFAULT_MOSAIC_IDX = 1; // 默认中等块 + +// 这里单独定义便于扩展。半径越大涂抹范围越宽。 + +// 涂抹半径预设(逻辑像素),马赛克涂抹模式使用,与画笔粗细预设共用同一组子菜单第二组无效, + +inline constexpr int SC_MOSAIC_RADIUS[] = { 12, 22, 36 }; + +inline constexpr int SC_MOSAIC_RADIUS_COUNT = sizeof(SC_MOSAIC_RADIUS) / sizeof(SC_MOSAIC_RADIUS[0]); + +static const int SC_DEFAULT_MOSAIC_RADIUS_IDX = 1; // 默认中等半径 + +// 颜色预设 + +inline constexpr COLORREF SC_COLOR_PRESETS[] = { + RGB(0xE5, 0x39, 0x35), // 红 + RGB(0xFB, 0x8C, 0x00), // 橙 + RGB(0xFD, 0xD8, 0x35), // 黄 + RGB(0x43, 0xA0, 0x47), // 绿 + RGB(0x00, 0xAC, 0xC1), // 青 + RGB(0x1E, 0x88, 0xE5), // 蓝 + RGB(0xFF, 0xFF, 0xFF), // 白 + RGB(0x33, 0x33, 0x33), // 黑 +}; + +inline constexpr int SC_COLOR_COUNT = sizeof(SC_COLOR_PRESETS) / sizeof(SC_COLOR_PRESETS[0]); + +static const int SC_DEFAULT_COLOR_IDX = 0; // 默认红 +// 撤销栈最大深度(快照份数)。撤销历史是整份标注的深拷贝,不限深会随操作数平方级累积内存 +// (每笔操作全量复制一次),超出后由 PushAnnotationHistory 裁掉最老快照。 + +static const int SC_UNDO_MAX_DEPTH = 50; diff --git a/src/screenshot/windows/sc_theme.h b/src/screenshot/windows/sc_theme.h new file mode 100644 index 0000000..328732b --- /dev/null +++ b/src/screenshot/windows/sc_theme.h @@ -0,0 +1,31 @@ +// 截图模块主题色常量(Windows):多个绘制文件共用的固定色值与 COLORREF → +// Gdiplus::Color 的不透明展开。由 internal.h 二次拆分而来(纯移动不改逻辑); +// 可独立包含,亦经 internal.h 伞头获得。 +#pragma once + +#include + +// GDI+ 需要 min/max(与 internal.h 伞头的同款注入;重复 using 声明合法) +namespace Gdiplus { + using std::min; + using std::max; +} +#include + +// ==================== 主题色常量 ==================== +// 多个绘制文件共用的固定色值集中于此(此前以 RGB 字面量散落 overlay_ui / icons / +// long_capture 各处,易各自漂移);各值与抽常量前逐字节一致。 +// GDI 侧直接传 COLORREF;GDI+ 侧经下方 ScOpaqueColor 展开通道(透明度各异的 +// 使用点自行以 GetR/G/BValue 构造)。 + +static const COLORREF SC_THEME_ACCENT_BLUE = RGB(0x00, 0x88, 0xFF); // 强调蓝:选区/标注边框、resize 手柄、放大镜准星 +static const COLORREF SC_THEME_TOOLBAR_BLUE = RGB(0x3B, 0x8B, 0xF2); // 工具栏选中态图标蓝 #3B8BF2(配浅蓝高亮底) +static const COLORREF SC_THEME_ICON_DARK = RGB(0x33, 0x33, 0x33); // 子菜单图标默认深灰 #333333(与预设「黑」同值) +static const COLORREF SC_THEME_SEL_BG = RGB(225, 237, 253); // 选中态浅蓝高亮底(工具栏主题蓝叠白底 ~15% 预混合色) +static const COLORREF SC_THEME_HOVER_BG = RGB(235, 243, 255); // hover 态极浅蓝底 + +// 不透明封装:COLORREF 主题色 -> alpha=255 的 Gdiplus::Color(通道按位展开,值不变)。 + +static inline Gdiplus::Color ScOpaqueColor(COLORREF c) { + return Gdiplus::Color(255, GetRValue(c), GetGValue(c), GetBValue(c)); +} diff --git a/src/screenshot/windows/sc_types.h b/src/screenshot/windows/sc_types.h new file mode 100644 index 0000000..848fe3b --- /dev/null +++ b/src/screenshot/windows/sc_types.h @@ -0,0 +1,284 @@ +// 截图模块基础类型与常量(Windows):会话状态枚举、工具按钮、预截首帧缓冲、 +// 工具栏/子菜单/面板/手柄几何(常量 + DPI 缩放结构)、GDI 资源缓存、窗口信息、 +// 截图结果结构、显示器枚举数据等跨文件共享类型。 +// 由 internal.h 二次拆分而来(纯移动不改逻辑);可独立包含,亦经 internal.h 伞头获得。 +#pragma once + +#include +#include +#include + +static const auto SC_PRIMED_FRAME_TTL = std::chrono::seconds(2); + +struct PrimedScreenshotFrame { + HBITMAP bitmap = NULL; + int vx = 0; + int vy = 0; + int vw = 0; + int vh = 0; + double dpiScale = 1.0; // 单一 scale 模型的已知限制见 CaptureContext.dpiScale 注释 + std::chrono::steady_clock::time_point capturedAt{}; + bool valid = false; +}; +// 截图常量 + +// ==================== 区域截图功能(预截屏 + 双缓冲架构) ==================== + +static const int SC_PANEL_WIDTH = 140; + +static const int SC_PANEL_HEIGHT = 140; + +static const int SC_MAGNIFIER_HEIGHT = 74; + +static const int SC_PANEL_MARGIN = 15; + +static const int SC_PANEL_CORNER_RADIUS = 8; + +static const int SC_ZOOM_FACTOR = 4; + +// 取值 0~255,数值越大越暗(0 = 无遮罩,255 = 全黑) + +// 选区外遮罩:微信风格,选区内部保持清晰,外部覆盖半透明黑色 + +static const BYTE SC_MASK_ALPHA = 120; + +// 截图状态枚举 + +enum CaptureState { + CS_Idle, // 等待选择(hover 窗口/拖拽开始) + CS_Selecting, // 正在拖拽框选 + CS_Confirmed, // 已确认选区,可调整/拖动/打开工具栏 + CS_Resizing, // 正在拖拽手柄调整选区 + CS_Moving, // 正在整体拖动选区 + CS_Drawing, // 正在绘制标注(矩形/圆/箭头/画笔) + CS_TextEditing, // 正在输入文字 + CS_LongCapturing, // 长截图滚动捕获进行中(独立于编辑态,由 RunLongCapture 驱动) + CS_Done, + CS_Cancelled +}; + +// 选区调整手柄(8 个方向) + +enum ResizeHandle { + RH_None = -1, + RH_Left = 0, + RH_Right = 1, + RH_Top = 2, + RH_Bottom = 3, + RH_TopLeft = 4, + RH_TopRight = 5, + RH_BottomLeft = 6, + RH_BottomRight = 7, + RH_ArrowStart = 8, // 箭头起点端点手柄(仅箭头用,拖动改起点) + RH_ArrowEnd = 9, // 箭头终点端点手柄(仅箭头用,拖动改终点) + RH_CornerRadiusTL = 10, // 选区左上角内倒角手柄(拖动改选区圆角半径,不改变选区矩形) + RH_CornerRadiusTR = 11, // 选区右上角内倒角手柄 + RH_CornerRadiusBL = 12, // 选区左下角内倒角手柄 + RH_CornerRadiusBR = 13 // 选区右下角内倒角手柄 +}; + +// 工具栏最左「6 点拖拽把手」单元格的命中返回值:取负值与按钮索引区分, +// 既有调用方以 >=0 判定按钮,天然排除把手(把手按住 = 拖动工具栏,非工具按钮)。 +static const int SC_TB_GRIP = -2; + +// 工具栏按钮 + +enum ToolButton { + TB_Drag = 0, // 拖拽 + TB_Rect, // 矩形 + TB_Circle, // 圆形(含椭圆) + TB_Arrow, // 箭头 + TB_Brush, // 画笔 + TB_Mosaic, // 马赛克 + TB_Text, // 文字 + TB_Translate, // 翻译 + TB_LongCapture, // 长截图(滚动捕获入口,复用当前选区) + TB_Separator1, // 分隔线 + TB_Undo, // 撤销 + TB_Redo, // 重做 + TB_Separator2, // 分隔线 + TB_Save, // 保存到本地 + TB_Cancel, // 取消 + TB_Confirm, // 确定 + TB_Count +}; +// 手柄/工具栏几何常量 + +static const int SC_HANDLE_SIZE = 10; // 调整手柄边长(100% DPI 基准,运行时按 dpiScale 缩放) + +static const int SC_TOOLBAR_BTN = 32; // 按钮尺寸(正方形) + +static const int SC_TOOLBAR_PAD = 6; // 按钮↔工具栏边缘内边距(四边一致) + +static const int SC_TOOLBAR_H = SC_TOOLBAR_BTN + SC_TOOLBAR_PAD * 2; // 工具栏高度 = 按钮 + 上下内边距 + +static const int SC_TOOLBAR_GAP = 1; // 按钮间距 + +static const int SC_TOOLBAR_RADIUS = 8; // 工具栏圆角 + +static const int SC_TOOLBAR_MARGIN = 6; // 选区到工具栏间距 + +static const int SC_TOOLBAR_BORDER = 1; // 工具栏边框 + +static const int SC_MIN_SELECTION = 10; // 最小选区尺寸 + +static const int SC_CORNER_KNOB_INSET = 18; // 圆角拖拽手柄距选区角的内缩距离(100% DPI 基准,运行时按 dpiScale 缩放) + +static const int SC_CORNER_PROXIMITY = 14; // 倒角手柄"靠近"感应余量:在命中框外再扩此距离即显示该角手柄(100% DPI 基准) + +// 手柄几何(DPI 缩放后)。选区/标注 resize 手柄与圆角手柄共用同一套尺寸, +// 保证 1080p → 4K 下手柄与工具栏/图标同步放大,避免高 DPI 下手柄过小。 +struct SCHandleMetrics { + int handleSize; // 手柄边长(绘制 + 命中框半宽基准) + int cornerKnobInset; // 圆角手柄距选区角的内缩距离 + int handleMargin; // 脏区扩张余量 = handleSize/2 + 4(覆盖手柄半径 + 描边/抗锯齿) + int cornerProximity; // 倒角手柄靠近感应余量(命中框外扩展距离,鼠标进入即显示该角手柄) +}; + +// 子菜单几何常量(100% DPI 基准值,运行时按 dpiScale 缩放) +// 单行布局:[粗细圆点×3] | [分隔线] | [颜色圆点×8],无文案。 +// 单元格(点击区 + 选中背景区)大小与工具栏按钮一致,便于视觉对齐。 +static const int SC_POPUP_CELL = SC_TOOLBAR_BTN; // 单元格尺寸(= 工具栏按钮大小) + +static const int SC_POPUP_PAD = 4; // 内边距 + +static const int SC_POPUP_RADIUS = 8; // 圆角 + +static const int SC_POPUP_COLOR_DOT = 18; // 颜色圆点直径(图标本身) + +static const int SC_POPUP_SEP_GAP = 6; // 分隔线两侧间距 + +static const int SC_POPUP_SEP_H = 20; // 分隔线高度 + +static const int SC_POPUP_BORDER = 1; // 边框 + +static const int SC_POPUP_MARGIN = 4; // 工具栏与子菜单间距 + +// 子菜单几何(DPI 缩放后) + +struct SCPopupMetrics { + int pad; + int radius; + int cell; // 单元格尺寸(点击区 + 选中背景区,= 工具栏按钮大小) + int colorDot; // 颜色圆点直径(图标本身) + int sepGap; // 分隔线两侧间距 + int sepH; // 分隔线高度 + int border; + int margin; +}; + +// ---- 信息面板 DPI 缩放几何 ---- + +struct SCPanelMetrics { + int w; + int h; + int magnifierH; + int margin; + int radius; + int fontPx; + int crosshair; + int borderPad; + int labelPad; + int sizeLabelPadX; + int sizeLabelPadY; + int sizeLabelGap; +}; + +// 窗口信息 + +struct SCWindowInfo { + HWND hwnd; + RECT rect; + std::wstring title; +}; + +// 截图结果结构 + +struct ScreenshotResult { + bool success; + int x; + int y; + int x2; + int y2; + int width; + int height; + std::string base64; +}; + +// GDI 资源缓存 + +struct SCGdiResources { + HBRUSH bgBrush = NULL; + HPEN borderPen = NULL; + HPEN crosshairPen = NULL; + HPEN selectionPen = NULL; + HPEN highlightPen = NULL; + HFONT smallFont = NULL; + int smallFontPx = 0; + int crosshairWidth = 0; + // 选区外遮罩缓冲(虚拟屏幕大小,纯黑 + 常量 alpha),用于 AlphaBlend + HDC maskDC = NULL; + HBITMAP maskBitmap = NULL; + // ---- P2 性能优化:固定样式 Pen/Brush 会话级缓存,避免每帧 Create/Delete ---- + // 工具栏分隔线笔(DrawToolbar)。 + HPEN toolbarSepPen = NULL; // PS_SOLID, 1, RGB(230,230,230) + // 文字选择高亮画刷(AlphaBlend 半透明选区底色)。 + HBRUSH textSelBrush = NULL; // RGB(51,153,255) + // 悬停/选中标注边框:蓝色虚线笔(悬停文字/非文字标注 + 选中非文字标注共用)。 + HPEN annHoverPen = NULL; // PS_DASH, 1, RGB(0,136,255) + // 选中文字标注边框:蓝色实线粗笔(与 selectionPen 的宽度 1 区别)。 + HPEN annTextSelPen = NULL; // PS_SOLID, 2, RGB(0,136,255) + + // 创建/释放方法体下沉到 session_windows.cpp(唯一使用方在会话层, + // 从头文件内联定义改为外部定义,调用方签名/语义不变)。 + void Init(int fontPx = 12, int crosshairPx = 1); + // 创建遮罩缓冲(纯黑位图,配合常量 alpha 实现 40%+ 半透明遮罩) + // 须在 CaptureContext 虚拟屏幕尺寸确定后调用 + void InitMask(int virtualW, int virtualH); + void Cleanup(); +}; + +// ---- 工具栏 DPI 缩放几何 ---- +// 基础逻辑尺寸(100% DPI)按 dpiScale 放大,保证 1080p → 4K 下工具栏尺寸与图标同步。 +// 基础值与原 SC_TOOLBAR_* 常量保持一致,便于回归。 +struct SCToolbarMetrics { + int btn; // 按钮边长 + int h; // 工具栏高度 + int gap; // 按钮间距 + int pad; // 按钮↔工具栏边缘内边距(四边一致) + int radius; // 圆角半径 + int margin; // 选区到工具栏间距 + int border; // 工具栏边框宽度 + int iconSize; // 图标光栅化尺寸(物理像素) +}; + +// 工具栏图标位图缓存:按当前 DPI 渲染一次,dark/white 两色版本。 +// dark = normal/hover 图标色,white = active(蓝底)图标色。 +struct SCIconCache { + bool inited; + int iconSize; + HBITMAP dark[TB_Count]; // 普通态:深灰图标 + HBITMAP active[TB_Count]; // 选中态:主题蓝图标(搭配浅蓝高亮底) + + SCIconCache() : inited(false), iconSize(0) { + for (int i = 0; i < TB_Count; i++) { dark[i] = NULL; active[i] = NULL; } + } + + // Init/Cleanup 依赖 nanosvg 光栅化(kIconSvgs/RenderSvgToBitmap),实现在 icons_windows.cpp + void Init(int physicalIconSize); + void Cleanup(); + + // 取按钮位图:isActive 时用主题蓝版本,其余用深灰 + HBITMAP Get(int btn, bool isActive) const { + if (btn < 0 || btn >= TB_Count) return NULL; + return isActive ? active[btn] : dark[btn]; + } +}; +// 显示器枚举回调数据 + +struct MonitorEnumData { + LONG minLeft, minTop, maxRight, maxBottom; + double totalDpiScale; + int monitorCount; + HMODULE shcore; // 外层一次 LoadLibraryW("shcore.dll") 的句柄,回调内复用 GetDpiForMonitor(避免每显示器重复 Load) +}; diff --git a/src/screenshot/screenshot_windows.h b/src/screenshot/windows/screenshot_windows.h similarity index 100% rename from src/screenshot/screenshot_windows.h rename to src/screenshot/windows/screenshot_windows.h diff --git a/src/screenshot/session_windows.cpp b/src/screenshot/windows/session_windows.cpp similarity index 96% rename from src/screenshot/session_windows.cpp rename to src/screenshot/windows/session_windows.cpp index 8ac1b6e..1dee2b4 100644 --- a/src/screenshot/session_windows.cpp +++ b/src/screenshot/windows/session_windows.cpp @@ -28,7 +28,7 @@ std::mutex g_longCtxMutex; CaptureContext* g_captureCtx = nullptr; -// ==================== SCGdiResources 实现(CR-022 自 internal.h 下沉)==================== +// ==================== SCGdiResources 实现(自 internal.h 下沉)==================== // 原为 internal.h 内联定义,唯一调用方在本文件(会话初始化/失败清理/退出清理), // 下沉为外部定义以减少头文件内联实现体;签名/语义完全不变。 @@ -227,7 +227,7 @@ static void CallScreenshotJs(napi_env env, napi_value js_callback, void* context } } -// 统一 ScreenshotResult 发射口(CR-017):构造结果并经截图会话 TSFN 回传 JS。 +// 统一 ScreenshotResult 发射口:构造结果并经截图会话 TSFN 回传 JS。 // 统一守卫:TSFN 未就绪或 napi_tsfn_nonblocking 因队列满返回非 napi_ok 时,自行 // delete 分配的 result 防泄漏(CallScreenshotJs 只在成功入队时才 delete)。 // 仅允许在截图线程内调用。各发射点(确认/取消/保存/ESC/长截图完成等)统一走此函数。 @@ -251,7 +251,7 @@ void EmitScreenshotResult(bool success, int x, int y, int x2, int y2, } // 会话初始化失败快速回传:构造 {success:false} 结果并经 EmitScreenshotResult 回传 JS, -// 唤醒 await 方避免永久挂起(CR-002 早退路径统一收口点)。 +// 唤醒 await 方避免永久挂起(早退路径统一收口点)。 void FailFast() { EmitScreenshotResult(false); } @@ -534,7 +534,7 @@ void ScreenshotCaptureThread() { ShutdownGdipResources(&ctx); UnregisterClassW(L"ZToolsScreenshotOverlay", GetModuleHandle(NULL)); - // 会话出口统一收口(CR-002):正常出口均已在确认/取消分支各回调一次 + // 会话出口统一收口:正常出口均已在确认/取消分支各回调一次 // (ESC 取消、右键取消、确认/保存完成、长截图 RunLongCapture 各自的结果发射)。 // 这里兜底:若循环未经任何确认/取消分支退出(如外部投递 WM_QUIT),补发一次 // 失败结果,保证 JS 端 await 必然被唤醒;随后释放 TSFN,防止每次 start() 的 @@ -575,12 +575,11 @@ Napi::Value StartRegionCaptureWithPrimedFrame(const Napi::CallbackInfo& info) { // 默认 autoConfirm=true,选区确定后直接出图,跳过编辑态; // 传 false 才进入编辑态(工具栏/标注) // longCapture 为编辑态工具栏「长截图」按钮的手动滚动捕获参数: - // { maxFrames?: number, interval?: number } + // { interval?: number } // 每次会话显式解析长截图参数到默认值后再按 JS 覆盖,不再依赖跨会话残留的粘滞全局 - // (旧实现用文件级全局 g_lcMaxFrames/g_lcInterval 承载,第二次 start 不传 longCapture + // (旧实现用文件级全局 g_lcInterval 承载,第二次 start 不传 longCapture // 时会沿用上一次的值,属跨会话状态泄漏)。 bool autoConfirm = true; - int lcMaxFrames = 100; // 默认最大拼接帧数(与 LongCaptureContext.maxFrames 一致) int lcInterval = 250; // 默认滚轮防抖间隔 ms for (int i = 0; i < (int)info.Length(); i++) { if (info[i].IsFunction()) { @@ -610,13 +609,6 @@ Napi::Value StartRegionCaptureWithPrimedFrame(const Napi::CallbackInfo& info) { Napi::Value v = opts.Get("longCapture"); if (v.IsObject()) { Napi::Object lc = v.As(); - if (lc.Has("maxFrames")) { - Napi::Value f = lc.Get("maxFrames"); - if (f.IsNumber()) { - int mf = f.As().Int32Value(); - if (mf >= 1 && mf <= 200) lcMaxFrames = mf; - } - } if (lc.Has("interval")) { Napi::Value t = lc.Get("interval"); if (t.IsNumber()) { @@ -630,7 +622,6 @@ Napi::Value StartRegionCaptureWithPrimedFrame(const Napi::CallbackInfo& info) { } g_autoConfirm = autoConfirm; // 每次会话重置为本次解析值(默认值或 JS 覆盖),消除跨会话粘滞 - g_lcMaxFrames = lcMaxFrames; g_lcInterval = lcInterval; g_isCapturing = true; diff --git a/src/screenshot/wndproc_windows.cpp b/src/screenshot/windows/wndproc_windows.cpp similarity index 98% rename from src/screenshot/wndproc_windows.cpp rename to src/screenshot/windows/wndproc_windows.cpp index 7003aba..a92a3f3 100644 --- a/src/screenshot/wndproc_windows.cpp +++ b/src/screenshot/windows/wndproc_windows.cpp @@ -207,8 +207,7 @@ LRESULT CALLBACK ScreenshotOverlayWndProc(HWND hwnd, UINT msg, WPARAM wParam, LP LongCaptureContext* lc = g_longCtx.load(); if (lc) { RunLongCapture(lc); - // 清理:释放采样 DIB 段(dibDC/dibBmp 跨界所有权收进 DestroyLongCaptureContext, - // CR-021)、销毁灰蒙版/小地图面板、释放上下文、结束截图会话 + // 清理:释放采样 DIB 段(dibDC/dibBmp 跨界所有权收进 DestroyLongCaptureContext)、销毁灰蒙版/小地图面板、释放上下文、结束截图会话 DestroyLongCaptureContext(lc); if (g_longMaskWindow) { DestroyWindow(g_longMaskWindow); From cc9daf4e616ea60277ddec87100602c015c82d37 Mon Sep 17 00:00:00 2001 From: patrick <980141374@qq.com> Date: Sat, 29 Aug 2026 18:08:48 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=E9=95=BF=E6=88=AA=E5=9B=BE=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E6=A0=8F/=E9=9D=A2=E6=9D=BF=E9=AB=98=E5=88=86?= =?UTF-8?q?=E5=B1=8F=20DPI=20=E9=80=82=E9=85=8D=EF=BC=8Cchrome=20=E5=B0=BA?= =?UTF-8?q?=E5=AF=B8=E6=94=B9=E7=94=A8=20uiScale?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PMv2 捕获线程下 GetSystemMetrics 已返回物理像素,dpiScale = physVw/vw 恒为 1,工具栏/面板界面尺寸乘它导致高分屏下保持 96 DPI 尺寸(过小)。 新增 LongCaptureContext.uiScale(GetDpiForSystem/96,与编辑态 CalcToolbarMetrics 同源):工具栏窗口/布局/图标/tooltip 与面板窗口/ 内边距/圆角等界面 chrome 一律改用 uiScale;内容几何换算(rows/ds 标签、物理采样偏移、预览显示尺寸)保持 dpiScale 不变。 macOS 端 UI 以 point 布局、系统按 backing scale 自动渲染,无此问题。 --- src/screenshot/algo/long_capture_internal.h | 5 ++++ .../windows/lc_panel_ui_windows.cpp | 19 +++++++----- src/screenshot/windows/lc_session_windows.cpp | 6 ++-- .../windows/lc_toolbar_ui_windows.cpp | 30 ++++++++++--------- 4 files changed, 36 insertions(+), 24 deletions(-) diff --git a/src/screenshot/algo/long_capture_internal.h b/src/screenshot/algo/long_capture_internal.h index eac0da9..b18cdc1 100644 --- a/src/screenshot/algo/long_capture_internal.h +++ b/src/screenshot/algo/long_capture_internal.h @@ -179,6 +179,11 @@ struct LongCaptureContext { // 虚拟屏幕(逻辑坐标)与 DPI int vx = 0, vy = 0, vw = 0, vh = 0; double dpiScale = 1.0; // 单一 scale 模型的已知限制见 CaptureContext.dpiScale 注释 + // UI 界面(chrome)缩放 = GetDpiForSystem/96,与编辑态工具栏/面板(CalcToolbarMetrics(uiScale)) + // 同源。内容几何换算(rows/ds、cropRect、物理采样偏移)一律用 dpiScale;PMv2 捕获线程下 + // GetSystemMetrics 已返回物理像素,dpiScale = physVw/vw 恒为 1,若 chrome 也乘它, + // 工具栏/面板在任意缩放比下都保持 96 DPI 尺寸(高分屏下过小),故界面尺寸一律用本值。 + double uiScale = 1.0; // 选区(逻辑虚拟屏幕坐标,已规范化) RECT selection = {}; diff --git a/src/screenshot/windows/lc_panel_ui_windows.cpp b/src/screenshot/windows/lc_panel_ui_windows.cpp index bdef65d..c3f3895 100644 --- a/src/screenshot/windows/lc_panel_ui_windows.cpp +++ b/src/screenshot/windows/lc_panel_ui_windows.cpp @@ -11,7 +11,8 @@ const int LONG_MASK_GRAY = 44; const BYTE LONG_MASK_ALPHA = 0xA0; -// 预览面板布局常量(逻辑像素,绘制时按 dpiScale 缩放)。 +// 预览面板布局常量(逻辑像素,窗口尺寸/内边距等界面 chrome 按 uiScale 缩放, +// 内容显示尺寸换算用 dpiScale,区分见 LongCaptureContext.uiScale 注释)。 // 面板只保留小地图本体(帧提示与完成/取消按钮已移至选区底部的长截图工具栏窗口)。 const int LC_PANEL_W = 232; // 面板总宽 @@ -31,7 +32,7 @@ static const int LC_PANEL_MIN_H = 48; // 预览最小高度(逻辑像素,防 static RECT LongCapturePanelPreviewRect(LongCaptureContext* c, int cw, int ch) { RECT r = {0, 0, 0, 0}; if (!c || c->stitchH <= 0 || c->physW <= 0) return r; - double ds = c->dpiScale; + double ds = c->uiScale; // 预览区内边距为界面 chrome 尺寸 int pad = (int)(LC_PANEL_PAD * ds + 0.5); int availW = cw - pad * 2; int availH = ch - pad * 2; @@ -162,7 +163,7 @@ void LongCapturePanelRender(HWND panel, LongCaptureContext* c) { if (!EnsureArgbSurface(s_lcPnSurfDC, s_lcPnSurfBmp, s_lcPnSurfBits, s_lcPnSurfW, s_lcPnSurfH, cw, ch)) return; - double ds = c->dpiScale; + double ds = c->uiScale; // 面板圆角半径为界面 chrome 尺寸 float radius = (float)(8 * ds + 0.5); { Gdiplus::Bitmap surf(cw, ch, cw * 4, PixelFormat32bppPARGB, (BYTE*)s_lcPnSurfBits); @@ -383,7 +384,7 @@ LRESULT CALLBACK LongCapturePanelWndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM void LongCapturePanelUpdate(LongCaptureContext* c) { HWND panel = g_longControlWindow; if (!panel || c->stitchH <= 0 || c->physW <= 0) return; - double ds = c->dpiScale; + double ds = c->uiScale; // 面板 pad/避让边距为界面 chrome 尺寸 auto sc = [ds](int v) { return (int)(v * ds + 0.5); }; RECT wr; GetWindowRect(panel, &wr); @@ -396,11 +397,13 @@ void LongCapturePanelUpdate(LongCaptureContext* c) { int rows = rowEnd - rowStart; if (rows <= 0) return; // 显示空间逻辑尺寸统一公式:固定轴取 cropRect 逻辑尺寸(纵向=宽/横向=高, - // 无 /ds 舍入往返误差),滚动轴取 rows / ds。与 LongCaptureOutputSizeLabel、 + // 无 /ds 舍入往返误差),滚动轴取 rows / dpiScale(内容显示尺寸换算用几何缩放, + // 不随上面的 chrome uiScale 变)。与 LongCaptureOutputSizeLabel、 // LongCapturePanelPreviewRect 同一换算口径。 - double dispWLogical = c->horizontal ? rows / ds : (double)(c->cropRect.right - c->cropRect.left); + double dispWLogical = c->horizontal ? rows / c->dpiScale + : (double)(c->cropRect.right - c->cropRect.left); double dispHLogical = c->horizontal ? (double)(c->cropRect.bottom - c->cropRect.top) - : rows / ds; + : rows / c->dpiScale; if (dispWLogical < 1.0) dispWLogical = 1.0; int prevH = (int)(dispHLogical * ((double)availW / dispWLogical) + 0.5); // 屏幕下沿约束:预览不超过面板顶部以下剩余空间与屏高 45% @@ -446,7 +449,7 @@ HWND LongCaptureCreatePanel(CaptureContext* ctx, LongCaptureContext* c) { RegisterClassExW(&wc); registered = true; } - double ds = ctx->dpiScale; + double ds = c->uiScale; // 面板窗口尺寸/边距为界面 chrome 尺寸 auto sc = [ds](int v) { return (int)(v * ds + 0.5); }; int winW = sc(LC_PANEL_W); int pad = sc(LC_PANEL_PAD); diff --git a/src/screenshot/windows/lc_session_windows.cpp b/src/screenshot/windows/lc_session_windows.cpp index 8bf3f30..55fd814 100644 --- a/src/screenshot/windows/lc_session_windows.cpp +++ b/src/screenshot/windows/lc_session_windows.cpp @@ -47,6 +47,7 @@ void BeginLongCapture(CaptureContext* ctx, HWND overlayHwnd) { lc->vx = ctx->virtualX; lc->vy = ctx->virtualY; lc->vw = ctx->virtualW; lc->vh = ctx->virtualH; lc->dpiScale = ctx->dpiScale; + lc->uiScale = GetDpiScaleFactor(); lc->selection = ctx->selection; // 采样裁剪 = 选区每边内缩(公式收口见 CalcSampleCrop 注释),换算物理像素(与 ExtractRegionResult 同式) @@ -74,8 +75,9 @@ void BeginLongCapture(CaptureContext* ctx, HWND overlayHwnd) { lc->physOriginX = (int)(lc->vx * ds); lc->physOriginY = (int)(lc->vy * ds); } - // 面板两级缩略图固定列宽 = 面板预览内宽(物理像素),不超过选区宽度 - int previewPx = (int)((LC_PANEL_W - 2 * LC_PANEL_PAD) * ds + 0.5); + // 面板两级缩略图固定列宽 = 面板预览内宽(物理像素,按 uiScale;面板窗口尺寸同源), + // 不超过选区宽度 + int previewPx = (int)((LC_PANEL_W - 2 * LC_PANEL_PAD) * lc->uiScale + 0.5); lc->thumbW = (std::min)((std::max)(1, previewPx), lc->physW); g_longCtx.store(lc); diff --git a/src/screenshot/windows/lc_toolbar_ui_windows.cpp b/src/screenshot/windows/lc_toolbar_ui_windows.cpp index a79d1e5..5e964c8 100644 --- a/src/screenshot/windows/lc_toolbar_ui_windows.cpp +++ b/src/screenshot/windows/lc_toolbar_ui_windows.cpp @@ -18,7 +18,8 @@ // 菜单浮层绝不进入选区画面——否则会被逐帧采样采进拼接内容、污染重叠识别基准。 -// 布局常量(逻辑像素,绘制/命中测试时按 dpiScale 缩放) +// 布局常量(逻辑像素,绘制/命中测试时按 uiScale 缩放——界面 chrome 用 uiScale, +// 内容几何换算用 dpiScale,两者区分见 LongCaptureContext.uiScale 注释) static const int LC_BAR_H = 44; // 工具栏高度(图标按钮 32 + 上下内边距 6) @@ -182,7 +183,7 @@ static void LongCaptureOutputSizeLabel(const LongCaptureContext* c, wchar_t* buf static LongToolbarLayout LongCaptureToolbarLayout(LongCaptureContext* c, int cw, int ch) { LongToolbarLayout L = {}; - double ds = c ? c->dpiScale : 1.0; + double ds = c ? c->uiScale : 1.0; // 界面 chrome 尺寸(按钮/间距/菜单)按 uiScale auto sc = [ds](int v) { return (int)(v * ds + 0.5); }; int barH = sc(LC_BAR_H), pad = sc(LC_BAR_PAD), btnH = sc(LC_BAR_BTN_H); bool popMenu = c && c->menuKind != LCM_None; @@ -245,10 +246,10 @@ static LongToolbarLayout LongCaptureToolbarLayout(LongCaptureContext* c, int cw, return L; } -// 工具栏窗口总宽(逻辑像素 × dpiScale),与 LongCaptureToolbarLayout 的横向排布严格一致 +// 工具栏窗口总宽(逻辑像素 × uiScale),与 LongCaptureToolbarLayout 的横向排布严格一致 static int LongCaptureToolbarWindowWidth(LongCaptureContext* c) { - double ds = c ? c->dpiScale : 1.0; + double ds = c ? c->uiScale : 1.0; auto sc = [ds](int v) { return (int)(v * ds + 0.5); }; int cell = LC_BAR_BTN_W + LC_BAR_GAP; int content = LC_BAR_BTN_W + LC_BAR_GAP // 最左 6 点拖拽把手格 @@ -270,7 +271,7 @@ static int LongCaptureHitTestToolbar(int x, int y, const LongToolbarLayout& L) { // 方向/裁剪共用同一套布局与命中机制) static RECT LongCapturePopoverCellRect(const LongCaptureContext* c, const LongToolbarLayout& L, int i) { - double ds = c ? c->dpiScale : 1.0; + double ds = c ? c->uiScale : 1.0; // popover cell 为界面 chrome 尺寸 int pad = (int)(LC_POP_PAD * ds + 0.5); int cell = (int)(LC_POP_CELL_W * ds + 0.5); int gap = (int)(LC_POP_CELL_GAP * ds + 0.5); @@ -489,7 +490,7 @@ void LongCaptureToolbarRender(LongCaptureContext* c, int dstX, int dstY, int w, s_lcTbSurfW, s_lcTbSurfH, w, h)) return; LongToolbarLayout L = LongCaptureToolbarLayout(c, w, h); - double ds = c->dpiScale; + double ds = c->uiScale; // 圆角/字号/图标均为界面 chrome 尺寸 int radius = (int)(LC_BAR_RADIUS * ds + 0.5); { Gdiplus::Bitmap surf(w, h, w * 4, PixelFormat32bppPARGB, (BYTE*)s_lcTbSurfBits); @@ -772,7 +773,7 @@ static void LongCaptureTooltipRender(LongCaptureContext* c, int dstX, int dstY, if (!EnsureArgbSurface(s_lcTipSurfDC, s_lcTipSurfBmp, s_lcTipSurfBits, s_lcTipSurfW, s_lcTipSurfH, w, h)) return; - double ds = c ? c->dpiScale : 1.0; + double ds = c ? c->uiScale : 1.0; // 气泡圆角/内边距/字号为界面 chrome 尺寸 { Gdiplus::Bitmap surf(w, h, w * 4, PixelFormat32bppPARGB, (BYTE*)s_lcTipSurfBits); Gdiplus::Graphics g(&surf); @@ -811,7 +812,7 @@ static bool LongCaptureTooltipVisible() { static void LongCaptureTooltipShow(LongCaptureContext* c, const wchar_t* text, const RECT& anchor) { if (!c || !text || !*text) return; - double ds = c->dpiScale; + double ds = c->uiScale; // 气泡测量/内边距/锚距为界面 chrome 尺寸 int padX, padY; LongCaptureTipPadding(ds, padX, padY); // 文本测量:必须与绘制同为 GDI+(MeasureString + 同款 Font/StringFormat)。 @@ -889,7 +890,7 @@ static void LongCaptureTooltipCancel() { //(此时可能盖到选区边角:菜单是点击即散的瞬态浮层,污染帧会被匹配管线安全拒绝)。 static bool LongCaptureMenuOpenBelow(LongCaptureContext* c, const RECT& bar) { - double ds = c->dpiScale; + double ds = c->uiScale; // 菜单面板高/间距为界面 chrome 尺寸(选区/屏幕边界为屏幕坐标不缩放) int gap = (int)(LC_MENU_GAP * ds + 0.5); int menuH = (int)(LongCaptureMenuHeightLogi(c) * ds + 0.5); bool below; @@ -925,7 +926,7 @@ void LongCaptureSetMenu(LongCaptureContext* c, LCMenuKind kind) { c->menuHover = -1; LongCaptureTooltipCancel(); if (!tb) return; - double ds = c->dpiScale; + double ds = c->uiScale; // 底条高/菜单高/间距为界面 chrome 尺寸 int barH = (int)(LC_BAR_H * ds + 0.5); int gap = (int)(LC_MENU_GAP * ds + 0.5); RECT wr; @@ -1009,8 +1010,9 @@ void LongCaptureSwitchDirection(LongCaptureContext* c) { // 帧缓冲转置复用纵向管线:physW/physH 交换(capW/capH 的屏幕采样 DIB 不变) c->physW = c->horizontal ? c->capH : c->capW; c->physH = c->horizontal ? c->capW : c->capH; - // 缩略图列宽随新帧宽重算(面板预览内宽,不超过帧宽) - int previewPx = (int)((LC_PANEL_W - 2 * LC_PANEL_PAD) * c->dpiScale + 0.5); + // 缩略图列宽随新帧宽重算(面板预览内宽,物理像素按 uiScale,与面板窗口尺寸同源, + // 不超过帧宽) + int previewPx = (int)((LC_PANEL_W - 2 * LC_PANEL_PAD) * c->uiScale + 0.5); c->thumbW = (std::min)((std::max)(1, previewPx), c->physW); LongCaptureResetSession(c); } @@ -1134,7 +1136,7 @@ LRESULT CALLBACK LongCaptureToolbarWndProc(HWND hwnd, UINT msg, WPARAM wp, LPARA if (c->tbDragging && GetCapture() == hwnd) { POINT pt = { GET_X_LPARAM(lp), GET_Y_LPARAM(lp) }; ClientToScreen(hwnd, &pt); - double ds = c->dpiScale; + double ds = c->uiScale; // 拖拽边界留白为界面 chrome 尺寸 auto sc = [ds](int v) { return (int)(v * ds + 0.5); }; RECT wr; GetWindowRect(hwnd, &wr); @@ -1367,7 +1369,7 @@ HWND LongCaptureCreateToolbar(CaptureContext* ctx, LongCaptureContext* c) { RegisterClassExW(&wc); registered = true; } - double ds = ctx->dpiScale; + double ds = c->uiScale; // 窗口尺寸/边距/图标光栅化均为界面 chrome 尺寸 auto sc = [ds](int v) { return (int)(v * ds + 0.5); }; // 图标缓存:按当前 DPI 光栅化(与主截图工具栏同比例:按钮边长 − 8 后缩放) s_lcIcons.Init(sc(LC_BAR_BTN_W - 8) + 2);