# duckimg **Repository Path**: acomma/duckimg ## Basic Information - **Project Name**: duckimg - **Description**: No description available - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-09-30 - **Last Updated**: 2026-10-02 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # DuckImg 看图 · 编辑 纯原生 Windows 看图与标注工具。**当前阶段:P0 界面设计完成,P1(Win32 壳与渲染管线)进行中。** | 项 | 内容 | |---|---| | 技术栈 | C++17/20 · Win32 API · Direct2D 1.3 · DirectWrite · WIC | | 目标系统 | Windows 10 1809+ / Windows 11,x64 与 ARM64 | | 运行时依赖 | 无 .NET、无 Qt、无 Electron;单 exe | | 设计基准 | 96 DPI DIP,Per-Monitor V2 DPI 感知 | --- ## 交付物 | 文件 | 说明 | |---|---| | [`docs/UI-DESIGN.md`](docs/UI-DESIGN.md) | **界面设计规格说明**:区域划分与尺寸、设计令牌(颜色/字体/间距)、组件规格、交互行为、标注工具规格、菜单与快捷键、DPI 与无障碍、性能目标、Win32 实现映射 | | [`prototype/index.html`](prototype/index.html) | **高保真可交互原型**:9 个界面 × 2 套主题 × 3 种窗口尺寸 | | [`design/tokens.yaml`](design/tokens.yaml) | **设计令牌唯一真源**:规格文档、原型、C++ 实现三者共用同一组数值 | | `src/` | **P1 实现**:Win32 窗口 + Direct2D/DirectWrite 渲染管线 + 矢量图标 + 主题令牌(见下方 P1 现状) | | [`CMakeLists.txt`](CMakeLists.txt) | 构建脚本(CMake + Ninja + MSVC,一键出 exe) | | [`tools/build.ps1`](tools/build.ps1) | 构建:配置 VS 环境 → CMake 配置 → 编译,产出 `build/-/DuckImg.exe` | | [`tools/capture-window.ps1`](tools/capture-window.ps1) | 抓取运行中的窗口画面(屏幕 BitBlt),用于人工核对渲染 | | [`tools/screenshots.ps1`](tools/screenshots.ps1) | 用本机 Chrome / Edge 无头渲染原型截图到 `prototype/.shots/`(9 界面 × 主题 × 窗口尺寸,共 27 张) | ### 构建并运行 P1 实现 ```powershell powershell -ExecutionPolicy Bypass -File tools\build.ps1 # Debug x64 powershell -ExecutionPolicy Bypass -File tools\build.ps1 -Run # 构建后直接启动 powershell -ExecutionPolicy Bypass -File tools\build.ps1 -Config Release ``` 调试开关: ``` DuckImg.exe --render=software 强制软件渲染(WARP) DuckImg.exe --topmost 窗口置顶(便于抓图核对) DuckImg.exe --self-test=<目录> 离屏渲染自检(当前受限,见 P1 现状) ``` ### 打开原型 直接双击 `prototype/index.html`(无需服务器、无外部资源)。 原型控制条(顶部深色条)提供: - **界面切换**:1 主界面 / 2 文件菜单 / 3 裁剪模式 / 4 缩放图像抽屉 / 5 导出对话框 / 6 设置 / 7 关于 / 8 右键菜单 / 9 空状态 - **主题**:深色(默认)/ 浅色 - **窗口**:1440×920(默认)/ 1100×700(紧凑断点)/ 全屏无边框 - **快捷键**:`←` `→` 切屏、`1`–`9` 直达、`T` 切主题 也支持深链,便于逐项评审与截图对比: ``` prototype/index.html?screen=crop&theme=light&size=compact ``` ### 重新生成截图 ```powershell # 默认:深色 + 1440×920 窗口 powershell -ExecutionPolicy Bypass -File tools\screenshots.ps1 # 浅色 / 紧凑断点 powershell -ExecutionPolicy Bypass -File tools\screenshots.ps1 -Theme light powershell -ExecutionPolicy Bypass -File tools\screenshots.ps1 -Size compact -Width 1200 -Height 800 ``` 输出文件名格式:`界面编号-界面名-主题-窗口尺寸.png`,例如 `03-crop-dark-compact.png`。 > 注:脚本用 UTF-8 **带 BOM** 保存(Windows PowerShell 5.1 需要 BOM 才能正确解析其中的中文)。用外部编辑器修改后请保留 BOM。 --- ## 设计要点(摘要) 1. **单窗口 + 标签页**,不搞多窗口 MDI;文件夹内导航放在底部可折叠缩略图条。 2. **没有"编辑模式"开关**——选中标注工具即可直接在图上画,这是与多数看图软件的核心差异。 3. **标注是矢量叠加层**,可随时重编辑、可单独显隐、导出时拍平;坐标以原图像素为单位,缩放视图不影响标注位置。 4. **破坏性操作全部可撤销**(裁剪/旋转/缩放/马赛克/拍平),栈深 100 步,按差异记录而非整图快照。 5. **状态栏右 1/3 永远在回答"下一步能做什么"**,同时常驻 RGB、坐标、缩放、标注计数。 6. **裁剪用画布内浮动确认条**而不是弹对话框,视线不离开画面,`Enter` 应用 `Esc` 取消。 7. **深/浅双主题**,颜色、字体、间距、圆角全部令牌化——可直接 1:1 映射为 `ui/theme/tokens.h`。 8. 完整界面尺寸表、快捷键总表、Win32 消息与渲染管线映射见设计文档第 4、12、15 节。 ### 首屏布局 ``` 标题栏 32 ┊ 菜单栏 28 ┊ 工具栏 44 ┊ 标签页 32 ├ 左工具栏 52 ┬ 画布视图区(弹性)───┬ 右侧检查器 272 ┤ │ └ 缩略图条 148 ─────────┘ │ 状态栏 26 ``` --- ## 分阶段实现计划(供排期参考) | 阶段 | 内容 | 状态 | |---|---|---| | **P0 界面设计** | 区域划分、令牌、组件、交互规格、可交互原型 | ✅ 完成 | | **P1 壳与渲染** | Win32 窗口、DPI、D2D 交换链渲染管线、自绘控件库、七大区域、63 个矢量图标、离屏导出 | 🟡 基本完成,收尾见下 | | **P2 看图内核** | WIC 解码、质量预缩放、滚轮缩放/拖动平移、缩放锚点、真实缩略图条、EXIF、光标取色、错误提示 | 🟡 核心已可用,见下 | | **P3 基础编辑** | 裁剪(画布内确认条)、旋转/翻转、图像缩放、撤销/重做栈、保存/导出 | 🟡 核心已可用,见下 | | **P4 标注** | 矢量图层模型;9 种图元(矩形/椭圆/直线/箭头/画笔/荧光笔/文字/序号/马赛克);绘制/选中/移动/显隐;拍平导出;与像素共用撤销栈 | 🟢 主要功能完成,见下 | | **P5 扩展** | 调色、任意角度旋转、边框、文字水印、批量处理已实现;幻灯片、UIA 无障碍待做 | 🟡 进行中 | ### P0 修复(命令路由 / 菜单 / 马赛克) 对照 `docs/UI-DESIGN.md` 与 `prototype/` 的界面忠实度审计发现了一批"看起来能用其实没用"的缺陷, 本批先修 P0(每一条都有可脚本化复现的回归证据): | 缺陷 | 根因 | 修法 | 回归证据 | |---|---|---|---| | 按 `R` 变成"箭头标注工具";工具栏 打开/保存/裁剪/旋转/翻转/缩放 全部错位 | `icons::Id`(1..62)与左侧工具栏 id(1..4、10..21)撞在同一段整数上,`App::on_command` 先按工具分支 `return` | 新增 [`src/ui/commands.h`](src/ui/commands.h) 统一命令命名空间(工具 101..104 / 201..212、标签页 1000+、菜单 4000+…),删掉 1..4 与 10..21 两条分支 | `--keys=R`:2400×1600 → **1600×2400**;`--clicks=223;50` 命中日志 `hit=裁剪 cmd=11` | | 整条菜单"点了只关菜单" | 所有菜单项共用一个恒为 0 的 `menu_command_id_` | `MenuItem` 增加 `cmd` 字段并逐项填真实命令 ID;`App::on_command` 增加 `cmd::menu_*` 分支 | `--clicks=183;14,250;42 --keys=Enter`(图像→裁剪→Enter)导出 **1920×1280** | | 点菜单项会**穿透**到底层控件(点「图像→裁剪」顺带触发工具栏"左转") | `dispatch_down` 在 `menu_->on_mouse_down()` **之后**才判断菜单是否打开,而它已经把菜单关掉了 | 点击前先记录 `menu_was_open`,打开过就消费掉这次点击 | 同一用例中工具条不再被误触发 | | `[` / `]` 快捷键从来没生效 | `case '['` 比的是字符码,Windows 实际发 `VK_OEM_4/6` | 改为 `VK_OEM_4` / `VK_OEM_6` | `--keys=[`、`--keys=]` 均得到 1600×2400 | | 画布上马赛克永远是半透明灰块 | 帧外预渲染好的 `mosaic_bitmaps_` 从未传进 `RasterContext` | `paint_annotations` 接上 `raster.mosaic_bitmaps` 并逐条设 `mosaic_index_base` | 渐变测试图上马赛克区出现规则 5–6px 色块(无马赛克时相邻像素全不同) | | 工具栏"显示 / 隐藏标注"实际隐藏的是**底图**;`Tab` 无效 | 眼睛按钮接到了 `show_image_` | 新增 `CanvasView::set_show_annotations`,按钮 / 视图菜单 / `Tab` 共用一个入口 | `--keys=Tab` 后标注消失、底图仍在 | | 视图菜单"显示缩略图条 / 检查器 / 网格"勾选态写死、点了没反应 | 无状态同步 API、无处理分支 | `MenuBar::set_command_enabled/checked` + `App::refresh_status` 按真实状态刷新;缩略图条与检查器真的可隐藏 | `--clicks=130;46,170;258` 后检查器消失(截图 79864 → 56807 字节) | | `--self-test=shots\selftest`(本文档里的用法)永远失败 | `SHCreateDirectoryExW` 不认相对路径 | 先 `GetFullPathNameW` 转绝对路径 | 退出码 0,4/4 用例 OK | > 注入开关的用途:屏幕抓图依赖 DWM 合成时序,本机经常抓到黑帧或别的窗口, > 而"按 C 到底进裁剪还是切成椭圆工具"这类问题必须真跑一遍。`--keys` / `--clicks` > 调用的是与真实输入完全相同的入口(`handle_message` / `dispatch_down/up`),结论可迁移到手动操作。 > **坐标是客户区物理像素,回归时要带 `--dpi=1`**(150% 缩放的系统上不带就会"点了没反应",此坑已踩)。 ### P1 修复(第一批:保存事务 / 脏检查 / 面板数值) | 缺陷 | 根因 | 修法 | 回归证据 | |---|---|---|---| | **`Ctrl+S` 覆盖保存从来没成功过** | 解码器一直握着图片文件,既不允许写也不允许删(`MoveFileEx` / `CopyFile` 都是 `ERROR_SHARING_VIOLATION 32`) | 两段式保存:先编码到 `<目标>.tmp`(此时还能读原图复制 EXIF)→ `ImageDoc::release_file()` 放掉解码器 → 备份 `.bak` + 原子替换 → `reacquire_file()` 挂回新内容 | `--save-now` 日志 `[save] ok=1`;输出在描边处 = `#FF3B30`,`.bak` 仍是原图绿 `#3CA05A` | | 保存没有事务:编码中途失败会留下半张图 | 直接 `InitializeFromFilename(目标)` 截断写 | 写 `.tmp` → `.bak` → `MoveFileEx` 原子替换(被占用时退化为原地覆盖) | 失败路径不再碰原文件;无 `.tmp` 残留 | | `Ctrl+S` 没有改动也照写一遍盘 | 没有脏检查 | `has_unsaved_changes()` 为假时只提示"没有需要保存的修改" | 第二次 `--save-now`:`[save] ...(skipped)` | | **关闭窗口会静默丢掉编辑** | `WM_CLOSE` 没有分支,直接 `DestroyWindow` | 增加三选一(保存 / 不保存 / 取消);"保存"失败或被取消则不关窗 | 脏文档 `WM_CLOSE` → 进程存活且出现 `#32770` 对话框;选"不保存"后退出;干净文档直接退出 | | 保存出来的文件里**没有标注** | 保存只写像素缓冲,矢量标注层留在内存 | 保存前自动拍平(与 `Ctrl+Shift+F` 同一入口,可撤销) | `[flatten] ok=1 拍平 1 个标注到底图`,输出文件出现红描边 | | 序号标注编号恒为 ① | 画布写死 `index = 1`,`Editor::anno_add` 不重编号 | 新增 `next_annotation_index()`(1 起第一个未占用编号),预览与提交共用 | 连画两个得 `序号 1 / 序号 2`;删掉 ① 后再画又回到 `序号 1` | | 属性面板滑杆"拖了没用":线宽每次只 +1、不透明度完全不动 | App 读的是旧样式再做固定增量,滑杆自身的值从未被读取 | 从命令来源控件取值(`dynamic_cast(source)->value()`),并把线宽 / 字号 / 块大小的映射统一到设计稿范围(1–40 px / 8–200 / 4–64) | 点滑杆 6% / 47% / 95% → `[prop] width=3.4 / 19.3 / 38.2`;不透明度 47% → `opacity=0.47` | | 拍平 / 导出路径的马赛克也是灰块 | 预渲染位图没接进拍平用的 `RasterContext` | 拍平前 `prepare_mosaic_bitmaps()`,并把位图表传进绘制回调 | 与画布路径共用同一实现 | 新增开关:`--save-now`(走界面的"保存"流程:拍平 + 事务化写盘 + 脏检查,用于脚本验证 `Ctrl+S` 这条路径)。 ### P1 修复(第二批:剪贴板 / 空状态) | 缺陷 | 根因 | 修法 | 回归证据 | |---|---|---|---| | `Ctrl+C` / 菜单"复制到剪贴板"**完全没实现**(此前只有粘贴) | 全库只有 `load_clipboard`,没有任何写剪贴板的代码 | 新增 `set_clipboard_image()`:`CF_DIBV5`(alpha 已反预乘)+ 注册格式 `PNG`;`App::copy_to_clipboard()` 提供两种载荷(当前显示尺寸 / 原图尺寸),菜单与 `Ctrl+C` / `Ctrl+Shift+C` 共用 | 载荷经 `--copy-to` 落盘核对:视图 **759×506**、原图 **2400×1600**;原图载荷里有 **5200** 个 `#FF3B30` 描边像素 | | **`Ctrl+C` 复制出来的图里没有标注** | 标注坐标是图像像素,而载荷位图是 zoom 倍大小,绘制前没缩放坐标系(`zoom=1` 的原图载荷因此正常,视图载荷全落在位图之外) | 画标注前 `push_scale(zoom)`,与画布同一条路径 | 视图载荷 vs 无标注基线**差异 2466 像素**(修复前为 0) | | 空状态显示 4 个并不存在的文档标签 | `TabBar` 构造函数里硬编码了原型的演示标签 | 起始为空,标签页只反映真实打开的文档 | 无图启动帧的标签条为空(`shots/compare/fix-10-empty-tabs.png`) | 新增开关:`--copy-to=view:out.png` / `--copy-to=whole:out.png`(把 `Ctrl+C` / `Ctrl+Shift+C` 会放进剪贴板的那张图写到文件,用于脚本核对载荷尺寸与内容)。 > 剪贴板验证:本机剪贴板偶尔被别的进程占着,代码里因此加了 6×40ms 重试;重试成功后 > 从**外部**读回剪贴板确认:`CF_DIBV5` 与 `PNG` 两种格式都在,PNG 能被系统解码为 > **759×506**、描边像素 `#FF3B30` —— "复制含标注的当前视图"这条链路是真的通了。 ### P1 修复(第三批:§12 键位重排) 设计稿 §12 的键位与实现原本大面积冲突(`R`=旋转、`H`/`V`=翻转、`[`/`]`=旋转…)。 现在按规格重排,并且**组合键也能脚本化回归**(`--keys` 支持 `Ctrl+` / `Shift+` / `Alt+` 前缀): | 键 | 现在的行为(= 设计稿) | 回归证据 | |---|---|---| | `V` `H` `I` `L` `R` `O` `D` `A` `B` `Y` `T` `N` `M` `U` `E` `G` | 单键选工具(选择 / 抓手 / 取色 / 套索 / 矩形 / 椭圆 / 直线 / 箭头 / 画笔 / 荧光笔 / 文字 / 序号 / 马赛克 / 模糊 / 橡皮 / 网格) | `--keys=R` → `[tool] anno id=201 name=矩形`,图片保持 2400×1600(不再被旋转) | | `Ctrl+[` / `Ctrl+]` | 左转 / 右转 90° | 两者都得到 1600×2400 | | `[` / `]` | 减小 / 增大笔宽 | `--keys=],],[` → `[width] 4.0 → 5.0 → 4.0` | | `,` / `.` | 上一个 / 下一个颜色 | `--keys=.,.` → `[color] idx=1 → 2` | | `Shift+H` / `Shift+V` | 水平 / 垂直翻转 | 左上角 `#DC3C3C` → `#3CA05A`;裸 `H` 只切工具、不翻转 | | 方向键 / `Shift`+方向键 | 微调选中标注 1px / 10px;无选中时 `←`/`→` = 上 / 下一张 | `[nudge] dx/dy` 日志 | | `PageUp` / `PageDown` | 文件夹内上一张 / 下一张(循环) | `--keys=PageUp` → `[step] 6/8`,导出尺寸变成上一张的 1600×1200 | | `+` / `-` | 放大 / 缩小 | 走与工具栏按钮同一条路径 | | `Tab` / `Shift+Tab`(或 `F12`) | 标注层显隐 / 沉浸模式(隐藏全部界面元素) | 截图字节:82550(普通)→ 81864(Tab)→ **17752**(沉浸) | | `Ctrl+Shift+Z` | 重做(此前误当成撤销) | 旋转 → `Ctrl+Z` 回到 2400×1600 → `Ctrl+Shift+Z` 回到 1600×2400 | | `Ctrl+R` / `Ctrl+B` / `Ctrl+O`+`Shift` / `Ctrl+,` / `Ctrl+P` / `Ctrl+W` | 缩放图像 / 批量处理 / 打开文件夹 / 设置 / 打印 / 关闭标签 | 前两个已实现;后四个尚未实现,按 `Ctrl+P` 会在状态栏明确提示"尚未实现" | > 踩坑记录:注入用的"虚拟修饰键"位**不能**直接用 VK 码 —— `VK_SHIFT=0x10` 与 > `VK_CONTROL=0x11` 按位与会撞,症状是"只按 Ctrl 被当成 Ctrl+Shift"(`Ctrl+Z` 变成重做, > 看起来像撤销失效)。现在用独立的位标志(见 `App::InjectMod`)。 ### P1 修复(第四批:右键上下文菜单) 设计文档 §7.3/§10.3 要求的右键菜单此前**完全不存在**(全库没有 `WM_RBUTTONDOWN`), 右键等于没反应。现在新增 `ContextMenu` 控件(样式与菜单栏弹出层一致,复用 `MenuItem`): - **内容随命中对象变化**:命中标注 → 删除此标注 / 上移一层 / 下移一层 + 灰着的"裁剪到选区""对选区打马赛克"占位; 空白处 → 适应窗口 / 实际大小 / 复制到剪贴板 + 全选标注(占位)/ 拍平 / 显示网格 / 显示标注。 - **禁用项保留占位不隐藏**(设计稿明确要求),宽度按最长标签自适应并钳在 200–320(§10.3 最大宽 320),贴边自动翻转。 - 右键即选中该标注;打开时独占点击(点菜单项执行、点别处只关闭,不穿透到下层控件);`Esc` / 再点右键关闭。 | 回归证据 | 结果 | |---|---| | 在标注上右键 | `[ctx] x=480 y=300 hit=0 items=6`(6 项 = 3 个可用 + 分隔 + 2 个占位) | | 空白处右键 | `[ctx] x=900 y=600 hit=-1 items=8` | | 点「删除此标注」 | `[ctx] delete sel=0 remaining=1`(2 个标注 → 1 个) | | 点「上移一层」 | `[ctx] raise sel=0` | | 点「复制到剪贴板」 | `[clip] copied 759x506 whole=0` | | 右键后按 `Esc` | 截图字节回到 82550(与"无菜单"状态一致) | > 踩坑记录:`--rclicks` 与 `--clicks` 的执行顺序必须与真实交互一致(**右键 → 左键 → 按键**), > 否则会出现"菜单还没打开就点选项"的假失败;另外菜单项的命中不能依赖"之前发生过 `WM_MOUSEMOVE`" > (脚本注入的点击没有移动事件),所以 `on_mouse_down` 自己重算一次命中项。 ### P1 修复(第五批:去掉重复的标题栏) 用户反馈"菜单栏上面有一行和标题栏一样的 DuckImg 看图 · 编辑"。根因是设计稿 §3.1 的 ① 自绘标题栏**只做了一半**: - 窗口仍是标准 `WS_OVERLAPPEDWINDOW` → 系统(DWM)画一条标题栏,文字由 `SetWindowTextW` 设为「文件名 — DuckImg 看图 · 编辑」; - 客户区里又预留了 32 DIP,并自绘了同样的文字(旧 `App::paint_ui`); - 把两者"合并成一条"的那一步 —— `extend_client_area()` / `hit_test_borderless()` ([`src/core/win32.cpp`](src/core/win32.cpp))—— 写好了但**没有调用点**, `WM_NCCALCSIZE` / `WM_NCHITTEST` 也没接。 于是系统标题栏与自绘带子同时存在,文字还一模一样。 **本批处理**:删掉客户区里的自绘带子,只保留系统标题栏(文件名继续由 `SetWindowTextW` 同步), 客户区第一行直接是菜单栏。主界面截图 79864 → 80054 字节(画布多出 32px 高); 工具栏按钮、菜单、属性滑杆的坐标相应上移 32px,已重新标定并跑通全部注入断言。 > ⚠️ **与设计稿的偏差(有意为之)**:§3.1 要求 ① 自绘标题栏(含应用图标与 `•` 脏标记), > §15.1 给出了"客户区扩展 + `HTMINBUTTON`/`HTMAXBUTTON`"的实现路线。当前先用系统标题栏, > 等自绘标题栏(图标 + 脏点 + 按钮 + 贴靠浮出)整体做完再切回去,避免长期停在半成品状态。 > 这条偏差按"未完成项"看待。 > > ✅ **已解决(第十五批)**:客户区扩展 + `WM_NCCALCSIZE` + `WM_NCHITTEST` 全部接上, > ① 自绘标题栏按设计稿落地(图标 + 文件名 + 脏点 + 三个窗口按钮 + 系统贴靠浮出), > 见下面的「第十五批」。 ### P1 修复(第六批:对话框体系) 设计文档 §10.1 的通用对话框外壳此前**完全不存在**(关窗确认还是系统 `MessageBox`, 导出/关于根本没有界面)。本批新增 [`src/ui/dialog.{h,cpp}`](src/ui/dialog.h): - 结构照 §10.1:页头 40(图标 + 标题 + ×)/ 内容内边距 16 / 页脚 54,主按钮 accent 实底且更高(30 vs 28); - 出现时整窗 35% 黑遮罩(浅色 22%),**独占点击**(点遮罩不关闭,模态语义);`Enter` = 主按钮,`Esc` = 取消; - 内容用"行描述"(`DialogSpec.rows`:分组标题 / 标签 / 说明 / 分段片 / 开关 / 滑杆)表达, 选择变化时改 spec 再 `open()` 重建即可,不必管控件生命周期; - 已接入三个对话框: | 对话框 | 尺寸 | 内容 | 证据 | |---|---|---|---| | 导出图片(§10.2 #1) | 520 | 格式分段片、**体积预估**(缩到长边 512 编码后按面积比放大)、JPEG 质量滑杆、尺寸、EXIF 开关、页脚"导出不会修改原文件" | 选 JPEG 后出现质量行(截图 100945 → 101902 字节);质量 54% 导出 **96212** 字节 vs 92% 的 **119972** 字节 | | 关于(§10.2 #4) | 420 | 版本、技术栈、目标系统、设计基准 | `--dialog=about` → `shots/compare/dlg-01-about.png` | | 保存修改确认(§10.2 #5) | 380 | "有未保存的修改" + **"对之后所有图片使用此选择"** + 保存 / 不保存 / 取消 | 真机注入实测:**取消** → 窗口保留;**不保存** → 退出;**保存** → `[save] ok=1` 后退出 | - 关窗流程从 `MessageBox` 换成自绘对话框,并改成**异步**(不能再阻塞消息循环): `WM_CLOSE` 只负责弹框,由 `dlg_save_yes` / `dlg_save_no` 两个命令决定是否 `DestroyWindow`。 **顺带修掉一个只在真机路径暴露的保存失败**:缩略图条会为**当前图片**也解一份缩略图, 那个解码器握着文件句柄,让"原子替换"以 `ERROR_SHARING_VIOLATION(32)` 失败 (`--save-now` 的无人值守回归没画过帧,所以漏掉了它)。现在覆盖保存前先 `FilmStrip::release_thumb(path)` 放掉,下一帧按需重新解码。 新增开关:`--dialog=about|export|save`(启动即打开对话框,供截图/回归)、 `--export-to=<路径>`(按当前导出选项直接写新文件;扩展名能识别时用它推断格式)。 仍待处理:批量处理对话框、另存为的质量面板。 ### P1 修复(第七批:设置对话框 + 配置持久化 + 快捷键一览) - **配置持久化**(§15.1):新增 [`src/core/config.{h,cpp}`](src/core/config.h),写 `%APPDATA%\DuckImg\config.json`(扁平 JSON,先写 `.tmp` 再原子替换)。自己写一个极小的解析器 而不是引 JSON 库:配置只有十来个扁平键值;写出去的仍是合法 JSON。读失败或文件不存在时退回默认值。 - **设置对话框**(§10.2 #3,640 宽):主题(深色 / 浅色,切换即时生效,含 DWM 标题栏)、 缩略图条 / 检查器 / 画布网格显隐、标注默认线宽·字号·马赛克块;页脚显示配置文件路径, 按钮为 恢复默认 / 取消 / 确定。**改完即时生效(可预览),"确定"才落盘,"取消"回滚到打开时的快照**。 - **对话框内容滚动**:设计稿要求"内容区最大高 = 屏幕 70%,内容滚动",此前没实现; 现在超出部分可用滚轮滚动。 - **快捷键一览**(F1 / 帮助菜单 / 工具菜单):按 §12 分成 文件与文档 / 编辑 / 视图 / 标注工具 / 菜单与面板 五组。 | 回归证据 | 命令 | 结果 | |---|---|---| | 主题即时切换 | `--dialog=settings --clicks=466;267` | 对话框随主题变浅(截图 97307 → 94995 字节) | | 配置落盘 | 同上 + `--keys=Enter` | `config.json` 里 `"theme_dark": false` | | **重启后仍然生效** | 再启动不带任何参数 | 主界面即浅色(55304 字节,与深色帧明显不同) | | 取消回滚 | `--dialog=settings --keys=Escape` | 配置不变、界面回到打开前状态 | | 内容滚动 | `--dialog=shortcuts --wheel=-600` | 与未滚动帧哈希不同(105051 vs 97841 字节) | | 全量回归 | 12 项注入断言 + `--self-test` | 全绿 | 新增开关:`--dialog=settings|shortcuts`、`--wheel=`。 > 注:设置对话框目前是**单列 + 分组标题**,设计稿 §10.2 #3 的左栏 7 分类导航还没做 > (需要在对话框行模型里加"分类 + 分页");文件关联、快捷键自定义、性能选项、界面密度 > 在对话框里以"尚未实现"列出。 ### P1 修复(第八批:设置对话框的左栏分类导航) 补上 §10.2 #3 要求的"左侧 7 分类 + 右侧表单"(上一批是单列 + 分组标题): - 对话框行模型新增**分类**维度:`DlgRow.cat` + `DialogSpec.categories / active_cat / cat_cmd`。 `categories` 为空时仍是单页对话框(关于 / 导出 / 保存确认不受影响)。 - 左栏 148 DIP,分类项 32 高,选中态 accent_soft + accent 文字,悬停 bg_hover; 点分类走 `cat_cmd + 下标`,App 只重建内容(**不重新快照**,所以"取消"仍能回滚到打开设置时的状态)。 - 7 个分类:常规 / 外观与主题 / 查看与缩放 / 编辑与标注 / 保存与导出 / 性能 / 快捷键。 | 回归证据 | 结果 | |---|---| | 默认页(常规) | 84873 字节 | | 点「外观与主题」 | 87355 字节(与默认页不同) | | 点「编辑与标注」 | 81740 字节(与前两页都不同) | | 点「快捷键」 | 81279 字节 | | 全量回归 | 10 项注入断言 + `--self-test` 4/4 全绿 | > 踩坑记录:分类项的高度受 `rail_.b` 约束,最初按"当前页内容"给对话框定高, > 结果第 4 个分类之后就被截断、点不着。现在高度取**最高的一页**与**左栏总高**的较大者—— > 顺带解决了切换分类时对话框上下跳动的问题。 ### P1 修复(第九批:另存为的格式 / 质量面板) §10.2 #6 要求"系统保存对话框 + 质量面板",此前 `Ctrl+Shift+S` 直接弹系统对话框, 只能按扩展名定格式,**没有质量、没有 EXIF 开关**。本批: - 导出对话框(§10.2 #1)与另存为面板(§10.2 #6)合并成同一套内容 (`rebuild_image_options_dialog(bool save_as)`):同样的格式分段片、体积预估、JPEG 质量滑杆、 尺寸、EXIF 开关,只有标题 / 图标 / 页脚文案 / 主按钮不同(导出… / 选择路径…)。 - `Ctrl+Shift+S` → 先出面板,选好格式与质量,再弹系统保存对话框;写入走 `write_image_with_options()`(与导出同一条:拍平标注 → 临时文件 → 原子替换;不写 `.bak`, 也不改"已保存"状态)。 - 新增 `--save-as-now=<路径>`:走"选择路径…"之后的**同一条**写入路径,绕过系统模态框, 让格式 / 质量 / EXIF 可以被脚本化验证。 | 回归证据 | 结果 | |---|---| | 面板(PNG,默认) | 101863 字节 | | 面板(JPEG:多出质量行) | 102179 字节(与前者不同) | | 质量 54% 写入 | `[write] … fmt=1 q=0.54` → **98261** 字节 | | 质量 92% 写入 | `[write] … fmt=1 q=0.92` → **125286** 字节 | 新增开关:`--dialog=save-as`、`--save-as-now=<路径>`。 > 系统保存对话框是模态框,脚本无法驱动;面板本身是自绘的(可注入、可截图), > 而两侧共用同一条写入路径,所以"面板里选的选项真的生效"是被验证过的。 ### P1 修复(第十批:批量处理对话框) §10.2 #7 此前是"系统文件夹选择框 + 操作序列输入框",没有文件列表,也没有格式 / 尺寸 / 输出位置选项。 本批改成自绘对话框(760 宽,照设计稿): - 对话框行模型新增 **list** 行(可勾选列表,24 高一行,最多列 8 行),点整行切换勾选; - 内容:源文件夹路径 / 文件列表(可勾选)/ 输出格式(PNG·JPEG·BMP·TIFF·GIF)/ 尺寸限制(保持原尺寸 · 长边 1920 · 长边 1280)/ 输出位置(源目录 `out\` 子目录 · 源目录内, 文件名加 `_out`)/ 命名规则说明;页脚显示"共 N 张,已勾选 M 张"; - 处理仍复用 `core/batch.cpp` 的流水线;"尺寸限制"翻译成新操作 **`resize-fit:<长边>`** (等比缩到长边不超过 N,已更小则不动)——这样**单文件编辑与批量走同一套操作语义**: `--edit=resize-fit:1280` 同样可用。 | 回归证据 | 结果 | |---|---| | 对话框截图 | `--dialog=batch --batch-dir=…` → 96731 字节;点击后 98750 字节 | | 真跑一批(8 张 → JPEG) | `[batch] 完成:成功 8 / 8,输出目录 …\out`,产出 8 个 `*_out.jpg` | | `resize-fit` 操作 | 2400×1600 → **1280×853**(长边 1280,比例保持) | | 全量回归 | 10 项注入断言 + `--self-test` 4/4 全绿 | 新增开关:`--dialog=batch`、`--batch-dir=<目录>`、`--batch-run=<目录>`。 > 命名规则与完整文件列表在第十四批已补齐:列表行不再截断,命名规则可编辑并随配置持久化 > (见「第十四批:对话框文本输入行」)。 ### P1 修复(第十一批:F11 真全屏) `F11` 此前只是 `todo("全屏")` 提示。现在: - 用 `WS_POPUP | WS_VISIBLE` + `SetWindowPos` 铺满**所在显示器**(`MonitorFromWindow` + `MONITORINFO`),而不是"把窗口放大"——后者在非 100% DPI 下会因非客户区尺寸把内容挤出屏幕; - 进入前记住 `GWL_STYLE` 与窗口矩形,`F11` 再按一次或 `Esc` 还原; - 切换后重建 `Gfx` 后备缓冲与布局(客户区尺寸变了)。 | 回归证据(真机注入) | 结果 | |---|---| | 原始窗口 | 1440×920(客户区 1418×864),样式 `0x14CF0000` | | F11 后 | **3840×2160**,客户区 = 3840×2160(即没有标题栏/边框),样式 `0x94000000`,无 `WS_CAPTION` | | Esc 后 | 1440×920,样式与原始**逐位一致** | | 全量回归 | 11 项注入断言 + `--self-test` 4/4 全绿 | ### P1 修复(第十二批:§7.3 选区与 8 个手柄) §7.3 要求"选中标注后给 8 个控制手柄,拖动可缩放"。此前只有一圈随缩放变化的细框: **没有手柄、不能缩放**(P4 的"下一步"里一直挂着"缩放已有标注"), 而且没有任何注入开关能表达"按下→移动→抬起",所以这类交互**根本无法脚本化验证**。 **先补验证能力,再改交互**(顺序很重要): | 新开关 | 作用 | |---|---| | `--drag=x1;y1,x2;y2[|…]` | 注入左键拖拽:`WM_LBUTTONDOWN` → 6 次 `WM_MOUSEMOVE`(带 `MK_LBUTTON`)→ `WM_LBUTTONUP`,全程走真实消息路径;多段用 `|` 分隔 | | `--post-keys=<序列>` | 在**拖拽之后**按键(`--keys` 的历史位置在 `--drag` 之前,导致"拖完再 `Ctrl+Z`"测不到) | | `--probe-cursor=x;y[|…]` | 报告该点上画布会用哪个光标(与 `WM_SETCURSOR` 共用 `App::canvas_cursor_at`) | | `--print-state` | 打印 `zoom` / `image_rect` / `canvas` / 选中下标 / 手柄数 / 每个标注的图像与屏幕矩形 | **交互实现**(`src/ui/panels2.cpp`、`src/core/annotations.cpp`、`src/core/editor.cpp`): - 选中框与 8 个手柄画在**屏幕空间**(先 `pop_transform` 再画):手柄 8×8 DIP、白底 + accent 描边, **图缩放时大小恒定**(画在 `push_scale(zoom)` 里的话,15% 时手柄只有 1px、800% 时比图还大)。 - 命中**优先于标注本体**(±4 DIP):手柄就压在图形描边上,若先做本体命中, "拖右下角"会被解释成"按住内部拖动" → 永远只能平移、缩不了。 - 拖动期间**只改预览**(画布只持有只读的标注数组),松手才走 `Editor::anno_set_rect()` 提交**一次**可撤销的编辑;尺寸没变不入历史。 这条语义与属性面板滑杆的"松手才上报"完全一致。 - 三条约束:**对边固定**(只动被拖的边)、最小 **4 图像像素**、**夹在图像边界内**; 反向拖动用 `min/max` 兜住 `std::clamp` 的 `lo <= hi` 前提,不会出现 UB 或翻转矩形。 - 光标反馈:`WM_SETCURSOR` → `canvas_cursor_at()`,四角 `IDC_SIZENWSE/SIZENESW`、 上下边 `IDC_SIZENS`、左右边 `IDC_SIZEWE`;判定用**位置命中**而不是缓存的悬停态, 窗口刚激活、还没收到 `WM_MOUSEMOVE` 时也给得对。 - 手柄只在**选取工具**下出现(§9.1 状态机的 `[选中 Select]` 状态):画标注时手柄若还能拖, 就和"拖动绘制"分不清了。 - `Annotation::bounds()` 顺手修正:文字标注的宽度此前恒为 0(锚点而已)、序号是个退化点, 两者都会让 8 个手柄叠成一团。现在文字按"全角 1em / 半角 0.55em + 内边距"估算宽度, 序号按圆形徽标(半径 `0.8×字号`)给包围盒。 **顺手修掉的两个既有问题** 1. 选取模式下点空白取消选中时**没有清掉拖动状态**:下一次鼠标移动会拿上一次的点集算出位移, 于是"点空白 → 移动鼠标 → 再点空白"会凭空平移上一次选中的标注。 2. `--export-to` 排在 `--drag` **前面**,导出的是"拖动还没发生"的那一版 (第一次这样验证时前后两个文件哈希完全相同);`--save-as-now` 同理。 两者都是"拍平 + 写盘",现在统一排到所有输入注入之后。 另外 `--copy-to=view:x.png` 的前缀此前只识别 `whole:`,README 里写的 `view:` 形式 会去写一个名为 `view:x.png` 的文件(目录不存在 → 必然失败),已一并修掉。 **回归证据**(`tools/check-selection.ps1`,25 项断言全绿;坐标取自 `--print-state` 的权威数值, 不从截图反推几何): | 断言 | 结果 | |---|---| | 选中态手柄数 | `[sel] handles=8 index=0 box=395,260,702,500`;标注工具下 `[sel] handles=0` | | 拖右下角手柄(+120,+90 客户区像素) | 图像矩形 `600,400,1500,1100` → **`600,400,1857,1368`**:左/上**不动**,右 = 期望 1856.8、下 = 期望 1367.6 | | 一次可撤销编辑 | `[resize] idx=0 ok=1 size=1257x968 hist=2 head=2`;`--post-keys=Ctrl+Z` 后回到 `600,400,1500,1100` | | 最小尺寸 | 拖到左上角之外 → `size=4x4`(4 图像像素) | | 图像边界 | 拖到画布外 → 右下夹到 `2400,1600`(= 图像尺寸) | | 光标映射 | 8 个手柄 → `SIZENWSE`/`SIZENS`/`SIZENESW`/`SIZEWE` 各 2 次,画布空白 `SIZEALL`,画布外 `ARROW` | | **对照组 A**(矩形工具拖同一位置) | 标注数 1 → **2**(新建),#0 仍是 `1500x1100` **未变** | | **对照组 B**(选取模式、未选中时拖同一位置) | 仍是 1 个标注,尺寸 `900x700` **不变**,原点 `600 → 957`(**平移**而非缩放) | | 选中 vs 未选中截图 | SHA-256 不同;画布内差异 **3157 px**,差异框 `(390,255)-(707,505)` 正好是选中框 + 手柄 | | 导出像素 | 19393 → 19432 字节,差异 **17780 px**,差异框 `(598,398)-(1859,1370)` = 新旧矩形并集 | | 手柄尺寸恒定 | `zoom=0.2` 与 `zoom=0.6` 下左上角手柄均为 **6 px 白心**(8 DIP 去掉描边);150% DPI 下为 12 布局像素 = 8 DIP | | 全量回归 | 25 项注入断言 + `--self-test` 4/4 全绿(深/浅主题、满/空选中态各截图见 `shots/sel/`) | > 验证脚本本身也踩了一个坑:`--probe-cursor` 用的是 `arg_value()`(只取**第一次**出现), > 写成多个开关时后面几个被静默忽略——多点必须用 `|` 串成一个参数。脚本里已注明。 ### P1 修复(第十三批:§7.3 旋转手柄) §7.3 还有后半句:"可旋转手柄(悬停顶部中点上方 20px 处出现)"。第十二批做完 8 个手柄后 它是唯一还缺的交互。难点不在手柄本身,而在**标注模型里没有"角度"这个概念**。 **为什么把角度存成字段,而不是把旋转烘焙进点集** 矩形只有两个角点,一旦把旋转写进坐标就退化成"四点折线",再编辑(转回去、改样式)就还原 不回来;画笔笔迹每次旋转还要重算全部点,误差会累积。存一个 `Annotation::angle` (度、顺时针为正、绕包围盒中心)则处处可逆:绘制、命中、导出、撤销都只是同一件事。 | 落点 | 做法 | |---|---| | 绘制 | 新增 `Gfx::push_rotate(cx,cy,deg)`(与 `push_scale` 同样"新变换 × 当前变换");`paint_annotation` 画之前压入、画完弹出 | | 命中 | `annotation_hit` 先把查询点绕框心**反向**转回未旋转坐标系,再做原来的判定——各图元的命中逻辑一行没改 | | 导出 | 旋转放在 `paint_annotation` 内部而不是画布层:拍平导出直接调它,若只在画布做,屏幕上转过的标注导出后会"转回来" | | 手柄 | 8 个手柄位、旋转手柄位、选中框(折线闭合的四条斜边)全部按同一角度绕框心旋转 | | 缩放 | 拖角时把鼠标位移**投影回图元自己的坐标轴**(`rotate_about(dx,dy,-angle)`),否则斜着拖一个转 45° 的矩形,改的是它的外接矩形 | | 提交 | 松手走 `Editor::anno_set_angle()`:角度归一化到 `(-180,180]`,角度没变不入历史(与 `anno_set_rect` 同一条语义) | | 吸附 | 拖动中 `Shift` → 就近 15° 整数倍;`--drag=Shift+x;y,x;y` 会把这位置进 `WM_MOUSEMOVE` 的 `wParam`,与真实按键一致 | **回归证据**(`tools/check-selection.ps1` 第 8 节共 8 项;累计 33 项断言全绿): | 断言 | 结果 | |---|---| | 脚本化设角(`--annotate=…,angle:0;30`) | `[anno-screen] … angle=30.0` | | 手柄跟着转 | 旋转后左上角 `(476,199)` → `handle=1 IDC_SIZENWSE`;**未旋转的旧位置 `(395,260)` 已无手柄** | | 拖旋转手柄 | `grip down handle=9 … angle=0.0` → `grip up angle=30.4` → `[rotate] ok=1 hist=2 head=2` | | 撤销 | `--post-keys=Ctrl+Z` → `angle=0.0` | | `Shift` 吸附 | 同一条拖动:不按 Shift = **20.0°**,按住 Shift = **15.0°**(15 的整数倍) | | 命中测试 | 同一屏幕点 `(404,269)`:未旋转 `hit=0`,旋转 30° 后 `hit=-1`(该点确实落到旋转后的形状之外) | | 导出 | 未旋转 vs 旋转 30°:差异 **25246 px**,差异框 `(483,219)-(1617,1281)` = 旋转后的矩形范围 | | 马赛克也能转 | 预渲染位图走同一条变换:旋转前后差异 **35383 px**(`zoom-mos30.png` 里能看到倾斜的块) | | 旋转手柄只在悬停时出现 | `40-rotate-hover.png` 有圆形手柄 + 连接线;未悬停时差异 15062 px | | 全量回归 | 33 项注入断言 + `--self-test` 4/4 全绿 | > 旋转手柄在 `WM_SETCURSOR` 里用 `IDC_CROSS`(Windows 没有"旋转"标准光标); > `--probe-cursor` 报告 `handle=9 kind=IDC_CROSS`,与 8 个缩放手柄区分得开。 ### P1 修复(第十四批:对话框文本输入行 + 完整文件列表) 对话框的行模型此前只有 8 种**只读**行,于是一直流着两条"待做":批量处理的命名规则写死 `_out`(第十批的遗留说明)、文件列表最多列 8 项(再多只给个"共 N 张")。这两件事卡在 同一个能力上——**自绘对话框没有文本输入**。而自绘输入框需要一整套系统级配套, 缺任何一个都表现为"能看不能打"。 | 配套 | 做法 | |---|---| | 输入 | `WM_CHAR`(普通键入)+ `WM_IME_CHAR`(输入法提交)+ `WM_IME_COMPOSITION` 的 `GCS_COMPSTR` 内联预览 | | 光标 | 530ms 闪烁(复用既有的 50ms 定时器)、按文本度量定位、内容超长时框内横向滚动 | | IME | `caret_rect` → `ImmSetCompositionWindow` / `ImmSetCandidateWindow`;自绘输入框没有真实 HWND 可供输入法定位,少了这一步候选窗会飘到窗口角上 | | 焦点 | 打开对话框自动聚焦第一个文本行、点击按 x 落光标、点别处失焦(失焦后键入不插入)、`Tab` 在文本行间循环;`Enter`/`Esc` 仍是主按钮 / 取消 | **IME 只插一次**:`WM_IME_COMPOSITION` 交给 `DefWindowProc`,由它生成 `WM_IME_CHAR`; 如果自己去读 `GCS_RESULTSTR` 再插一遍,中文就会**插两遍**(自绘输入框的经典坑)。 `GCS_COMPSTR` 只用来做未提交的内联预览,不落进 value。 **命名规则**(批量处理):`{name}` 原名 · `{n}` 序号(按列表顺序 1 起)· `{ext}` 扩展名 · `{date}` 日期;非法文件名字符自动替换为 `_`(用户能输入模板,就一定能输入出一个非法文件名, 不挡的话整批都会写盘失败);随 `config.json` 持久化,对话框与 `--batch-pattern=` 共用一条规则。 列表行同时去掉 8 项上限,内容超出 70% 屏高时整页滚动。 **顺手修掉的两个既有 bug**(都被"8 行上限"盖住了): 1. **命令 ID 撞车**:列表行上报 `dlg_batch_toggle + 下标`,而 5080/5090/5100/5110 分别是 格式 / 尺寸限制 / 输出位置 / 开始处理——列表一超过 9 行,"第 11 个文件"就变成 "输出格式 PNG",**点勾选会静默改格式**。现在列表行只报一个命令 ID,第几行由 `Dialog::last_pick_opt()` 给出(`commands.h` 里记了这一类坑)。 2. **滚动后命中矩形错位**:命中矩形登记的是未滚动坐标,靠 `on_wheel` 做"绝对量"平移; 只要中间发生一次 layout(App 每次注入前都会 layout),矩形就复位而 `scroll_` 还留着 ——滚到底点第 12 行会点到第 7 行。现在绘制与命中在 `layout()` 里用**同一个公式** (`content_.t - scroll_`)算出。 **回归证据**(`tools/check-dialog-text.ps1`,20 项断言全绿): | 断言 | 结果 | |---|---| | 自动聚焦 + 光标在输入框内 | `focus_row=9 caret=10`(= 默认模板长度);`caret_rect.l=533` 落在输入框 `[455,1073]` | | `WM_CHAR` 键入 | `{name}_out` → `{name}_out-v2` | | 编辑键 | `End,Back,Back,Left,Delete` → `{name}_`(caret=7);`Home` + 键入 → `pre_{name}_out` | | IME 提交 | `WM_IME_CHAR` → `{name}_out-中文版`;`[ime] start composition caret=533,559` 落在输入框内;提交后 `comp=""`(无残留预览) | | 点击落光标 | 点输入框中段 `x=495` → `caret=5`(0 < caret < 10) | | I 形光标 | 输入框上 `dlg_ibeam=1 kind=IDC_IBEAM`,其余对话框区域 `0` | | 失焦 | 点标题区 → `focus_row=-1`;随后键入 `ZZZ` **未插入** | | 完整文件列表 | 12 张图 → `[dlg-list] items=12 checked=12`(旧版为 8),`scroll=0/103` | | 滚动命中一致 | 滚到底点同一 y → `toggle file=11`;未滚动时同一 y → `toggle file=7`(对照组) | | 命名规则落到盘上 | 对话框里改成 `{n}-{name}` → 输出 `1-a.jpg 2-b.jpg 3-c.jpg`;`config.json` 写入 `"batch_pattern": "{n}-{name}"` | | CLI 同一规则 | `--batch-pattern=dup-{n} --batch=…` → `dup-1.png dup-2.png dup-3.png` | | 全量回归 | 20 项 + 选区 33 项 + `--self-test` 4/4 全绿;关于/导出/保存确认/设置/快捷键/另存为 6 个对话框截图无异常 | 新增开关:`--type=<文本>`(注入 `WM_CHAR`)、`--type-ime=<文本>`(注入 `WM_IME_STARTCOMPOSITION` + `WM_IME_CHAR` + `WM_IME_ENDCOMPOSITION`)、`--post-clicks=x;y`(滚轮之后再点)、 `--batch-pattern=<模板>`。 > 已知限制:输入框没有"选区"模型(`Ctrl+A` 无效),只能靠 `Home`/`End`/`Back`/`Delete` 编辑; > 候选窗定位这条路径无法脚本化(真正的中文输入法不能从注入里驱动), > 可验证的是同一个 `caret_rect` 与 `WM_IME_CHAR` 提交路径,实机输入法需手动核对一次。 ### P1 修复(第十五批:自绘标题栏 / 客户区扩展) 设计稿 §3.1 的 ① 是"32 DIP 自绘标题栏:应用图标 + 文件名 — 应用名 + `•` 脏点 + 窗口按钮", §15.1 给的路线是"保留 `WS_OVERLAPPEDWINDOW`,处理 `WM_NCCALCSIZE` / `WM_NCHITTEST` 做无边框 客户区扩展,按钮命中返回 `HTMINBUTTON`/`HTMAXBUTTON`"。第五批**故意**没做(半成品会出现 "系统标题栏 + 自绘带子"两行重复),本批把它整体接上。 | 落点 | 做法 | |---|---| | `WM_NCCALCSIZE` | 非客户区算成 0(客户区铺满整窗),保留 `WS_OVERLAPPEDWINDOW` → 阴影、圆角、贴靠、系统菜单、`Alt+Space` 全在 | | 最大化 | 系统会把窗口矩形按边框厚度向外撑大,直接把客户区算成整窗会让内容顶到屏幕外:最大化时按 `SM_CXSIZEFRAME + SM_CXPADDEDBORDER` 收回,实测客户区 = 显示器工作区 | | 非最大化 | 左右下各内缩 1px(留 DWM 边框),**上边不缩**——缩了标题栏会被裁掉一条 | | 创建后 | 补一次 `SetWindowPos(SWP_FRAMECHANGED)`:创建期间的 `WM_NCCALCSIZE` 早于 `WM_CREATE`,那时 `hwnd_` 还是空指针,这一轮走的是标准边框 | | `WM_NCHITTEST` | 三个按钮矩形 → `HTMINBUTTON`/`HTMAXBUTTON`/`HTCLOSE`(贴靠浮出、双击标题栏最大化、拖动贴靠都由系统负责);其余交给 `hit_test_borderless()`(缩放边框 + `HTCAPTION`) | | 悬停高亮 | 命中非客户区后鼠标消息不再进客户区,因此高亮靠 `WM_NCMOUSEMOVE` 喂给控件 | | ① 标题栏控件 | 矢量应用图标(不引位图资源)、"文件名 — DuckImg 看图 · 编辑"、文件名右侧 7px 橙点(§6.3 脏点)、三个 46×32 按钮(新增 `WinMin`/`WinMax`/`WinRestore` 图标,最大化时切成"还原"字形);沉浸模式与 F11 全屏下收成空矩形 | | 开关 | `--probe-nchit=x;y`(把点送进 `WM_NCHITTEST` 读命中码)、`--window=max|restore|min`;`--print-state` 增加 `[window]` / `[title]` 两行 | **顺手修掉一个真 bug:脚本化编辑从来没进过渲染帧** `run_edit_script()` / `run_annotate_script()` 改完文档后**不做任何界面同步**——不丢 D2D 位图缓存、 不重绑画布像素源。于是 `--edit=rotate-cw --capture=out.png` 与不编辑的截图**逐像素相同** (实测差异 0 px)。历史上所有"--edit 之后截图"的验证,看到的其实都是编辑前的画面; 之所以一直没暴露,是因为没人对这两张图做过像素比对。现在两条脚本路径统一走 `sync_after_document_change()`(丢缓存 → 重绑像素源 → 标签页/标题栏/检查器/状态栏一起刷)。 **回归证据**(`tools/check-titlebar.ps1`,18 项断言全绿): | 断言 | 结果 | |---|---| | 客户区扩展 | window `1440×920` → client **`1438×919`**(左右下各 1px),样式 `0x04CF0000` 仍含 `WS_CAPTION|WS_THICKFRAME` | | 标题栏几何 | rect `0,0,1438,32`;三个按钮各 46 DIP 且右对齐(`1300..1346 / 1346..1392 / 1392..1438`) | | 命中码 | 最小化 `HTMINBUTTON`、最大化 `HTMAXBUTTON`、关闭 `HTCLOSE`、标题栏空白 `HTCAPTION`、标题栏下方 `HTCLIENT`、左/右/下边框 `HTLEFT`/`HTRIGHT`/`HTBOTTOM` | | 最大化 | client **`3840×2088` = 显示器工作区**(内容没有被顶出屏幕);y=16 仍命中 `HTCAPTION`;`max,restore` 往返回到 `1438×919` | | F11 全屏 | client `3840×2160`(= 整屏)、标题栏高度 0、`fullscreen=1` | | Shift+Tab 沉浸 | 标题栏高度 0(不占布局) | | 脏点 | `--edit` 后 `[title] dirty=1`,标题栏区域截图差异 36 px(橙点) | | 脚本化编辑进帧 | `[sync] doc=1600x2400`(rotate-cw),整帧差异 **474472 px**(修前 0 px) | | 全量回归 | 标题栏 18 项 + 选区 33 项 + 对话框 20 项 + `--self-test` 4/4 全绿;4 个既有对话框截图无异常 | > 三个回归脚本里原先写死的注入坐标(拖拽终点、旋转手柄位置、命中测试取点)这一批全部改成 > **由日志里的权威几何算出来**:加了 32px 标题栏之后,写死的坐标会悄悄指到别的地方 > (表现为"边界夹取"和"Shift 吸附"这两项假失败,而程序行为一直是对的)。 ### P1 修复(第十六批:幻灯片播放 F5) 设计 §7.1:`F5` 从当前图开始,间隔默认 3s(可配 1–30s),空格暂停,`Esc` 退出; §12.2 把它列在视图组。此前 `F5` 只是 `todo("幻灯片播放")` 提示。 | 落点 | 做法 | |---|---| | 计时 | **复用既有的 50ms `WM_TIMER`**(与工具提示、输入框光标同一个定时器):每次进来比一次时间戳,到点翻下一页。不另开定时器、不阻塞消息循环,播放期间滑杆/菜单照样能用 | | 翻页 | 走 `step_document(+1)`——与 `PageDown` 完全同一条路径(到末张回到第 1 张,缩略图条高亮跟着走) | | 暂停 / 退出 | 空格暂停/继续、`Esc` 退出、再按 `F5` 也退出;判定插在 `WM_KEYDOWN` 最前面(空格平时是抓手、Esc 平时退全屏,播放期间优先给播放) | | 手动翻页 | `PageUp/PageDown`、`←/→` 之后**重新计时**,否则刚好卡在到点那一刻会"手翻一张又被自动翻走" | | 间隔 | `AppConfig::slideshow_interval`(1–30s,读写 `config.json`),设置对话框「查看与缩放」里给了滑杆;改完立刻重排计时 | | 提示 | 状态栏中段提示(画布徽标此后整套去掉,见「第十九批」);只有一张图的文件夹直接说明原因而不是静默不动 | | 开关 | `--slideshow=start\|stop\|pause\|resume[,…]`、`--slideshow-tick=<总毫秒>`(按 50ms 步进喂合成时间戳走同一个 `slideshow_tick()`) | **顺手修掉一个一直没被发现的布局 bug:状态栏的中段提示从来没显示过** `StatusBar::layout()` 把除弹性段以外的**所有**段都右对齐:左侧那组(路径/尺寸/色彩/体积) 于是跟右侧那组并到一起,中间的弹性段被挤成 **0 宽**。设计 §3.2 明确要求"右 1/3 永远留给 下一步可做什么",§6.7 的段顺序也是"1–4 靠左、5 弹性、6–10 靠右"——所以这不是审美问题, 而是与设计稿不符、且**所有操作提示都看不见**("滚轮缩放 · 拖动平移…"、保存结果、幻灯片状态…)。 现在按 grow 之前靠左 / 之后靠右分开排布。 **回归证据**(`tools/check-slideshow.ps1`,21 项断言全绿): | 断言 | 结果 | |---|---| | F5 开始 / Esc / 再按 F5 | `on=1 interval=3 file=7/8`(从当前图开始)→ `on=0` → `on=0` | | 定时 | 2900ms **0 次**、3100ms **1 次**、10000ms **3 次**,且循环回第 2 张 | | 暂停 / 继续 | `start,pause` + 10s → 0 次;空格 → `paused=1` 且 0 次;`resume` + 3100ms → 1 次 | | 手动翻页 | `PageDown` 后 2900ms 内 **0 次**自动翻页(计时已重置) | | 间隔 | 设置对话框滑杆 3s → 15s(`value01 0.069 → 0.483`),确定后 `config.json` 写入 `"slideshow_interval": 15`;新进程 3100ms **0 次** / 16000ms **1 次**;随后还原 3 秒 | | 单图文件夹 | 拒绝开始(`on=0`)且提示"文件夹内只有一张图,无需播放" | | 状态栏中段 | `[status] message="幻灯片播放:3 秒/张…"`,中段区域像素差异 **1036 px**(修前该段宽度为 0) | | 全量回归 | 幻灯片 21 + 标题栏 18 + 选区 33 + 对话框 20 项断言,`--self-test` 4/4 全绿 | > 有意**不**在播放时自动进全屏/沉浸:§7.1 只要求"从当前图开始 + 间隔 + 暂停 + 退出", > 而 `Tab`/`F11` 是用户自己的视图选择;强行改视图的话退出时还得猜原来是什么状态。 ### P1 修复(第十七批:多标签页) §6.3 / §12.1 的"单窗口 + 标签页":图片 = 标签页,`Ctrl+T` 新建、`Ctrl+W` 关闭、 `Ctrl+Tab` 循环、`Ctrl+1..9` 直选,脏标签先问再关(§7.6),单文档时标签条自动隐藏(§3.2)。 此前 `TabBar` 早就画得出 N 个标签、也画了 `×` 与末尾的 `+`,但 **App 只往里塞一个标签**, 文档模型也是单个 `Editor` —— 标签条上的两个按钮**只有绘制、没有命中测试**。 | 落点 | 做法 | |---|---| | 文档模型 | `std::vector`(每个标签一个 `Editor`:各自的像素、撤销栈、标注层)+ `active_doc_`;`doc()` 返回活动标签的引用,**没有标签时返回一个空的兜底 Editor**(把"空"做成合法状态,几百处 `doc().xxx()` 一处都不用判空) | | 兼容改动 | 268 处 `doc_.` 机械替换为 `doc().`(编译器全程兜底),App 里不再有单个 `Editor` 成员 | | 打开 | `--open=a.png\|b.png` / 拖入多文件 → 多标签;已有文档时 `Ctrl+O`、拖入单文件都开**新**标签(不顶掉当前那张) | | 切换 | 每个标签自带视图状态(缩放/平移/缩放模式),切走时存、切回时恢复;裁剪框等瞬时状态不跨标签 | | 关闭 | `Ctrl+W` 关当前、`Ctrl+Shift+W` 关全部、点标签上的 `×` 关那一个;脏标签先弹"保存修改?"(选保存 = 先切到该标签再存;选不保存 = 直接关) | | 标签条 | 内容/脏点/显隐统一走 `sync_tabs()`;**单文档时高度为 0**(§3.2"减少视觉噪音"),多标签时占 32 DIP | | 开关 | `--print-state-after`(在所有注入**之后**再 dump 一次状态;`--print-state` 保持"注入前"语义不动,原有脚本的断言不受影响) | **顺手修掉的四个问题**: 1. **标签上的 `×` 与末尾的 `+` 从来没有命中测试**:`TabBar::on_mouse_down` 只报"切换", 两个按钮纯粹是画上去的(用户看到 `×` 一定会去点)。现在关闭按钮优先于切换命中, `+` 也接上了 `cmd::tab_new`;命令 ID 改用 `commands.h` 的常量,不再硬编码 `1000+i`。 2. **多处 `set_tabs({单个标签})` 会把整张标签列表替换成一个**:编辑一次、保存一次、 撤销一次之后,别的标签就"消失"了。全部改走 `sync_tabs()`。 3. **`--open=a|b|c` 只开出第一张**:循环直接迭代 `open_path_`,而 `open_document()` 内部 会把 `open_path_` 改写成当前文件(供标题/保存用)——迭代源被被调方改写,列表第一轮就没了。 现在先拷一份再迭代。 4. **保存确认对话框的三个命令一律 `DestroyWindow`**:多标签下"保存/不保存"必须作用于 被关的那个标签(`pending_close_tab_`:`>=0` 关该标签、`-2` 关全部、`-1` 关窗口)。 > ⚠️ **设计文档自身的冲突**:`Ctrl+1` 在 §12.1 是"切换到第 1 个标签"、在 §12.2 是"实际大小 100%"。 > 取舍:**有多个标签时按 §12.1 切标签,只有一个标签时仍是 §12.2 的 100%**,两种用法都不丢。 **回归证据**(`tools/check-tabs.ps1`,21 项断言全绿): | 断言 | 结果 | |---|---| | 多文件打开 | `--open=a\|b\|c` → `count=3 active=2`(打开最后一个) | | 单文档隐藏 | `count=1 visible=0`,画布上移到 `canvas.t=104`(标题栏 32 + 菜单 28 + 工具 44) | | 切换 | 点第 2 个标签 → `active=1`;`Ctrl+Tab`×3 循环回原标签;`Ctrl+Shift+Tab` 反向;`Ctrl+1` → `active=0` | | 关闭 | 点 `×` → "关了 #2 alpha-400x300.png,剩 2 个";`Ctrl+W`(干净)→ 剩 2;脏标签 → 先问(标签数仍 3)→ 点"不保存"(`cmd=5031`)→ 剩 2 | | 关闭全部 | `Ctrl+Shift+W`(脏)→ 先问 → `count=0`,画布回到空状态(`size=0x0`) | | 各标签独立 | `#2 zoom=1.8700` vs `#3 zoom=1.0000`(各自的 fit/actual);切走再切回 tab3 仍是 **1.0000**(没有被重新 fit) | | 标注隔离 | 在 tab3 加标注后切到 tab1:活动标签 `[anno-screen]` **0 行**,而 `tab=2 dirty=1`、`tab=0 dirty=0` | | `+` 按钮 | 点击标签条末尾 → `[tab] 新建标签页` | | 全量回归 | 标签 21 + 幻灯片 21 + 标题栏 18 + 选区 33 + 对话框 20 项断言,`--self-test` 4/4 全绿 | ![标签条](shots/tabs/20-tabs-zoom.png) ### P1 修复(第十八批:标签条溢出) **现象**:图片开多几张之后,**多标签被遮住了一部分**——最后一个标签被窗口右边缘切掉一半, 标签条末尾的 `[+]` 直接被推出窗口(点了没反应),而越界的标签还会盖到右侧检查器上。 **根因**:`TabBar::layout()` 把标签从 `area.l` 一个接一个排开,**既不看 `area` 的右边界、 也不给末尾的 `[+]` 留位置**:标签总宽一旦超过窗口宽,整条标签带就往窗口外长。 (单文档时标签条高度为 0,所以这个问题只在多标签时暴露。) | 落点 | 做法 | |---|---| | 布局 | `[+]` 永远钉在可视区右端;剩下的宽度才分给标签。放不下就**只标记放得进的那几个**为可见(`rects_[i].visible`),其余既不绘制也不命中 | | 溢出导航 | 有标签被挡在外面时,`[+]` 左侧出现 `←` / `→`(各 32 DIP):点一下滑一格;标签条上滚轮也横滚(没溢出时不拦截,滚轮仍归画布缩放) | | 活动标签 | 切标签(`Ctrl+Tab` / `Ctrl+1..9` / 点标签)时把活动标签滑进可视区。**只做一次**——每次 layout 都做的话会和用户手动滚动打架:按 `←` 把前面的标签滑出来,下一帧又被拉回活动标签,表现为"箭头点了没反应" | | 关闭按钮 | 改成 §6.3 的 **hover 关闭**:常显时它给每个标签永久扣掉 27 DIP,最窄的标签里文件名只剩 `qua…`(激活标签还要再让出一格状态图标) | | 越界防护 | `paint_ui` 里标签条与其余面板一样 `push_clip(bounds_)`,越界内容一律拦掉 | | 图标 | 新增 `icons::ChevronLeft`(`ChevronRight/Down` 没法靠变换翻转——`paint` 只吃目标矩形) | ![标签条溢出](shots/tabs/t17-overflow-fwd.png) **回归证据**(`tools/check-tabs.ps1`,30 项断言全绿;第 6 节为本次新增的 9 项): | 断言 | 结果 | |---|---| | 8 个文件 → 溢出 | `count=8 overflow=1`(1 个标签被挡在可视区外) | | `[+]` 仍在窗口内 | `add=1406..1438`,窗口宽 1438(修复前它被推到 1438 之外) | | 滚动箭头 | `nav_l=1342,1374` `nav_r=1374,1406`(与 `[+]` 并列,互不重叠) | | 不越界 | 可见标签的右边缘 ≤ `nav_l.l`(没有任何标签压到检查器上) | | 活动标签可见 | `active=7` 且 `tab=7 vis=1` | | `←` 能滑回来 | `scroll=1 → 0`,被挡住的 `tab=0` 重新可见(`rect=0..154`) | | `→` 能滑出去 | 点两下 → `scroll=3`,右侧标签真的滑进可视区 | | `Ctrl+8` | 切到最后一个标签时自动滑进可视区(修复前切过去也看不见) | ### P1 修复(第十九批:画布上不再有浮层) **现象**:画布右上角总有一条提示牌压在图片上——先是常驻的「浏览模式 · 滚轮缩放 / 拖动平移」, 改成 2 秒自动消失后,切工具时的「标注模式 · XXX」依旧会短暂盖住正在看的内容。 看图软件最不该做的事就是往图上盖字。 **根因**:§4.3 当初把徽标定义成"浮动徽标(当前工具 + 操作提示)",于是**所有**提示都挤到 图片右上角内侧 12px 那一块:它既是常驻提示、又是即时反馈,两种诉求都落在"图片上面"。 而这些信息状态栏中段本来就有(§6.7 的中段提示区就是干这个的)。 | 落点 | 做法 | |---|---| | 画布不放浮层 | `CanvasView` 的浮动徽标整套删掉(`set_badge()` / `badge_` / 绘制代码)——不留无人调用的 API,画布上只剩图片与标注叠加层 | | 提示全走状态栏 | 切工具(选择 / 抓手 / 取色器 / 套索、12 个标注工具)、编辑结果、幻灯片播放 / 暂停 / 退出、来自剪贴板、没有打开的图片,全部写状态栏中段;工具名悬停工具条还有 tooltip | | 顺手修掉一处提示被吞 | `after_edit()` / `after_anno_edit()` 是"先 `set_message()` 再 `refresh_status()`",而 `refresh_status()` 末尾会把中段刷成"滚轮缩放…/EXIF"——**编辑结果从来没在状态栏显示过**(以前靠徽标兜着,徽标一去就露出来了)。改成先 `refresh_status()`、再写结果,与 `toggle_slideshow()` 里早已注明的顺序一致 | | 取色器 / 套索 | 这两个工具只有选中状态、没有实现,状态栏直说「尚未实现(当前等同浏览模式)」,不再只显示一个工具名 | **验证**(`--capture` 逐张截图 + `--clicks` / `--keys` 注入真实命令路径): | 断言 | 结果 | |---|---| | 浏览模式画面干净 | `--open=… --capture=…` 的截图里图片上没有任何文字(修复前右上角有「浏览模式 · 滚轮缩放 / 拖动平移」) | | 切标注工具不留浮层 | `--keys=R`(矩形)截图:图片干净,状态栏中段为「拖动绘制「矩形」 · Tab 隐藏标注」 | | 切浏览工具有说法 | `--keys=V` →「点选 / 拖动已有标注 · 方向键微调 · Tab 隐藏标注」;`--keys=I` →「「取色器」尚未实现(当前等同浏览模式)」 | | 编辑结果回到状态栏 | 点工具条「向右旋转 90°」(`--clicks=289;83`)→ 状态栏「已向右旋转 90° · Ctrl+Z 撤销 Ctrl+S 保存」(修复前这里显示的是"滚轮缩放…",结果只在徽标上) | | 全量回归 | 标题栏 22 + 选区 34 + 对话框 20 + 幻灯片 22 + 标签 30 项断言,`PASS`/**0 FAIL** | 修复前(提示牌压在图片右上角)→ 修复后(图片上什么都没有): ![修复前:浏览模式徽标压住图片](shots/badge/01-before-badge.png) ![修复后:画布上没有任何浮层](shots/badge/02-after-clean.png) 工具切换与编辑结果的反馈改由状态栏中段承担(截图自上而下:切「选择 / 移动」、切「矩形」、 切「取色器」、点「向右旋转 90°」之后的提示): ![状态栏中段的提示(选择 / 矩形 / 取色器 / 旋转结果)](shots/badge/statusbar-strips.png) > 设计文档同步:§4.3 的"浮动徽标"一条改为「**画布上不放任何浮层**」, > §3.1 布局图里的徽标框、§5.2 字体表里的"画布徽标"一行一并去掉。 ### P1 修复(第二十批:检查器页签切不出去) **现象**(用户反馈):右侧检查器的「属性 / 图层 / 信息 / 历史」切换几次之后就再也切不动了, 连悬停高亮都没有。实测"几次"其实是**一次**:只要停到「图层」页,四个页签立即全部失效。 **根因**:`Inspector::on_mouse_down()` 一进来就是分页判断—— ```cpp if (tab_ == 1) { // 图层页:眼睛 / 行的命中 for (…) { … } return; // ← 页签头也被这句一起 return 掉了 } for (…) { if (tab_rects_[i].contains(x, y)) set_tab(i); } // ← 只在非图层页才会走到 ``` 页签头在内容区**之上**,与"当前是哪个页签"无关,但它排在了图层页分支**之后**。 于是停在图层页时点页签:命中测试 `hit_test()` 走的是同一个 `tab_ == 1` 早退分支 (连 `hot_tab_` 都不再更新,所以悬停也没高亮),点击又被 `on_mouse_down` 的早退吃掉。 在图层页点任何地方都只会在"图层行"里找,找不到就 `return`——**没有出口**。 **修法**:页签头命中提成 `tab_header_at()`,`hit_test()` 与 `on_mouse_down()` 都**先**问它; 图层页的行命中退到它之后。这样页签头永远优先于所有分页内容。 | 落点 | 做法 | |---|---| | 页签优先 | 新增 `Inspector::tab_header_at(x,y)`(返回页签下标,未命中 -1);`hit_test()` 里先更新 `hot_tab_` 并返回,再进图层页分支 | | 点击优先 | `on_mouse_down()` 第一句就是 `if (tab_header_at(...) >= 0) { set_tab(...); return; }` | | 顺手修掉"命中矩形依赖画过一帧" | 图层行的行 / 眼睛 / 锁热区以前在 `paint_layers()` 里 `push_back`,也就是说**没绘制过就没有热区**(启动即注入、切页签后第一帧之前都点不中)。现在抽成 `compute_layer_rows()`,在 `layout()` 末尾算好,绘制与命中读同一份几何 | | 顺手修掉 `on_command` 的越界 | `30000+i` / `31000+i` 直接 `annotations()[i]`,索引越界就是 UB;现在先判范围 | | 注入开关的顺序 | `--inspector=N` 原先排在 `--post-clicks` **之后**,会把注入点出来的页签重新盖回去(表现为"点了页签,`--print-state-after` 里还是起始页")。移到所有输入注入之前——它表达的是"起始页签" | | 新增自检输出 | `--print-state` / `--print-state-after` 打印 `[inspector] tab=N head_bottom=… tabs=4 collapse=l,t,r,b visible=0/1` 与四条 `[inspector-tab] i=… rect=l,t,r,b`(回归脚本据此算点击坐标,不从截图反推);图层命令补 `[layer] eye/select` 日志,让"点了眼睛"与"点了行"可分辨 | | 页签头右端的 chevron 接上命令 | 原型 `#inspector` 里它是 `data-tip="收起面板"` 的 24×24 按钮,搬到实现里**只画不算**——点了没反应,用户直接来问"这个 `>` 是干什么的"。现在它是真按钮:`panel_collapse_inspector` 与视图菜单「检查器」共用同一个开关,悬停有底色与提示(气泡锚在那个 24×24 小方块上,不是面板中间) | | 顺带修掉状态栏提示被吞 | 折叠时写的中段提示又踩了同一个坑:`refresh_status()` 末尾会把中段刷成"滚轮缩放…/EXIF",先写提示再 `refresh_status()` 等于没写。改成先 `refresh_status()`、再写提示(与第十九批在编辑结果上的修法一致) | | **沉浸模式"进去出不来"** | 用户反馈"点视图-显示检查器后菜单栏/工具栏等都消失了"。逐像素比对确认那是**沉浸模式**的画面(唯一会把全部界面元素藏起来的状态),而它当时是**真的出不来**:唯一的提示写在状态栏中段,而沉浸模式**把状态栏一起藏了**——提示等于没写;退出键只有 `Shift+Tab`,`Esc` 完全没反应。现在:进入时在画布上显示 4 秒提示(整个界面都没了,提示只能画在画布上)、`Esc` 也能退出(与 F11 全屏一致)、状态栏与菜单项都点明两种退出方式 | | 菜单项派发可核对 | 用户坚持"点的是显示检查器",而代码里那条路径在物理上到不了 `immersive_`。新增 `[menu-item] menu=… click=x,y popup=… y=… label="…" cmd=… enabled=…` 日志(每次点中菜单项都记一行),另在 `--print-state` 里打印 `[menu-root]` 与 `[menu-item-geom]` 两条权威几何 | | **菜单项命中几何没跟着 DPI 缩放**(真正的根因) | `MenuBar::paint()` 用 `L.px(28)` 画行,而 `MenuBar::on_mouse_down()` 判命中用的是**固定 28**。100% 缩放下两者相等、一切正常;**任何非 100% 缩放下"画在哪"与"点在哪"就对不上了**:150% 时视觉行高 42 px、命中却只认前 28 px,于是每一行只有上面约 2/3 生效,点一行下半部分会派发到下一项,一路错位。用户点「显示检查器」(150% 下 `269.5..297.5`)的下半部分,亮的与执行的都变成了下面那一行——这正是"点显示检查器却进了沉浸模式"。修法:行高/分隔/上内边距在 `layout()` 里按 DPI 算好(`item_height_` 等成员),绘制与命中**读同一份** | | 修饰键守卫 | `Shift+Tab` 正是沉浸模式的快捷键,而菜单里紧挨「显示检查器」的下一项就是「沉浸模式」。按住 `Shift`/`Ctrl` 时现在**不执行**任何菜单项(菜单留着不关,也不产生破坏性动作) | | **弹出层溢出:放不下就裁 + 滚轮滚** | `layout()` 现在把弹出层裁到"菜单栏以下、状态栏以上"(`menu_->set_visible_bottom()` 每帧由 App 传入),多出来的部分用滚轮滚。修复前不裁:200% 缩放下「视图」菜单完整高 792 px、可用只有 747 px,最后一项被状态栏压住,**既看不全也难点** | | **弹出层优先于顶层项** | 弹出层顶边比菜单栏底边高 3 DIP(设计上要贴着菜单栏),于是最上面一两行落在菜单栏矩形里。原来先判顶层项 → 点这两行被当成"再点一次视图",菜单直接关掉(100% 缩放下必现,前两项"点不动")。现在盖住的部分算弹出层的(位置上它本来就画在上面) | | `--wheel-at=x;y` | 滚轮注入原先固定在窗口中心;菜单是按**光标位置**决定吃不吃滚轮的,所以必须能指定注入点。顺带修掉 `WM_MOUSEWHEEL` 的坐标约定:它的 `lParam` 是**屏幕坐标**(处理器里 `ScreenToClient`),注入客户区坐标会变成负值、谁也命中不了 | | **检查器显隐不再落盘** | 用户接着问"要怎样重新显示属性/图层/信息/历史"——因为旧版本把「收起检查器」写进了 `config.json` 的 `show_inspector`,**下次启动就少一块**,而用户根本不知道自己什么时候关的(设置对话框里那个开关点一下、再按"确定"就永久生效)。现在:检查器显隐是随手的视图开关(视图菜单 / 页签头折叠按钮),`AppConfig` 里删掉该字段、启动时恒为可见、设置对话框里删掉该开关;旧配置里的 `show_inspector` 被安静忽略(解析器按 key 查找,多余键无副作用) | **回归证据**(`tools/check-inspector.ps1` 28 项 + `tools/check-menu.ps1` 11 项,全绿): | 断言 | 结果 | |---|---| | **菜单行高与绘制一致(跨 5 个 DPI)** | 100% → 28 px、125% → 35、150% → 42、175% → 49、200% → 56,逐行核对(旧代码在非 100% 下恒为 28,此断言必挂) | | **菜单 192 次点击都派发自己那一行** | 5 个 DPI × 13 行 × 每行上/中/下三个位置(放不下的行归下面那条);含 150% 下每行的下半部分(修复前必然错位) | | **150% 下点「显示检查器」整行都不会进沉浸模式** | 从行顶到行底每 2 px 点一次,`immersive` 始终为 0(用户报的就是这条) | | **200% 下菜单溢出时被裁到可视区** | 完整下沿 `906` → 裁到 `867`(状态栏上沿),可滚 `39 px` | | **滚轮滚到底后最后一项可点** | `scroll=39/39`,末项「主题:跟随系统」`y=801..857` 完全落在弹出层内,点击派发的就是它 | | 页签几何(程序给出) | 四个页签各 `32x24`,`[1176,1208] [1210,1242] [1244,1276] [1278,1310]`,互不重叠,页签底 `133` ≤ 内容上沿 `138` | | **任意起点 → 任意终点(4×4 = 16 条路径)** | 全部到达目标页签(修复前 **0/12**:页签完全没反应) | | 图层页 → 属性 / 信息 / 历史 | 这 3 条正是修复前必挂的路径 | | 点图层行 | `[layer] select idx=0 name=矩形标注`、`canvas.select=0`,页签仍是图层(没有误切) | | 点图层行的眼睛 | `[layer] eye idx=0 visible=1->0`,页签仍是图层 | | 切页签不进撤销栈 | 连点 5 次后 `hist=1`(只有标注提交那一笔) | | 折叠按钮热区 | `collapse=1408,109,1432,133`(24×24 贴右内边距),左沿 `1408` ≥ 末页签右沿 `1310`(不重叠、不会误切页签) | | 点折叠按钮 | `visible=1 → 0`,检查器真的收起(`shots/inspector/collapsed.png`,画布铺满右侧) | | 收起后的状态栏 | 中段提示变成「已收起右侧检查器(视图 ▸ 检查器 可再打开)」 | | 悬停提示 | 气泡落在按钮正下方(`shots/inspector/collapse-hover.png`) | | **沉浸模式有可见退路** | 进入时画布上出现「沉浸模式 · Esc 或 Shift+Tab 恢复界面」(顶部 80px 内白色采样点 404 个;修复前整帧只有画布,**一个字都没有**) | | **沉浸模式下 Esc 能退出** | `immersive=1 → 0`;Shift+Tab 老路径同样可退 | | **检查器不再被配置记住** | 旧配置里写死 `"show_inspector": false` 时启动仍然显示检查器(`visible=1`);收起后再"保存设置"也不会把隐藏状态写回配置 | | 四个页签截图 | 四张互不相同(`shots/inspector/tab{0..3}-*.png`) | | 全量回归 | 检查器 28 + 标签 30 + 选区 34 + 标题栏 22 + 对话框 20 + 幻灯片 22 项断言,0 FAIL | 新增开关:`--no-file-dialog`(不弹系统打开/保存框,只记录意图并当作取消——见文末说明)。 > **与设计稿的偏差(有意为之)**:§6.5 只规定了"4 个页签复用同一区域",页签头右端 > 并没有折叠按钮;它与 §4.2 的断点折叠(820–980 收成右侧抽屉)也不是一回事。 > 这个 chevron 是原型独有的,本次把它**从装饰改成真按钮**而不是删掉,理由是: > 既然已经画出来了,用户就会去点;点了没反应比没有更糟。 > 顺带修掉一个**一直挂死整套回归**的问题:`tools/check-tabs.ps1` 第 5 节点标签条末尾的 `+` > 会打开 `GetOpenFileNameW` 的系统模态对话框,而它**在消息循环启动之前就自己泵消息**, > `--post-keys=Esc` 那条注入排在它返回之后、永远执行不到——脚本于是永久挂住 > (本机实测:进程停在对话框上 150s 不退出;把源码回到修复前同样挂,与本次改动无关)。 > 处理办法:三个系统文件框(打开 / 导出 / 另存为)合并到一个入口 `App::pick_system_path()`, > 并新增 `--no-file-dialog`——脚本模式**不弹框**,只把"本来要弹什么"写进日志并当作"用户取消", > 于是"点 `+` 真的走到了新建标签页这条路"可以在不弹框的前提下断言;框里选哪个文件本来就无法脚本化 > (README 早有注明),所以这里损失的可测性为零。系统模态框的真实路径仍按原样保留。 ### P1 修复(第二十一批:箭头工具一拖就退出) **现象**(用户反馈):选中「箭头」标注工具后刚开始拖,程序**立刻退出**——没有报错框、没有卡顿,就是不见了。 **根因不在交互层,而是画箭头时一句 `std::clamp` 的取值区间反了**。自检日志 (`%TEMP%\duckimg-selftest.log`,未处理异常过滤器写的符号化调用栈)一步到位: ``` [menu-item] menu=4 click=431,290 popup=351,86,711,747 scroll=0 label="箭头" cmd=204 enabled=1 [tool] anno id=204 name=箭头 implemented=1 [crash] code=0x80000003 addr=00007FFD7AB85D32 [crash] VCrtDbgReportA+0x192 [crash] CrtDbgReport+0x60 [crash] std::clamp > (...\include\algorithm:10882) [crash] std::clamp (...\include\algorithm:10909) [crash] duck::paint_annotation (src\core\annotations.cpp:334) [crash] duck::CanvasView::paint_annotations (src\ui\panels2.cpp:843) ``` `paint_annotation` 的箭头分支原来是: ```cpp const float head = std::clamp(a.style.width * 4.0f / z, 8.0f / z, len * 0.6f); // ^^^^^^^^^ 下界 ^^^^^^^^^ 上界 ``` 下界是「屏幕上至少 8 px」,上界是「不超过杆长的 60%」。**这两个界会互相越过**: `8/z > 0.6·len` 等价于 `len < 13.3` 屏幕像素。也就是说——**只要拖出来的箭头短于 13.3 屏幕像素 (随手一点一拖就是这个量级),上界就掉到了下界下面**。而 `std::clamp(v, lo, hi)` 的**前置条件是 `lo <= hi`**,违反即 UB:MSVC 的 checked STL 直接 `_STL_VERIFY` → `__debugbreak()` (`code=0x80000003` 就是这个),Release 下则静默取到 `lo` 一侧的错值、箭头三角形整个翻面。 用户那次会话的 `zoom=1.9767` 让阈值只剩 **4.05 屏幕像素**——任何一次箭头拖拽在中途必然踩中, 所以现象是"一拖就退",而不是"拖到某个长度才退"。 | 落点 | 做法 | |---|---| | **箭头头部取值** | 改成 `min/max` 形式:`max_head = max(min_head, len*0.6)`、`head = min(max(宽*4/z, min_head), max_head)`。语义处处有定义,且 `lo <= hi` 时与 `clamp` 逐位一致;头部永远不会超过杆长 | | **裁剪确认条的 y 夹取** | 定位那句 `std::clamp(bar_y, bounds_.t+8, bounds_.b-bar_h-8)` 是同一类写法:画布比"上下各留 8px + 一条确认条"更矮时两个界同样会反过来。加一条 `bar_y_hi >= bar_y_lo` 的守卫,不够高就贴顶(宁可压住内容,也不能让程序退出) | | **不引入"只在 Debug 才有效"的检查** | 没有加 `DUCK_ASSERT` 之类——那种断言在 Release 里是空语句,而用户装的就是 Release。修的是取值本身,两种配置下都正确 | **回归证据**(新增 `tools/check-arrow.ps1`,12 项断言全绿;Debug 与 Release 各跑一遍): 配套三个新文件,各管一头:`tools/check-arrow.ps1`(4 节 12 项断言的总入口)、 `tools/probe_arrow_head.cpp`(纯逻辑探针,独立于界面与 D2D)、 `tools/arrow_diff.py`(导出图的像素比对,被第 4 节调用)。 | 断言 | 结果 | |---|---| | **纯逻辑:取值不变量** | `tools/probe_arrow_head.cpp` 用 **checked STL** 编译(`/MDd /D_ITERATOR_DEBUG_LEVEL=2`),12 个用例覆盖 zoom=0.377/1.977/1.0/1e-6、len=0…5000:`fixed head` 全部满足「≤0.6·len 或 ≤8/z」 | | **纯逻辑:旧写法确实会炸** | 同一个探针并排跑**修复前的 clamp 写法**:在第一条用例 `point-drag-3.6px@0.377` 上中断,**退出码 0x80000003**——与报障的崩溃码逐位一致。这条断言是"修复真的对症"的证据,而不只是"改完不崩了" | | **真实绘制路径** | `--annotate=arrow:1000;800;1002;802`(2.8 图像像素)与 `…;1000;801`(1 像素)都 `exit=0`、日志 0 条 `[crash]`(`paint_annotation` 只有一条路径,Debug 下旧代码这里必崩) | | **真实拖拽路径** | `--keys=A` 切箭头工具 + `--drag` 注入 3.6 屏幕像素点拖(按下 → 6 次 `WM_MOUSEMOVE` → 抬起,正是用户的操作序列):`annos=1`、`exit=0`;`--drag` 原地不动(0 位移)时 `annos=0`(误触过滤仍生效)且同样不崩 | | **像素级:箭头真的画出来了** | 导出相对无标注底图有 56 个差异像素,包围盒 `992,796,1004,804` 精确落在标注处(图像坐标 1000,800 → 1002,802);正常长度箭头 4825 个差异像素、头部最粗一行 **14 px**(默认线宽 3 px)——修复前 Release 的翻面三角在这里会退化成一条线 | | 全量回归 | 箭头 12 + 检查器 28 + 菜单 11 + 标签 30 + 选区 34 + 标题栏 22 + 对话框 20 + 幻灯片 22,0 FAIL | > **顺手记下一个坑**:含中文的 `.ps1` **必须存成 UTF-8 with BOM**。 > Windows PowerShell 5.1 对无 BOM 的脚本按 ANSI 解码,中文注释会被解坏,进而吃掉行尾的 `|` 或引号 > ——报出来的却是**别的行**"缺少右括号"。本次在这上面绕了远路(同一份文本 > `Parser::ParseFile` 报 12 个错、`Parser::ParseInput` 却是 0 个错,就是两种解码方式的铁证); > 日志里 `Check '鎷栨嫿-涓嶅穿婧?` 这种乱码本身就是"解码错了"的信号。 ### P1 修复(第二十二批:文字标注一按就退出) **现象**(用户反馈):选中「文字」标注工具后在画布上点一下,程序**立刻退出**—— 和上一批的箭头是同一个观感(没有报错框、没有卡顿),但根因完全不同。 **根因**:`App::prompt_text()` 里那个临时模态输入框的窗口过程,在**函数最开头**就预取了 `GWLP_USERDATA`,而那个值要到 `WM_CREATE` 里才被写入: ```cpp static LRESULT CALLBACK proc(HWND h, UINT m, WPARAM w, LPARAM l) { auto* c = reinterpret_cast(::GetWindowLongPtrW(h, GWLP_USERDATA)); // ← 恒为 nullptr switch (m) { case WM_CREATE: { auto* cs = reinterpret_cast(l); ::SetWindowLongPtrW(h, GWLP_USERDATA, ...cs->lpCreateParams); // ← 写得太晚 ::CreateWindowExW(0, L"STATIC", c->label, ...); // ← 空指针解引用 ``` `WM_CREATE` 是窗口收到的**第一条带 lParam 的消息**,此刻 `GWLP_USERDATA` 还是窗口的初值 0, 所以函数开头取到的 `c` 必然是 `nullptr`;下面那行 `SetWindowLongPtr` 写得再对也救不回来 ——`c` 早就读完了。于是 `c->label` 直接崩在 `0xC0000005`。 `prompt_text` 是**五处共用**的入口:添加文字标注 / 缩放图像 / 任意角度旋转 / 调色 / 文字水印。 所以这五条路都是"第一次用就崩",只是文字标注最容易被点到。 **崩溃日志**(`%TEMP%\duckimg-selftest.log`,异常过滤器写的符号化调用栈): ``` [tool] anno id=207 name=文字 implemented=1 [crash] code=0xC0000005 addr=00007FF734089E05 [crash] `duck::App::prompt_text'::`2'::Local::proc (src\app\app.cpp:1511) [crash] CallWindowProcW+0x853 [crash] User32InitializeImmEntryTable+0x180E [crash] NtUserCreateWindowEx+0x14 [crash] CreateWindowExW+0x6EA [crash] duck::App::prompt_text (src\app\app.cpp:1554) [crash] `duck::App::build_ui'::`2'::::operator() (src\app\app.cpp:1011) [crash] duck::CanvasView::on_mouse_down (src\ui\panels2.cpp:1238) ``` | 落点 | 做法 | |---|---| | **`Ctx` 一律从 `CREATESTRUCTW` 取** | `WM_CREATE` 里 `auto* c = cs->lpCreateParams;`,不再依赖 `GWLP_USERDATA`。这是 Win32 的标准写法:创建参数本来就走 `CREATESTRUCT`,把它存进 userdata 只是为了给**后续**消息用 | | **`GWLP_USERDATA` 只在后续消息里读** | `WM_COMMAND` / `WM_CLOSE` 一定发生在 `WM_CREATE` 之后,这时读 userdata 才安全;两处都补了 `if (!c)` 守卫 | | **不靠"读回来验证"绕过** | 排查时试过"设完立刻 `GetWindowLongPtr` 读回、用读回值继续",那确实能不崩——但它掩盖了真正的问题(局部变量 `c` 早已过期),换个人重构一次就会复发。现在改成从根上取对来源 | | **补一条可分辨的日志** | `[text] dialog ok=%d got=%s`:输入框"用户取消"与"根本没弹出来"都返回空串,没有这行日志,回归脚本只能看到"没生成标注",分不清是正常操作还是缺陷 | **回归证据**(当时新增的 `tools/check-text-dialog.ps1`,走用户的真实路径): | 断言 | 结果 | |---|---| | **切文字工具 + 点画布后进程存活** | `[tool] anno id=207` → 进程不退出(修复前此处必 `0xC0000005`) | | **全程无 `[crash]` 记录** | 结束后日志里 0 条 `[crash] code=` | | 输入框内部的断言 | 未能覆盖,原因见下;**下一批把这条路整个换掉了**,那部分改由 `tools/check-anno-text.ps1` 完整覆盖 | > **本次没能覆盖「输入框内部」这一步**:排查用的这台机器在长跑回归会话里**桌面堆耗尽**了 > ——同一个会话启动上百次 GUI 之后,应用自己都创建不出原生控件(`CreateWindowExW` 返回 > `ERROR_INVALID_WINDOW_HANDLE`,连一个独立的控制台探针也创建不出任何窗口, > 报 `ERROR_CANNOT_FIND_WND_CLASS`)。这条与本应用代码无关:同一台机器上, > 本批之前的所有回归都是全绿的。当时给脚本加了「环境受限时改报 SKIP」的区分, > 避免把环境问题伪装成断言失败。 > > **下一批(第二十三批)把结论又推进了一步**:既然这台机器创建不出原生控件,而文字标注的输入 > **只能**靠原生控件——那就说明「押在原生控件上」本身就是缺陷。于是文字标注改走自绘对话框, > 上面这条 SKIP 连同那个脚本一并删掉了:`tools/check-anno-text.ps1` 现在能在这台机器上 > **完整**验证「弹出输入框 → 打字 → 提交 → 画出来」。 > **两批崩溃的对比值得记一笔**:箭头(第二十一批)是"取值区间反了"的 UB,只在 checked STL 下炸; > 文字(本批)是**确定性**的空指针解引用,Debug / Release 都必崩。 > 两次都是"用户报的是界面现象、根因在渲染/窗口层",两次都靠 `%TEMP%\duckimg-selftest.log` > 里那条符号化调用栈一步定位——这个异常过滤器是本项目最值钱的基础设施之一。 ### P1 修复(第二十三批:文字标注没有输入的地方) **现象**(用户反馈):点了「文字」标注工具之后**没有让输入文本的地方**。 这一批有**两层**原因,第二层比第一层更要紧。 **第一层(会崩)**:原来的输入框是 `App::prompt_text()` —— 一个**真正的 Win32 窗口** (窗口类 `DuckImgPrompt` + `STATIC` / `EDIT` / `BUTTON` 子控件)。那个窗口过程在函数最开头 预取 `GWLP_USERDATA`,而该值要到 `WM_CREATE` 里才写入: ```cpp static LRESULT CALLBACK proc(HWND h, UINT m, WPARAM w, LPARAM l) { auto* c = reinterpret_cast(::GetWindowLongPtrW(h, GWLP_USERDATA)); // ← 恒为 nullptr switch (m) { case WM_CREATE: { ::SetWindowLongPtrW(h, GWLP_USERDATA, ...cs->lpCreateParams); // ← 写得太晚 ::CreateWindowExW(0, L"STATIC", c->label, ...); // ← 空指针解引用 ``` `WM_CREATE` 是窗口收到的**第一条带 lParam 的消息**,此刻 `GWLP_USERDATA` 还是初值 0, 所以开头取到的 `c` 必然是 `nullptr`,下面的 `SetWindowLongPtr` 写得再对也救不回来。 点一下画布就是 `0xC0000005` 退出(崩溃栈见 `%TEMP%\duckimg-selftest.log`)。 **第二层(更根本,也是用户实际看到的现象)**:把"输入文字"这件事押在**原生控件**上。 实测本机在长跑回归之后桌面堆耗尽,`CreateWindowExW` 直接返回失败——于是 `prompt_text` 静静地返回 false,**文字标注彻底没有输入的地方**。同一个函数还是 「缩放图像 / 任意角度旋转 / 调色 / 文字水印」的入口,那些路径同样受累。 | 落点 | 做法 | |---|---| | **文字标注改走自绘对话框** | 新增 `cmd::dlg_anno_text_ok` 与 `App::show_anno_text_dialog()`:用本项目自己的 `Dialog` + `DlgRowKind::text`(命名规则已在用,`check-dialog-text.ps1` 20 项断言覆盖)。它与画布、面板共用同一套 Gfx 绘制与按键 / IME 分发,**不依赖任何原生控件** | | **锚点寄存** | 画布点下时把锚点存进 `pending_anno_text_` / `pending_anno_text_at_`(用独立 bool,不用哨兵坐标——`(0,0)` 是合法图像坐标),用户确认后才 `make_annotation` + `anno_add`(一次可撤销编辑);取消则丢弃 | | **空内容拦在提交前** | 只有空白字符的输入不落笔:文字标注只有一个锚点,空内容会留下一个"看不见但占一层"的标注 | | **原生窗口过程也修掉** | `Ctx` 一律从 `CREATESTRUCTW` 取,`GWLP_USERDATA` 只在 `WM_COMMAND` / `WM_CLOSE` 里读。另外四处(缩放图像 / 旋转 / 调色 / 水印)仍在用 `prompt_text`,**下一次要一并迁到自绘对话框**(见文末待办) | | **让程序给出按钮几何** | `Dialog::debug_state()` 增印 `[dlg-btn] cmd=… rect=l,t,r,b`:注入式回归要点「添加 / 确定 / 开始处理」时,坐标必须取自程序算出来的那一份,而不是在测试里复刻布局公式(按钮宽 = 字数×7.4 + 28,改一个字就变) | **回归证据**(新增 `tools/check-anno-text.ps1`,12 项断言全绿,全自动走用户路径): | 断言 | 结果 | |---|---| | 切文字工具 + 画布单击 → 弹出对话框 | `[text] 对话框已打开 at 1338,1166` | | **打开即可直接打字**(焦点已在文本行) | `[dlg] open=1 focus_row=0` | | 打字走真实 `WM_CHAR` | `[type] WM_CHAR "测试文字一二三" dlg=1 focus_row=0` | | 点「添加」命中 | `[dlg-click] … hit=yes cmd=5120`(矩形 `890,509,933,539` 由程序给出) | | 标注落进文档、内容就是输入的那串 | `[text] 提交 … len=7 name=文字「测试文字一二三」` | | 落笔位置 = 点击处 | `[anno-screen] #0 image=1338,1166,…`(点击处 1338,1166) | | Esc 取消不留痕 | Esc 之后 `dlg=0`、无提交行、标注数 0 | | 空内容被拦住 | `[text] 内容为空`、标注数 0 | | 全程无崩溃 | 0 条 `[crash] code=` | | 既有回归 | 对话框文本 20 + 选区 34 + 箭头 17 项,0 FAIL | > **注入顺序踩坑记录**(写这类脚本必看):`App::init` 里的注入次序是**固定**的—— > `clicks → keys → drag → slideshow → post-keys → type → export-to → capture`。 > 所以:点「添加」只能用 `--post-clicks`(`--clicks` 排在 `--type` 之前,那时还没输入内容); > 想"先开对话框再按 Esc"只能用 `--post-keys`; > 而 `--export-to` 跑完会拍平标注并**直接返回**,排在它后面的注入全都不会执行 > (本次为此白跑了两轮,脚本里已写明)。 > **待办(下一批)**:`prompt_text` 还有四个调用点——缩放图像 / 任意角度旋转 / 调色 / > 文字水印。它们与文字标注是同一个入口,在同一类机器上会一起失效。 > 做法与本批一致:各加一个 `Dialog` 分支、把"确认后要做什么"搬进对应的命令处理里 > (参考 `dlg_anno_text_ok` 与批量处理读取命名规则的方式)。 ### P1 修复(第二十四批:另外四个输入框也迁到自绘对话框) 上一批把**文字标注**的输入从原生模态窗口迁到了自绘对话框,并留下一条待办: `prompt_text` 还有四个调用点。本批把它清干净。 **为什么必须一起迁**:那四个(任意角度旋转 / 调色 / 缩放图像 / 文字水印)与文字标注 是**同一个入口** `App::prompt_text`——一个真正的 Win32 窗口(窗口类 `DuckImgPrompt` + `STATIC`/`EDIT`/`BUTTON` 子控件)。所以上一批那台"创建不出原生控件"的机器上, 这四个功能同样是**点了菜单什么都不发生、也没有任何输入的地方**。 留着它们等于留着同一个缺陷的四个副本。 | 落点 | 做法 | |---|---| | **一个通用入口** | 新增 `App::show_text_input_dialog(ok_cmd, title, label, value, placeholder, note)`:标题 + 一行文本 + 说明 + 「取消/确定」。四个功能共用,`Dialog` 的文本行打开即聚焦 | | **命令分段** | `cmd::dialogs_first = 5130`:`dlg_rotate_ok` / `dlg_color_ok` / `dlg_resize_ok` / `dlg_watermark_ok`(每个 +10 留空档)。约定同前:**文本行的 cmd == 主按钮的 cmd**,内容用 `Dialog::text_value()` 读;解析与执行在 `on_command` 的这四个分支里 | | **解析搬进命令分支** | 原来"弹框 → 解析 → 执行"是阻塞式一条直线;现在拆成"弹框"与"确认后执行"两段。参数不合法**不关对话框**,就地改并给状态栏提示(与导出对话框一致) | | **`prompt_text` 退役** | 保留实现但**没有调用者**了,并注明"新功能不要再走这里"。它依赖 `CreateWindowExW` 创建原生控件,在创建不出原生控件的机器上会静默失效 | | **`split_params()` 提出来** | 调色与水印共用"按 `;` / `,` 切分一行参数"的逻辑,提成文件前部的静态函数(`on_command` 要用它,而对话框构造在文件后部) | **顺手修掉的两个真问题**(都是写回归时被逼出来的,不是测试专属): | 问题 | 现象 | 修法 | |---|---|---| | **对话框永远看不到 Ctrl** | `App::handle_message` 里 `mod_down()` 算出来的 `ctrl/shift/alt` 在**对话框分支之后**才算,`dlg_->on_key()` 拿不到 Ctrl——于是 `Ctrl+A` 这类编辑键在真机上也不生效 | 把三个 `mod_down()` 提到 `WM_KEYDOWN` 分支最前面,并把 `ctrl` 作为参数传给 `Dialog::on_key(vk, ctrl)` | | **输入框没有全选 / 清空** | `Dialog` 只支持退格、方向键、Home/End、Tab;改一个长参数只能一个字一个字删 | 加选区模型(`sel_anchor_`)+ `Ctrl+A` 全选、`Ctrl+Back` 与 `Ctrl+Z` 清空整个字段、打字/退格/删除都先作用于选区。**不做通用撤销栈**:这些输入行都是一行参数串,清空比逐次撤销更符合预期 | **回归证据**(新增 `tools/check-dialog-input.ps1`,34 项断言全绿): | 断言 | 结果 | |---|---| | 四个对话框都能打开 | `[dlg-input] open title=任意角度旋转 cmd=5130`(调色 5140 / 缩放 5150 / 水印 5160 同) | | **打开就预填了当前值** | `15` / `0; x; x; x` / `2400,1600` / `DuckImg; 4; 0xFFFFFFFF; 0.55; single` | | 旋转:清空重打 30 → 确定 | `[rotate] free deg=30.00` | | 调色:`0.2;1.5;1.2;x` → 确定 | `[color] b=0.200 c=1.500 s=1.200` | | 缩放:`1200,800` → 确定 | `[resize] target=1200x800 (before=2400x1600)` → `[resize] 完成 doc=1200x800` | | 水印:`WM;5;0xFFFF0000;0.5;tile` → 确定 | `[watermark] text=WM size=5.00 color=0xFFFF0000 tiled=1` | | **非法参数就地改** | `abc` → `[resize] 解析失败`,无编辑,`[dlg] open=1`(对话框还在);`0` → `[rotate] 角度为 0`,同样留在框里 | | **Esc 取消** | 四个都 `open=0` 且 0 条编辑日志 | | 全程无崩溃 | 每个用例 0 条 `[crash] code=` | | 既有回归 | 文字标注 12 + 箭头 17 + 对话框文本 20 + 选区 34 + 检查器 28 + 菜单 11 + 标签 30 + 标题栏 22 + 幻灯片 22,0 FAIL | 新增自检输出:`--print-state-after` 现在**无条件**打印一份 `[dlg]`(含 `open=0`)。 少了它,"Esc 之后对话框关了吗"只能读到注入前的那一份;而只在打开时打印的话, "已关闭"与"从没打开"又分不出来。 > **注入顺序又踩了两个坑**(已写进脚本注释): > 1. `--keys` 的取值是"只取第一次出现"——同一个开关给两次只有第一个生效, > 所以"先 `--keys=Ctrl+R` 开对话框、再 `--keys=Back,Back…` 清空"是**无效**的; > 要清空就用 `Ctrl+Back`,一个键连在开对话框的按键后面。 > 2. `--type` 的值由 `split_edit_script` 按**逗号**分段并 trim 首尾空白, > 所以值里**不能有空格**——有了会在空格处被截断(水印那串只打进去 `测试水印;`, > 参数全丢,排查了半天)。 > 3. `--print-state` 排在所有注入**之前**;要读"注入之后"的状态得用 `--print-state-after`。 ### P1 现状 **已完成并验证** | 项 | 说明 | |---|---| | 构建链路 | CMake + Ninja + MSVC 2022 全自动构建;`tools/build.ps1` 一条命令完成;Debug/Release、x64 | | 渲染后端 | **D3D11 + DXGI 交换链 + `ID2D1DeviceContext`**(不是老的 HwndRenderTarget,原因见下) | | 窗口外壳 | Win32 窗口、Per-Monitor V2 DPI 感知、DWM 深色标题栏/圆角、`WM_DPICHANGED` 重排 | | **自绘标题栏** | §3.1 的 ①:客户区扩展到整窗(`WM_NCCALCSIZE`)、32 DIP 标题栏(矢量图标 + 文件名 + `•` 脏点 + 三个窗口按钮)、`WM_NCHITTEST` 返回 `HTMINBUTTON`/`HTMAXBUTTON`/`HTCLOSE` 保留系统贴靠浮出;最大化客户区 = 显示器工作区(见「第十五批」) | | 设计令牌 | `design/tokens.yaml` 为唯一真源;`src/ui/theme/theme.h` 与之逐项对应,深浅双主题 | | 度量与缩放 | 全部尺寸/字号经 `Layout::px()` 按 `dpi/96 × density` 换算,与设计文档第 4/5 节一致 | | 矢量图标 | **63 个图标全部实现**,24×24 设计空间、按图元(折线/矩形/圆/弧)描述、任意 DPI 矢量绘制 | | 自绘控件库 | `Widget` 基类 + 14 种控件:图标按钮、文字按钮、分段 Chip、复选、开关、滑杆、色板、下拉、数值域、分组标题、标签、分隔线、容器 | | **对话框** | §10.1 通用外壳 + §10.2 的导出 / 设置(左栏 7 分类)/ 关于 / 保存确认 / 另存为 / 批量 / 快捷键一览;行类型 9 种(含**文本输入行**:WM_CHAR + IME + 光标闪烁 + 点击落光标);内容超出 70% 屏高时整页滚动 | | **幻灯片播放** | `F5` 开始 / 空格暂停 / `Esc` 退出,间隔 1–30s(设置里可调、随配置持久化),复用 50ms 定时器;状态栏提示(见「第十六批」「第十九批」) | | **多标签页** | 单窗口多文档:每个标签一个 `Editor`(像素/撤销栈/标注层/视图各自独立),`Ctrl+T`/`Ctrl+W`/`Ctrl+Shift+W`/`Ctrl+Tab`/`Ctrl+1..9`、点 `×` 关闭、脏标签先问、单文档时标签条自动隐藏(见「第十七批」) | | **七大区域** | 菜单栏(含可展开的下拉菜单)/ 主工具栏 / 文档标签页 / 左侧工具栏 / 画布视图区 / 缩略图条 / 右侧检查器(4 个页签)/ 状态栏 | | 交互 | 悬停高亮、按下态、命中测试与事件分发、命令 ID 上报、工具切换、网格开关、缩放档位、菜单开合(Alt / F10 / Esc) | | **工具提示** | 悬停 180ms 后显示「主标题 + 右对齐快捷键」的深色浮层;空间不足自动翻转;由 50ms 定时器推动(鼠标停住也会到点显示) | | **离屏导出** | 同一 DeviceContext 上把一帧渲染到离屏位图再编码 PNG;**不依赖桌面环境**,可用于自动化回归 | | 诊断设施 | 自检日志、未处理异常过滤器(输出符号化调用栈)、`--capture` / `--self-test` / `--dpi` / `--theme` 开关 | **调试与验证开关** ``` DuckImg.exe --render=software 强制软件渲染(WARP) DuckImg.exe --topmost 窗口置顶(便于屏幕抓图核对) DuckImg.exe --theme=light 浅色主题 DuckImg.exe --dpi=1 覆盖系统 DPI 缩放(确定性截图) DuckImg.exe --capture= 渲染一帧到 PNG 后退出 DuckImg.exe --self-test=<目录> 4 种组合(深/浅 × 100%/150%)批量导出 DuckImg.exe --probe-hover=x,y 模拟悬停到该点(验证工具提示渲染) DuckImg.exe --keys=C,Enter 启动时注入按键(走真实 WM_KEYDOWN 路径,再截图/导出) DuckImg.exe --keys=Ctrl+Shift+Z 组合键也支持(注入期间用虚拟修饰键覆盖 GetKeyState) DuckImg.exe --clicks=183;14,250;42 注入点击(客户区坐标;顺序:右键 → 左键 → 按键) DuckImg.exe --rclicks=500;350 注入右键(打开上下文菜单) DuckImg.exe --drag=395;260,470;330 注入拖拽:按下 → 6 次移动 → 抬起(多段用 | 分隔) DuckImg.exe --drag=Shift+549;240,619;259 拖拽时按住修饰键(Shift 吸附 15° / Ctrl / Alt) DuckImg.exe --post-keys=Ctrl+Z 在**拖拽之后**按键(拖完手柄再撤销/删除) DuckImg.exe --probe-cursor=395;260 报告该点用的画布光标(手柄 → SIZENWSE 等) DuckImg.exe --print-state 打印画布变换 + 每个标注的图像/屏幕矩形(注入后调用) DuckImg.exe --type={n}-{name} 注入 WM_CHAR(对话框输入框:命名规则等) DuckImg.exe --type-ime=中文 注入 IME 提交路径(WM_IME_CHAR) DuckImg.exe --post-clicks=600;405 在滚轮之后再点击("滚到底再点最后一行") DuckImg.exe --probe-nchit=cl:1369;16 把点送进 WM_NCHITTEST 读命中码(HTMAXBUTTON 等) DuckImg.exe --window=max,restore 走 WM_SYSCOMMAND 切窗口状态(验证最大化时的客户区) DuckImg.exe --slideshow=start,pause 幻灯片播放(与 F5 / 空格同一条路径) DuckImg.exe --slideshow-tick=3100 按 50ms 步进推进幻灯片定时器(到点翻页) DuckImg.exe --batch-pattern={n}-{name} 批量命名规则({name} {n} {ext} {date}) DuckImg.exe --copy-to=view:out.png 把 Ctrl+C 会复制的那张图写到文件(不依赖剪贴板) DuckImg.exe --save-now 走界面的"保存"流程(拍平 + 事务化写盘 + 脏检查) DuckImg.exe --open=<图片> 启动即打开图片(`--open=a.png|b.png` 一次开多个标签) DuckImg.exe --print-state-after 在所有注入之后再 dump 一次状态(关标签等断言用) DuckImg.exe --zoom=fit|width|actual|N 初始缩放模式或倍率 DuckImg.exe --print-info 打印解码结果到日志后退出(脚本化验证) DuckImg.exe --crop 启动即进入裁剪模式(截图/回归验证) DuckImg.exe --edit=<序列> 脚本化执行编辑序列(见 P3 现状) DuckImg.exe --save-as=<路径> 脚本化导出当前像素 DuckImg.exe --stats 输出撤销栈规模与进程内存后退出 DuckImg.exe --annotate=<图元:坐标> 脚本化添加/编辑标注(验证渲染、图层与撤销) DuckImg.exe --inspector=0|1|2|3 启动时切到检查器页签(属性/图层/信息/历史) DuckImg.exe --flatten 把标注拍平到底图(验证拍平与撤销) DuckImg.exe --edit=rotate-deg:30 任意角度旋转(度,顺时针为正) DuckImg.exe --edit=color:0.2;x;x;0.5 调色(亮度;对比度;饱和度;色温,x = 不变) DuckImg.exe --edit=border:50;0xFF101014 边框(边宽;颜色,颜色可省) DuckImg.exe --edit=watermark:文字@5;;0.5;tile 水印(文字@字号%;颜色;不透明度;tile|single) DuckImg.exe --batch=<目录>|<操作序列>|<输出目录> 批量处理(字段用竖线分隔) DuckImg.exe --snapshot-all 对照测量:强制每步都存快照(勿在日常使用) ``` 自检命令(可脚本化,无需桌面会话): ```powershell .\build\x64-Debug\DuckImg.exe --self-test=shots\selftest # 退出码 0 = 全部成功 ``` **踩过的坑(已解决,留档避免重犯)** 1. **`HwndRenderTarget` 让离屏导出无路可走**。它既不实现 `ID2D1Device`,也不与 `ID2D1DeviceContext` 共享资源域; 试过 WIC 位图渲染目标 / 兼容位图目标 / 独立编码设备三条路,分别撞在 `D2DERR_WRONG_STATE`、`D2DERR_RECREATE_TARGET`、资源域不匹配上。 换成交换链 + DeviceContext 后,离屏位图与编码器天然同设备,一次做对。 2. **`D2D1_FIGURE_BEGIN_HOLLOW` 的图形不计入 `GetBounds`**(恒返回无限大), 调试时会误判成"几何是空的"。统一改用 `FILLED`(描边行为一致)。 3. **手写 SVG path 解析器 + 几何缓存是错误方向**:解析器在部分子路径上静默丢图形, 缓存又与 D2D 工厂生命周期不一致(退出期崩溃)。现改为显式图元 + 每帧重建几何。 4. **窗口客户区尺寸 ≠ 设计尺寸**:系统会把窗口收缩到工作区内。 截图时必须以真实客户区为逻辑尺寸,再按 DPI 放大位图并施加同比例变换。 5. **各区域绘制必须裁剪到自身 bounds**,否则越界绘制会掩盖布局 bug(曾出现画布盖住工具栏)。 6. 注意 Windows SDK 的宏污染:`ext`(`__int64`)等名字不能用作变量名。 **下一步(P1 收尾 → P2)** - ~~菜单项命令真正下发~~ ✅ 已完成:每个菜单项带真实命令 ID(见 [`src/ui/commands.h`](src/ui/commands.h) 与上面的「P0 修复」); 仍未实现的项会在状态栏明确提示"尚未实现",而不是静默无反应。 - ~~工具提示浮层~~ ✅ 已完成(含延迟、翻转、快捷键右对齐)。 - 面板细化:检查器数值双向绑定、滑杆拖动改值回写、下拉框弹出列表、菜单键盘导航(上下键 + Enter)。 - 标签页文件名在窄宽下的省略策略(当前长名会被截断)。 - 画布占位插画替换为真实解码位图(P2)。 **P1 验证截图**:`shots/ui/dark.png`、`shots/ui/light.png`(1418×864,`--dpi=1` 确定性渲染)、 `shots/ui/tooltip.png`(工具提示)、`shots/selftest/p1-{dark,light}-{100,150}.png`。 ### P2 现状(看图内核) **已完成并验证** | 项 | 说明 | |---|---| | 解码层 | `src/core/decoder.{h,cpp}`:WIC 解码,覆盖 JPEG/PNG/GIF/BMP/TIFF/ICO/HEIF/WebP 等系统解码器支持的格式 | | 像素格式 | 统一转 **32bppPBGRA**,透明通道保留;含 alpha 的图在画布上叠棋盘格 | | 打开方式 | `--open=<文件>` / 拖放到窗口 / `Ctrl+O` 文件对话框 / `Ctrl+V` 剪贴板 / 点击缩略图条 / 双击 exe 关联 | | 缩放 | `适应窗口` / `适应宽度` / `实际大小` / 任意倍率(0.02×–32×),滚轮以**光标为锚点**缩放 | | 平移 | 画布内拖动平移(`IDC_SIZEALL`),双击切换缩放档位留给 P3 | | **缩放质量** | 缩小显示时先用 `IWICBitmapScaler`(Fant 插值)做一次 CPU 预缩放,再 1:1 绘制;
放大到 ≥100% 时改画原图并只取可见区域(`source rect`),避免大图全量拷贝 | | 缩放锚点数学 | 以"锚点下的图像像素保持不动"求解 pan:`ix = (anchor - center - pan)/zoom`,`pan' = anchor - center - ix*zoom'` | | 缩略图条 | 枚举同目录图片(按文件名排序),**只解码可见项**,按当前文档高亮;点击即切换文档 | | EXIF | 方向(1–8)、相机厂商/型号、光圈、快门、ISO、焦距,摘要显示在状态栏提示段 | | 光标取色 | 状态栏实时显示原图像素坐标与该点 `#RRGGBB`(含 alpha 反预乘还原真实色值) | | 状态栏 | 全部来自真实文档:文件名、像素尺寸、色彩/位深/Alpha/帧数、文件体积、缩放百分比 | | 错误处理 | 文件不存在 / 格式无法识别 / 尺寸无效 → 状态栏给出可读原因,不崩溃、不留空白视口 | | 验证设施 | `--print-info` 把解码结果写入 `%TEMP%\duckimg-selftest.log`(**UTF-8**,脚本可直接断言) | **验证手段**(全部脚本化,不依赖桌面会话) ```powershell # 解码与元信息路径:结果写入 %TEMP%\duckimg-selftest.log(UTF-8,可直接断言) DuckImg.exe --open=<图片> --print-info # 生成带 EXIF 的测试 JPEG(用于验证方向/光圈/快门/ISO/焦距) powershell -ExecutionPolicy Bypass -File tools\make-exif-jpeg.ps1 ` -Source in.jpg -Out out.jpg -Orientation 6 -Make DuckCam -Model 'DuckCam X1' ``` 实测日志(`--print-info`): ``` [info] format=JPEG [info] size=2400x1600 [info] bpp=24 alpha=0 frames=1 [info] orientation=6 [info] exif=DuckCam DuckCam X1 · f/2.8 · 1/500s · ISO 200 · 35mm [info] format=PNG [info] size=400x300 [info] bpp=32 alpha=1 [info] valid=0 [info] error=文件不存在 [info] valid=0 [info] error=无法识别该图片格式或文件已损坏 ``` **P2 踩的坑** 1. **WIC 的 EXIF 有理数不是 `VT_R8`,也不是正常的 `VT_VECTOR`**。 `GetMetadataByName` 对 `ExposureTime`/`FNumber`/`FocalLength` 返回 `vt = 0x0015`, 两个 `ULONG`(分子/分母)**内联**在 PROPVARIANT 的偏移 8 / 12 处,偏移 16 的指针恒为 null。 通过 SDK 的 union 成员去读会拿到错值——`caul.cElems` 的位置恰好就是内联分子的低 32 位, `pElems` 恒为 null(表现为"读到 280 这个元素个数")。现按实测布局取两个 ULONG 并校验。 2. **`PROPVARIANT` 是 union**:必须先判 `vt` 再取对应成员,否则会读到另一支的位模式(表现为"取到 0")。 3. **`IWICBitmapScaler` 的 `Initialize` 收 `IWICBitmapSource*`**,传 `ComPtr::Get()` 之外要多包一层会编译不过; 预缩放结果用 `IWICBitmap`(`WICBitmapCacheOnLoad`)持有,避免每帧重算。 4. 又一次撞上 Windows SDK 宏:`small` 被 `#define small char` 定义,不能用作变量名(此前还有 `ext`、`mono`)。 5. **PowerShell 的 `[byte[]] + [byte[]]` 得到 `Object[]`**,`BinaryWriter.Write` 会走 `Write(object)` 重载 并写入类型长度前缀——生成的 EXIF 段因此开头少 2 字节。测试脚本里改用 `List[byte]` 收集并加了签名自检。 6. 窗口客户区尺寸 ≠ 设计尺寸,截图必须按真实客户区布局再按 DPI 放大(同 P1 结论)。 7. **`fputws` 写的是 UTF-16LE**,按 UTF-8 读就是乱码;日志必须用 `WideCharToMultiByte` 转 UTF-8 后 `fwrite`。 另外 `& exe ... | Select-String` 捕获不到 `wprintf` 的控制台直写——脚本断言必须走文件。 **下一步(P2 收尾)** - 大图分块(tile)+ mipmap:当前 ≤2 亿像素的图可整张驻留显存,超过后需要瓦片化。 - 打开大图时的后台解码线程与进度指示(现在 `拖放/打开` 是同步的,超大图会短暂卡住)。 - 多文档标签页(现在标签页只显示当前文档,切换/关闭命令尚未实现)。 - GIF 动图播放(已能识别帧数与 `is_animated`,播放留给后续)。 --- ### P3 现状(基础编辑 + 撤销栈) **已完成并验证** | 项 | 说明 | |---|---| | 文档模型 | `src/core/editor.{h,cpp}`:`ImageDoc` 负责"读",`Editor` 负责"改";`base_` 保留原始解码结果 | | 像素变换 | `src/core/pixels.{h,cpp}`:裁剪用 `IWICBitmapClipper`、旋转/翻转用 `IWICBitmapFlipRotator`、缩放用 `IWICBitmapScaler`(Fant),全部交给 WIC 自带组件 | | 旋转/翻转 | 左转 90° / 右转 90° / 180° / 水平翻转 / 垂直翻转;90° 时宽高互换 | | 裁剪 | 画布内交互:框外压暗、三分构图线、8 个手柄、拖拽移动/改边/改角/框外拖出新框;
浮动确认条(尺寸 + 应用 + 取消),`Enter` 应用 / `Esc` 取消 | | 图像缩放 | 任意目标尺寸;`IWICBitmapScaler` 支持放大与缩小 | | **撤销/重做** | 差异记录 + 重放:旋转/翻转是**可逆无损**的,只记参数、撤销时重放(**0 份额外像素内存**);
裁剪/缩放会丢像素,保存操作前快照。撤销/重做统一走 `rebuild_to(target)`:找最近的快照锚点,重放余下步骤。
深度上限 30;新操作砍掉重做分支;有未保存改动时标签页显示脏标记 | | 保存 / 另存为 | `IWICBitmapEncoder`,PNG/JPEG/BMP/TIFF/GIF;JPEG 质量可调;`Ctrl+S` 覆盖保存、`Ctrl+Shift+S` 另存为 | | 剪贴板粘贴 | 剪贴板位图先落到临时 PNG,再走统一的 `open_document`,因此撤销栈/缩略图条/标题栏只有一条代码路径 | | 快捷键 | `C` 裁剪、`R`/`]` 右转、`[` 左转、`H` 水平翻转、`V` 垂直翻转、`Ctrl+Z` 撤销、`Ctrl+Y` 重做、`Ctrl+S`/`Ctrl+Shift+S` 保存 | | 状态同步 | 工具栏撤销/重做按钮按可用性自动置灰;标签页脏标记;状态栏提示随操作更新 | **验证手段(全部脚本化)** ```powershell # 执行编辑序列并断言(结果写 %TEMP%\duckimg-selftest.log) DuckImg.exe --open=<图片> --edit=rotate-cw,undo,redo --print-info DuckImg.exe --open=<图片> --edit=crop:100;200;800;600,resize:400;300 --save-as=out.png ``` 步骤用逗号分隔;`crop`/`resize` 的参数用**分号**分隔(避免与步骤分隔符冲突)。 **像素级校验结果**(源图四象限:左上红 `#DC3C3C`、右上绿 `#3CA05A`、左下蓝 `#3C5AD2`、右下黄 `#E6B428`) | 操作 | 左上角取样 | 尺寸 | 结论 | |---|---|---|---| | 无 | `#DC3C3C` 红 | 2400×1600 | 基准 | | 右转 90° | `#3C5AD2` 蓝 | 1600×2400 | 左上角变为源图左下角 ✓ 宽高互换 ✓ | | 旋转 180° | `#E6B428` 黄 | 2400×1600 | 左上角变为源图右下角 ✓ | | 水平翻转 | `#3CA05A` 绿 | 2400×1600 | 左上角变为源图右上角 ✓ | | 垂直翻转 | `#3C5AD2` 蓝 | 2400×1600 | 左上角变为源图左下角 ✓ | | 右转 90° ×2 | `#E6B428` 黄 | 2400×1600 | 等价于 180° ✓ | | 裁 0,0,1200,800 | 中心 `#DC3C3C` 红 | 1200×800 | 取到左上象限 ✓ | | 裁 1200,800,1200,800 | 中心 `#E6B428` 黄 | 1200×800 | 取到右下象限 ✓ | | 右转后撤销 | `#DC3C3C` 红 | 2400×1600 | 像素与尺寸都还原 ✓ | | 裁剪后撤销 | `#DC3C3C` 红 | 2400×1600 | 裁剪像素完整找回 ✓ | **P3 踩的坑(都是"看起来正常"的假象,值得记下来)** 1. **`PixelBuffer::adopt()` 会释放它正要接管的同一个指针**。 自接管时先 `Release()` 再赋回同一指针,留下野指针;表现为随机的 0xC0000005, 以及编辑栈里缓冲"凭空变 null"。现在自接管直接短路返回。 2. **首次编辑前必须先物化当前像素**。`current_` 初始为 null 时, `e.before = std::move(current_)` 存进去的是 null,撤销就把"空"换了回来; 而 `width()` 回退到 `info_`,**尺寸看起来完全正常**——这个假象让人误以为撤销没问题。 现在每个编辑操作入口都先 `ensure_current()`。 3. **`redo()` 必须用"搬走之前"的像素去重算**。写成先把 `current_` 移进 `e.before` 再拿 `current_.source()` 去变换,那时它已经是空的了。 4. **COM 对象的析构顺序**:全局 `IWICImagingFactory` 与 `App` 内部的 `PixelBuffer` 都必须在 `CoUninitialize()` **之前**释放,否则 `Release()` 打在已销毁的对象上, 进程在主流程全部成功之后仍以 `0xC0000005` 退出。现在 `wWinMain` 把 `App` 放进独立作用域, 并在 `CoUninitialize()` 前调用 `wic_factory_shutdown()`。 5. **`IWICBitmap` → `IWICBitmapSource` 的向上转型需要完整类型**, 前置声明下 `static_cast` 编译不过(同一类坑在 `ID2D1Factory1`、`IWICMetadataQueryReader` 上已踩过两次)。 6. 调试插桩用正则批量插入时**静默失配**过一次,导致"跟踪没输出"被误读成"这段代码没执行"。 关键插桩要确认插入点,或直接用编辑工具改。 **撤销栈的内存实测**(4000×3000 图,20 次操作,数据来自 `--stats` 的峰值工作集) | 场景 | 快照份数 | 峰值工作集 | 结论 | |---|---|---|---| | 20 次旋转 · 只对破坏性操作存快照 | **0** | 151 MB | 当前策略 | | 20 次旋转 · 每步都存快照(`--snapshot-all` 对照) | 20 | **1.06 GB** | 优化前;省下约 **900 MB** | | 20 次裁剪 · 当前策略 | 20 | 372 MB | 裁剪必须存快照 | | 20 次裁剪 · 对照策略 | 20 | 372 MB | 两者一致,符合预期 | `--snapshot-all` 是**只用于对照测量**的开关,正常路径不要打开。 **撤销/重做 fidelity 验证**(与「直接构造同一 head 状态」的图逐像素比对) | 场景 | 结果 | |---|---| | 撤销 1 步 / 2 步 == 对应 head 状态 | 逐像素一致 | | 撤销后再重做 1 步 / 2 步 == 对应 head 状态 | 逐像素一致 | | 裁剪+旋转:撤销 1 / 2 / 3 步 == head=1 / 0 / 0 | 逐像素一致 | | 裁剪+旋转:重做 1 / 2 / 3 步 == head=1 / 2 / 2 | 逐像素一致 | **下一步(P3 收尾)** - 「缩放图像」目前用极简的临时输入框;应换成自绘对话框(等比例锁定、预设百分比、插值方式选择)。 - 另存为对话框里的格式选择尚未驱动质量/位深选项。 - 设置与快捷键自定义(`Ctrl+,`)未实现。 --- ### P4 现状(标注图层) **已完成并验证** | 项 | 说明 | |---|---| | 图层模型 | `src/core/annotations.{h,cpp}`:标注是**矢量叠加层**,坐标一律用**原图像素**,缩放/平移不影响位置 | | 已实现图元 | 矩形、椭圆、直线、箭头、画笔、荧光笔、**文字**、序号、**马赛克**(9 种) | | 样式 | 颜色、线宽(按原图像素定义)、不透明度、线型(实线/虚线/点线)、端点、填充;样式随标注逐个保存 | | 绘制交互 | 工具条切换图元 → 画布内拖动绘制,带实时预览;误触过滤(尺寸过小不提交) | | 绘制坐标系 | `push_transform(图片左上角)` + `push_scale(zoom)`,之后所有图元直接用图像像素绘制,实现不必关心缩放 | | 标注不受像素编辑影响 | 标注不进像素撤销栈;它们是可单独增删改的矢量层 | | 图层顺序 | 数组顺序即图层顺序(后面的盖前面),提供 `raise/lower/move` | | 序号自动编号 | 自动跳过已占用编号(删掉 ① 后再加会重新用 ① ) | | **选中与移动** | 工具条「选择」→ 画布上点选已有标注(上层优先、6px 容差),拖动移动,`Delete` 删除 | | **选中与缩放** | 选中后 8 个手柄(§7.3):拖角/边缩放、对边固定、最小 4px、夹在图像内;光标按手柄切换;松手才提交一次可撤销编辑(见「第十二批」) | | **旋转手柄** | 顶部中点上方 20 DIP 的圆形手柄(悬停出现):拖动改 `Annotation::angle`、`Shift` 吸附 15°、命中与导出同步旋转(见「第十三批」) | | **图层页签** | 检查器「图层」页显示真实图层列表:显隐眼睛(可点)、锁定、类型徽章、名称与序号;点击行选中 | | **历史页签** | 检查器「历史」页显示真实的操作时间线(新 → 旧,当前步高亮) | | **统一撤销时间线** | 标注操作(增/删/改/显隐/顺序/清空)与像素操作**共用同一条栈**——`Ctrl+Z` 不区分两者 | | 状态栏 | 「标注 N 个 / N 个 · 已选 1」「图层 N」全部来自真实数据(此前是占位数字) | | **文字标注** | 单击放置 → 输入文案;支持多行;可选文字底板(保证亮背景上可读) | | **马赛克** | 拖动选区 → 降采样再放大做像素化;块大小可调;在**帧外**预渲染(见踩坑) | | **拍平导出** | `Ctrl+Shift+F` 把标注按原图分辨率烧进像素;**可撤销**(撤销后标注回来) | | **属性页签** | 接真实数据:有选中标注 → 编辑该标注;没选中 → 编辑"下一个标注"的默认样式。改完即入撤销栈 | | **信息页签** | 接真实数据:文件/尺寸/格式/位深/通道/大小/EXIF/路径 + 撤销栈与标注统计 | | **按类型显示** | 属性页只显示与当前图元相关的分组(马赛克只显示块大小、文字只显示字号),避免"改了没用"的死控件 | | 未实现工具 | 高斯模糊、橡皮擦、网格参考线 —— 工具条上会显示「未实现」而不是静默失败 | **验证方式**:`--annotate=<图元:坐标>` 脚本化添加标注后导出,与无标注基线**逐像素比对**。 ```powershell DuckImg.exe --open=<图> --annotate=rect:200;200;900;700,arrow:1000;900;1800;1450 --zoom=fit --capture=out.png ``` 逐图元真值表(差异像素数,0 = 没渲染): | 图元 | 修复前 | 修复后 | |---|---|---| | rect | 3948 | 3948 | | ellipse | 3536 | 3536 | | line | 2744 | 2744 | | arrow | 730 | **3012** | | pen | **0** | **2061** | | highlighter | 0 / 1854(不稳定) | **3293** | | number | 225 | 225 | **P4 踩的坑(一个根因导致三种图元"消失")** 1. **`Gfx::draw_geometry` / `fill_geometry` 把变换无条件重置为 `Identity`**。 它们本是给矢量图标用的(图标在绝对像素空间绘制),但我给标注加了 `push_scale(zoom)` 之后,这两个函数会把整个坐标系清掉: 标注按图像像素给出的坐标被当成绝对像素画到画布外。 **症状极具迷惑性**——矩形/椭圆/序号(不走这两个函数)完全正常, 画笔/荧光笔/箭头三角(走 `draw_polyline → draw_geometry`)完全不见。 修复:保存并恢复当前变换,而不是重置。 2. **`draw_polyline` 的 `count` 是 float 个数(2N)不是点数**。 传成点数时,2 点折线直接触发 `count < 4` 的守卫、静默什么都不画。 现在参数改名 `float_count`、加了偶数断言,并在注释里写明。 3. 排查过程中我**多次用错误的画布矩形反推屏幕坐标**做像素验证, 得到"标注没渲染"的错误结论,白绕了很久。教训: 几何验证应当**从程序内部导出权威数值**(现在 `--print-info` 会输出画布与图片矩形), 而不是从像素反推。 4. 日志设施曾在三处各写一份(`app.cpp` / `panels2.cpp` / 探针),编码还各不相同 (其中一处 `fputws` 写成 UTF-16LE)。现已统一为 `core/win32.h` 的 `log_line` / `log_ascii`,UTF-8 追加写文件。 **标注的撤销/重做验证**(起点 2 个标注,`annos=` 为每步执行后的标注数) | 操作序列 | 标注数序列 | 说明 | |---|---|---| | `undo` | 1 | 撤掉 ellipse | | `undo,undo` | 1 → 0 | 再撤掉 rect | | `undo,undo,undo` | 1 → 0 → 0 | 到底后第 3 次不可用 | | `undo,undo,redo,redo` | 1 → 0 → 1 → 2 | 完整往返 | | `rotate-cw,undo` | 2 → 2 | **撤销的是像素那步,标注保留** | | `rotate-cw,undo,undo,undo` | 2 → 2 → 1 → 0 | 先撤像素,再逐个撤标注 | | `rotate-cw,undo,undo,undo,redo,redo` | 2 → 2 → 1 → 0 → 1 → 2 | 重做把标注恢复回来 | **实现要点**:每条标注操作保存**操作前与操作后两份图层快照**。 标注是矢量数据(几十字节到几 KB),存两份的代价可以忽略, 换来的是撤销/重做都能 O(1) 直接取到目标状态——不需要重放,也不需要靠后继条目反推。 `rebuild_to(target)` 里像素与标注分两路独立推进:像素找最近快照锚点后重放, 标注直接取"最后一条已生效的标注操作的 anno_after"。 **P4 收尾时踩的坑** 1. **标注绕过撤销入口**:`--annotate` 脚本最初直接调用 `add_annotation()`(无撤销版本), 于是"标注是否进撤销栈"这件事**在脚本里根本测不到**。改成走 `anno_add()` 后才暴露出来。 2. **`--edit` 与 `--annotate` 的执行顺序**决定了能否测到跨类型撤销: `--edit` 在前时,`undo` 时标注还没添加,历史是空的。改为标注脚本先执行。 3. 三次验证里我有**两次把期望值写错**(`[1,0]` vs `[2,2]`), 都是因为没想清"这一步撤销的到底是像素操作还是标注操作"。 程序行为一直是对的——这类断言写错比代码出错更常见,值得记一笔。 **马赛克:为什么必须"帧外预渲染"** 马赛克用 D2D 内建效果(`CLIP` 裁剪 + 两次 `Scale`:先线性缩到 1/block 平均出块色, 再用最近邻放大回去)。这里有三个坑,**每一个都让整帧失败或位置尺寸全错**: 1. **D2D 不允许在同一 `ID2D1DeviceContext` 上嵌套 `BeginDraw`**。 最初我在 `paint_annotation`(帧内)里直接建效果并渲染中间位图, 结果 `EndDraw` 返回错误、**整帧作废**——截图直接失败(`exit=1`), 画面上什么也看不到,而错误点离病因极远。 现在:`CanvasView::prepare_mosaic_bitmaps()` 在 `begin_frame()` **之前**生成并缓存位图, 帧内只做一次 `draw_bitmap`。`Gfx::render_offscreen` 里加了 `in_frame` 断言, 再有人从帧内调用会立刻暴露,而不是变成一张黑图。 2. **`ID2D1Bitmap1` 没有 `CopyPixels`**(那是 D2D 1.0 的 `ID2D1Bitmap` 才有), 而且 `D2D1_BITMAP_OPTIONS_TARGET` 的位图不是 `IWICBitmapSource`, 所以"离屏渲染 → 像素缓冲"**无法零拷贝**。拍平导出因此借道一次 PNG 编码/解码(复用已验证的 `capture_png` + `load_pixels_from_file`), 而不是引入 D3D11/DXGI 互操作——后者为省一次磁盘往返要付出大得多的复杂度。 3. **效果的输出尺寸受它自己的 DPI 影响**。在 120 DPI 下 250 宽的框会画出 357 宽的马赛克。 `D2D1_PROPERTY_SIZE` / `D2D1_PROPERTY_DPI` 在 **SDK 头文件里都没有定义** (只在 MSDN 的 `D2D1_PROPERTY` 枚举里列出),设了也不稳定。 最终方案:把效果渲染进一张**尺寸正好等于目标**的中间位图,再 1:1 贴上—— 位图多大,贴出来就多大,坐标系问题被彻底隔离。 另外 `d2d1effects.lib` **不存在**(我一开始写在 `target_link_libraries` 里,直接 `LNK1104`); 效果 CLSID 由 `uuid.lib` 提供,且需要 `#include ` 让 `DEFINE_GUID` 真正**定义**符号。 **顺手修掉的一个既有崩溃** `FilmStrip::paint` 直接用 `rects_[i]` 而没有边界检查。 `rects_` 由 `layout()` 按当时的数据量填充;若列表数据在两次 layout 之间变化 (`--open` 先塞了 `items_`、还没 layout 就来了一帧 `WM_PAINT`),就会越界。 `--open=... --self-test=...` 这个组合正好命中——`0xC000027B`(断言失败)。 这个 bug 一直存在,只是之前从没用这个参数组合跑到过。 **属性面板接真实数据** 「属性」与「信息」两个页签此前是**写死的演示控件**——改颜色、拖粗细都不会有任何效果, 属于最典型的"看起来能用其实没用"。现在四个页签全部来自真实数据: | 页签 | 数据来源 | |---|---| | 属性 | 有选中标注 → 该标注的 `AnnoStyle`;没选中 → `Editor::current_style`(下一个标注的默认值) | | 图层 | `Editor::annotations()`(含显隐、锁定、类型、名称) | | 信息 | `ImageInfo` + 当前文档尺寸/撤销栈/标注数 | | 历史 | `Editor::history_labels()`(像素与标注共用一条时间线) | 改动全部走 `Editor::anno_update_style(index, style, block)`: - `index >= 0` → 改该标注并**入撤销栈**(`Ctrl+Z` 能撤销一次改色/改粗细) - `index < 0` → 只改默认样式,不产生历史 **脚本化验证**(属性面板与脚本走同一个入口,所以脚本能覆盖面板的行为): ```powershell # style:idx;color;width;opacity;line;cap;filled;fontsize;block (-1 = 该字段不动) DuckImg.exe --open=<图> --annotate=rect:200;200;900;700,style:0;0x4C9AFF;12;-1;1;2;-1;-1;-1 --capture=out.png ``` 对照验证:默认样式是红色细实线,改成 `0x4C9AFF;12;虚线;方头` 后渲染出来的边框 确实是蓝色粗虚线(见 `shots/p4/35-style-compare.png`)。 **本轮踩的坑** 1. **`sync_inspector_layers()` 被一次批量替换吃掉了大半**,只剩一行 `set_annotations`。 结果属性页一直显示"正在设置默认样式",看不到已选标注的样式。 教训:关键收尾函数的字符串替换要**立刻回读确认**,不能只看"替换成功"的提示。 2. **`--inspector=` 的调用顺序有讲究**:`set_tab()` 内部会用**当时**的 `style_ctx_` 重建内容, 所以必须先同步数据再切页签,切完再同步一次兜底。 3. 调试时我用"输出文件哈希是否变化"判断改动是否生效——比反复读代码可靠得多, 也正是它让我发现脚本路径的同步没走到。 **下一步(P4 收尾 / P5)** - 高斯模糊、橡皮擦、网格参考线。 - 文字标注的字体族选择(现在只用统一的默认字体)。 - P5:幻灯片、UIA 无障碍。 --- ## P5 现状(后处理:调色 / 旋转 / 边框 / 水印 / 批量) | 能力 | 说明 | |---|---| | 调色 | 亮度(加性)、对比度(绕中灰缩放)、饱和度(向亮度灰度插值)、色温(R↑B↓ 或反之) | | 灰度 | 就是饱和度 = 0,不是单独的特例分支 | | 任意角度旋转 | 输入 -180..180 的任意角度;输出自动放大到装下整张图,空白**完全透明** | | 撤销策略 | 两者都是**可重放的无损变换**——只记参数,撤销时重算,**不占快照内存** | | 边框 | 四边等宽、任意颜色;另加一圈 1px 内描边,避免浅色图和浅色框"糊"在一起 | | 文字水印 | 单个(右下角)或**错位平铺**;字号按**图片宽度百分比**给出,同一套参数在不同尺寸的图上比例一致 | | 入口 | `Ctrl+R` 任意角度旋转、`Ctrl+B` 调色、`Ctrl+Shift+B` 加边框、`Ctrl+W` 文字水印;也可用 `--edit=` 脚本 | **验证(四象限中心色,源图 TL红/TR绿/BL蓝/BR黄)** | 操作 | TL | TR | BL | BR | 结论 | |---|---|---|---|---|---| | 原图 | #DC3C3C | #3CA05A | #3C5AD2 | #E6B428 | — | | 灰度 | #6C6C6C | #7A7A7A | #5F5F5F | #B3B3B3 | R=G=B ✓ | | 亮度 +0.3 | #FF8989 | #89EDA7 | #89A7FF | #FFFF75 | 各通道恰好 +76 ✓ | | 亮度 -0.3 | #8F0000 | #00530D | #000D85 | #996700 | 减后夹紧 ✓ | | 对比度 2.0 | #FF0000 | #00C034 | #0034FF | #FFE800 | 低于中灰压到 0 ✓ | | 偏暖 +0.6 | #F43C24 | #54A042 | #545ABA | #FEB410 | R↑ B↓ ✓ | | 偏冷 -0.6 | #C43C54 | #24A072 | #245AEA | #CEB440 | R↓ B↑ ✓ | **任意角度旋转的正确性**:`rotate-deg:90` 与离散的 `rotate-cw` 结果**四象限逐一相同** (1601×2400 vs 1600×2400,仅包围盒取整差 1 像素),公共区域逐像素比对只有 0.34% 的采样点因边界抗锯齿而不同。 实现要点:WIC 的 FlipRotator 只支持 90° 整数倍,所以任意角度是**自己做的反向映射 + 双线性插值**。反向映射(对每个输出像素求源坐标)而不是正向映射,否则放大时输出上 会留下没被写到的空洞。插值必须在**预乘 alpha 空间**做,否则半透明边缘会出现暗边。 **本轮踩的坑(一个影响面很大的渲染 bug)** `CanvasView::paint_document` 在 `zoom < 1` 时会调用 `ImageDoc::scaled_source()` 做一次 高质量预缩放——**但那是解码原图的预缩放,与编辑结果无关**。 后果:fit 缩放下画布显示的始终是"未编辑的原图",于是**调色/灰度这类不改变尺寸的操作 看起来完全没生效**(撤销栈明明有记录、`--save-as` 导出的图也确实是灰的)。 旋转之所以看起来正常,只是因为尺寸变了、缩放比例随之变化,碰巧掩盖了它。 排查过程也值得记:我先怀疑 `adjust_pixels` 失败(不是)、再怀疑像素指针没更新(更新了)、 再怀疑保存路径(保存是对的),最后才定位到画布**另有一条预缩放路径**。 真正让我转向的是"**保存出来的图是灰度、画布不是**"这个对比——两条路径的差异 把范围一下缩小到了画布侧。 修法:画布自己维护预缩放缓存,源改成"当前像素"(`doc_pixels_`), 缓存键 = (像素源指针, 目标尺寸)。 --- ### 批量处理 | 项 | 说明 | |---|---| | 入口 | `Ctrl+Shift+P`(先选文件夹),或 CLI 的 `--batch=`(字段用竖线分隔) | | 操作语义 | **与单文件编辑完全同一套**(共用 `core/ops.h`)——单文件能做的,批量就能做 | | 独立文档 | 每张图用一个全新 `Editor`,撤销栈/标注/当前样式都从零开始,互相不残留 | | 容错 | **单张失败不中断整批**(批量里"一张坏图毁掉整趟"不可接受) | | 覆盖保护 | 默认跳过已存在的输出;输出到输入目录且无后缀时自动加 `_processed`,避免覆盖自己 | | 唯一例外 | `flatten` 需要界面渲染回调(Direct2D),批量里**明确报错**而不是静默跳过 | 把 `--edit` 的解析与应用从 App 抽到了 `core/ops.{h,cpp}`。这是个重构而不是新功能, 但价值在于:批量处理如果自己再实现一份解析,两边迟早会漂移 (只有一边支持某个操作、或分隔符理解不一致)。现在两边不可能不一致。 **踩的坑** 1. **`--batch` 的分隔符不能用逗号或分号**。操作序列自己就用逗号分步骤、分号分参数 (`resize:800;600,border:20`),所以第一版 `--batch=目录;操作;输出` 里 操作序列被从中间切断,变成了 `resize:800` + 一个叫 `600,border:20,...` 的输出目录。 改用**竖线** `|` 做字段分隔符后没有歧义。 2. **`extension_for()` 返回的是不带点的 `"png"`**,直接拼会得到 `a-quad_outpng` (内容是好的,但文件名很怪、系统也不按图片关联)。补点即可。 另外这次重构时我用脚本删除 `run_edit_script` 的旧实现,**多删了它后面的 `run_annotate_script` 与若干辅助函数**。靠"列出 `app.h` 里声明但 `app.cpp` 里没有 定义的方法"这个检查才确认只丢了这一个,然后重建了它。教训与之前一致: **批量文本替换必须用编译/检查把损失兜住**,不能只看"替换成功"的提示。 ### 状态栏尺寸显示的错误(顺手修掉) 状态栏一直用 `ImageInfo::width/height`——那是**打开时解码出来的原图尺寸**, 编辑之后就不对了。于是旋转/裁剪/加边框之后状态栏永远显示旧尺寸 (画布是对的,只有数字没跟上)。已改为用**当前编辑尺寸**, 并在与原尺寸不同时补一个对照:`2520 × 1720(原 2400×1600)`。 这与前面"信息页签显示写死数据"、以及更早"画布用原图预缩放"是同一类问题: **同一个事实存在多个来源,其中一个是过期副本**。 ### 边框 / 水印 | 能力 | 说明 | |---|---| | 边框 | 输出尺寸 = 原图 + 2×边宽;颜色任意;带 1px 内描边 | | 水印 | 单个(右下角,可调边距)或错位平铺(每行偏移半格,比整齐网格更难被裁掉) | | 字号策略 | 按图片**宽度百分比**给出,所以 800px 和 4000px 的图上水印比例一致 | | 撤销 | 与调色/旋转一样是**可重放的无损变换**,不占快照内存 | 水印文字用 **GDI+** 渲染:`core/pixels.cpp` 不应该反向依赖 `ui/gfx` 的 Direct2D, 而"把字画进位图"这件事 GDI+ 完全够用,只多链接一个 `gdiplus.lib`。 `GdiplusStartup` 只在首次使用时做一次、进程存活期间不 Shutdown, 这样就不必操心清理顺序(本项目已经在 COM 清理顺序上踩过坑)。 **本轮踩的坑:`_wtoi64` 不认 `0x` 前缀** 水印/边框的颜色参数写成 `0xFFFF0000`(红),而 `_wtoi64` 遇到 `0x` 直接返回 **0** (也就是全透明)。后果极具迷惑性: - 参数解析日志看起来完全正常(`text='DuckImg' size=12.00`) - 整步操作返回 `ok=1`、进撤销栈、`--save-as` 也成功 - 但画出来**什么都没有**——因为 alpha=0 花了不少时间在 `MeasureString` / 坐标 / 字体回退这几个方向上排查, 直到做了个最小实验(纯色小图上对比"默认参数"与"显式红色参数"的差异像素) 才定位到是颜色值本身变成了 0。 修法是加一个 `parse_color_literal()`:检测 `0x`/`0X` 前缀后用 `wcstoull(..., 16)`, 否则用 10 进制。验证方式是**十进制 `4294901760` 与 `0xFFFF0000` 必须产生完全相同的像素** (15139 个差异像素,逐字节一致)。 顺带提醒:`--edit` 里的 `border:60;0xFF000000` 也走同一个函数,否则深色边框会静默变成透明。 --- ## 技术选型说明 | 决策 | 选择 | 理由 | |---|---|---| | 界面框架 | 不用框架,纯 Win32 + 自绘 | 同类工具(ImageGlass、Honeyview)表明:自绘才能同时满足深色主题、Per-Monitor V2 DPI、60FPS 画布三者;MFC/WinUI 在画布混排与主题统一上都要额外打补丁 | | 渲染 | Direct2D 1.3 + DirectWrite | 硬件加速、矢量图标不失真、ClearType 中文排版、与 WIC 位图无缝互操作 | | 解码 | WIC 为主,libwebp / libheif / libjxl / LibRaw 为辅 | WIC 覆盖 JPEG/PNG/GIF/BMP/TIFF/ICO/HEIC(Win10+ 扩展);其余用静态库补齐,全部走统一"解码器注册表"接口 | | 控件 | 全部自绘,仅文本框/通用对话框用真 HWND | 避免控件主题、DPI、字体三处不一致;牺牲开发量换取一致的视觉与性能 | | 存储 | 配置用 JSON,图片写盘用"临时文件 + 原子替换" | 避免半成品文件;崩溃恢复目录独立 | --- ## 待确认 见 [`docs/UI-DESIGN.md` 第 16 节](docs/UI-DESIGN.md#16-待定问题):界面语言、单/多实例、标注默认持久化方式、界面密度、工具栏列数、云同步入口、是否内置截图、主色是否改品牌黄。