【CStackGUI 实战】画板 drawpad:鼠标拖拽作图、撤销 / 重做、导出 PNG

文章目录

一、它演示什么

画板上的图形不是像素,而是被录制的绘制命令 :每画一笔记一条命令,控件重绘(被盖住又露出、改大小)时按序回放 ------ 所以没有 "擦掉某一笔"的接口。示例把每条命令另存进一张固定数组,它才是可撤销的"真相来源":撤销 = 丢掉最后一条 → 清空记录 → 重放剩下的。

鼠标事件挂在画板自己 (mousedown / mousemove / mouseup),按下时运行时自动捕获鼠标,拖出边界也不断笔;直线 / 矩形 / 椭圆在拖动中有橡皮筋预览,松手才提交。

下面是「录制-回放」模型的核心流程:
#mermaid-svg-JWPbXyDer3v7SGvI{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-JWPbXyDer3v7SGvI .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-JWPbXyDer3v7SGvI .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-JWPbXyDer3v7SGvI .error-icon{fill:#552222;}#mermaid-svg-JWPbXyDer3v7SGvI .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-JWPbXyDer3v7SGvI .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-JWPbXyDer3v7SGvI .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-JWPbXyDer3v7SGvI .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-JWPbXyDer3v7SGvI .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-JWPbXyDer3v7SGvI .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-JWPbXyDer3v7SGvI .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-JWPbXyDer3v7SGvI .marker{fill:#333333;stroke:#333333;}#mermaid-svg-JWPbXyDer3v7SGvI .marker.cross{stroke:#333333;}#mermaid-svg-JWPbXyDer3v7SGvI svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-JWPbXyDer3v7SGvI p{margin:0;}#mermaid-svg-JWPbXyDer3v7SGvI .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-JWPbXyDer3v7SGvI .cluster-label text{fill:#333;}#mermaid-svg-JWPbXyDer3v7SGvI .cluster-label span{color:#333;}#mermaid-svg-JWPbXyDer3v7SGvI .cluster-label span p{background-color:transparent;}#mermaid-svg-JWPbXyDer3v7SGvI .label text,#mermaid-svg-JWPbXyDer3v7SGvI span{fill:#333;color:#333;}#mermaid-svg-JWPbXyDer3v7SGvI .node rect,#mermaid-svg-JWPbXyDer3v7SGvI .node circle,#mermaid-svg-JWPbXyDer3v7SGvI .node ellipse,#mermaid-svg-JWPbXyDer3v7SGvI .node polygon,#mermaid-svg-JWPbXyDer3v7SGvI .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-JWPbXyDer3v7SGvI .rough-node .label text,#mermaid-svg-JWPbXyDer3v7SGvI .node .label text,#mermaid-svg-JWPbXyDer3v7SGvI .image-shape .label,#mermaid-svg-JWPbXyDer3v7SGvI .icon-shape .label{text-anchor:middle;}#mermaid-svg-JWPbXyDer3v7SGvI .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-JWPbXyDer3v7SGvI .rough-node .label,#mermaid-svg-JWPbXyDer3v7SGvI .node .label,#mermaid-svg-JWPbXyDer3v7SGvI .image-shape .label,#mermaid-svg-JWPbXyDer3v7SGvI .icon-shape .label{text-align:center;}#mermaid-svg-JWPbXyDer3v7SGvI .node.clickable{cursor:pointer;}#mermaid-svg-JWPbXyDer3v7SGvI .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-JWPbXyDer3v7SGvI .arrowheadPath{fill:#333333;}#mermaid-svg-JWPbXyDer3v7SGvI .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-JWPbXyDer3v7SGvI .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-JWPbXyDer3v7SGvI .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-JWPbXyDer3v7SGvI .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-JWPbXyDer3v7SGvI .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-JWPbXyDer3v7SGvI .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-JWPbXyDer3v7SGvI .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-JWPbXyDer3v7SGvI .cluster text{fill:#333;}#mermaid-svg-JWPbXyDer3v7SGvI .cluster span{color:#333;}#mermaid-svg-JWPbXyDer3v7SGvI div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-JWPbXyDer3v7SGvI .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-JWPbXyDer3v7SGvI rect.text{fill:none;stroke-width:0;}#mermaid-svg-JWPbXyDer3v7SGvI .icon-shape,#mermaid-svg-JWPbXyDer3v7SGvI .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-JWPbXyDer3v7SGvI .icon-shape p,#mermaid-svg-JWPbXyDer3v7SGvI .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-JWPbXyDer3v7SGvI .icon-shape .label rect,#mermaid-svg-JWPbXyDer3v7SGvI .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-JWPbXyDer3v7SGvI .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-JWPbXyDer3v7SGvI .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-JWPbXyDer3v7SGvI :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
松手提交
用户按下鼠标
捕获鼠标并记录起点
拖动中
写入临时命令 + 整表重放

(橡皮筋预览)
把命令追加进命令表

(真相来源)
重放整张画布
状态栏更新命令条数
撤销:丢最后一条 → 清空 → 重放

二、怎么用

  1. 在项目根编译:build-app.bat drawpad,双击 examples\drawpad\drawpad.exe。
  2. 顶部「工具」下拉选一种(直线 / 矩形 / 椭圆 / 文字 / 自由笔);点「颜色」色块弹系统取色器;「笔宽」数值框调 1...12。
  3. 在白色画板上按下拖动:直线 / 矩形 / 椭圆拖动中就有预览、松手提交;文字工具单击一次落一个字;自由笔一路划过去逐段提交。
  4. 「撤销」退掉最后一笔,「重做」放回来,「清空」全部丢弃;状态栏三段分别是当前工具、命令条数、最近一次操作。
  5. 「导出 PNG」把画板截图存进用户数据目录(文件名带时间戳);底部终端会记下每一步作图 / 撤销 / 导出。
  6. 命令行:drawpad.exe(自动找同名 .cgui)、drawpad.exe xxx.cgui、--selftest。

三、一屏总览

控件 id 类型 干什么
cbo_tool combo 工具:0 直线 / 1 矩形 / 2 椭圆 / 3 文字 / 4 自由笔
cp_ink colorpicker 当前色(value = 0xRRGGBB),点它弹系统取色器
edt_pen edit(spin) 笔宽数值框,1...12
btn_undo / btn_redo / btn_clear button 撤销 / 重做 / 清空,共用一个回调
btn_export button 导出 PNG
cv_board canvas 画板本身,挂 mousedown / mousemove / mouseup
term_log terminal 每一步操作记一行
sb_main statusbar 三段:当前工具 / 命令条数 / 最近操作

四、关键接口

  • 画板绘制:cg_canvas_clear、cg_canvas_set_color、cg_canvas_set_line_width、cg_canvas_line、cg_canvas_rect、cg_canvas_ellipse、cg_canvas_text
  • 鼠标坐标:cg_event_x、cg_event_y(就是画板内的逻辑像素)
  • 截图与落盘:cg_widget_screenshot、cg_app_data_dir、cg_path_join
  • 记录与状态:cg_term_write、cg_status_set、cg_status_setf

五、录制-回放 vs 像素擦除

维度 录制-回放(本示例) 像素擦除(位图) 结论
撤销粒度 按「命令」撤销,可精确回退到任意一笔 只能整块擦除像素,无法还原被覆盖的笔画 录制-回放更精细,支持逐笔撤销
内存占用 只存命令参数(类型 + 坐标 + 颜色 + 笔宽),固定数组上限 kMaxOps = 4096 每帧都要存整张位图,画布越大内存越高 录制-回放更省内存,且占用可预估
缩放质量 重放时按当前尺寸重新绘制,任意缩放都清晰 位图缩放会模糊 / 锯齿,需额外重采样 录制-回放缩放无损,质量更高
实现复杂度 需维护命令表 + 重放逻辑,预览要「临时命令 + 整表重放」 直接改像素,实现直观但撤销 / 缩放难做 录制-回放略复杂,但换来前三个维度的收益

两种方案的取舍可以这样看:
#mermaid-svg-Qj6aHeubsDIzEvCC{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Qj6aHeubsDIzEvCC .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Qj6aHeubsDIzEvCC .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Qj6aHeubsDIzEvCC .error-icon{fill:#552222;}#mermaid-svg-Qj6aHeubsDIzEvCC .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Qj6aHeubsDIzEvCC .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Qj6aHeubsDIzEvCC .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Qj6aHeubsDIzEvCC .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Qj6aHeubsDIzEvCC .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Qj6aHeubsDIzEvCC .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Qj6aHeubsDIzEvCC .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Qj6aHeubsDIzEvCC .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Qj6aHeubsDIzEvCC .marker.cross{stroke:#333333;}#mermaid-svg-Qj6aHeubsDIzEvCC svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Qj6aHeubsDIzEvCC p{margin:0;}#mermaid-svg-Qj6aHeubsDIzEvCC .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Qj6aHeubsDIzEvCC .cluster-label text{fill:#333;}#mermaid-svg-Qj6aHeubsDIzEvCC .cluster-label span{color:#333;}#mermaid-svg-Qj6aHeubsDIzEvCC .cluster-label span p{background-color:transparent;}#mermaid-svg-Qj6aHeubsDIzEvCC .label text,#mermaid-svg-Qj6aHeubsDIzEvCC span{fill:#333;color:#333;}#mermaid-svg-Qj6aHeubsDIzEvCC .node rect,#mermaid-svg-Qj6aHeubsDIzEvCC .node circle,#mermaid-svg-Qj6aHeubsDIzEvCC .node ellipse,#mermaid-svg-Qj6aHeubsDIzEvCC .node polygon,#mermaid-svg-Qj6aHeubsDIzEvCC .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Qj6aHeubsDIzEvCC .rough-node .label text,#mermaid-svg-Qj6aHeubsDIzEvCC .node .label text,#mermaid-svg-Qj6aHeubsDIzEvCC .image-shape .label,#mermaid-svg-Qj6aHeubsDIzEvCC .icon-shape .label{text-anchor:middle;}#mermaid-svg-Qj6aHeubsDIzEvCC .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Qj6aHeubsDIzEvCC .rough-node .label,#mermaid-svg-Qj6aHeubsDIzEvCC .node .label,#mermaid-svg-Qj6aHeubsDIzEvCC .image-shape .label,#mermaid-svg-Qj6aHeubsDIzEvCC .icon-shape .label{text-align:center;}#mermaid-svg-Qj6aHeubsDIzEvCC .node.clickable{cursor:pointer;}#mermaid-svg-Qj6aHeubsDIzEvCC .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Qj6aHeubsDIzEvCC .arrowheadPath{fill:#333333;}#mermaid-svg-Qj6aHeubsDIzEvCC .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Qj6aHeubsDIzEvCC .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Qj6aHeubsDIzEvCC .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Qj6aHeubsDIzEvCC .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Qj6aHeubsDIzEvCC .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Qj6aHeubsDIzEvCC .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Qj6aHeubsDIzEvCC .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Qj6aHeubsDIzEvCC .cluster text{fill:#333;}#mermaid-svg-Qj6aHeubsDIzEvCC .cluster span{color:#333;}#mermaid-svg-Qj6aHeubsDIzEvCC div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Qj6aHeubsDIzEvCC .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Qj6aHeubsDIzEvCC rect.text{fill:none;stroke-width:0;}#mermaid-svg-Qj6aHeubsDIzEvCC .icon-shape,#mermaid-svg-Qj6aHeubsDIzEvCC .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Qj6aHeubsDIzEvCC .icon-shape p,#mermaid-svg-Qj6aHeubsDIzEvCC .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Qj6aHeubsDIzEvCC .icon-shape .label rect,#mermaid-svg-Qj6aHeubsDIzEvCC .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Qj6aHeubsDIzEvCC .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Qj6aHeubsDIzEvCC .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Qj6aHeubsDIzEvCC :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 需要逐笔撤销
只整块擦除即可
画板方案选型
撤销粒度要求
录制-回放

(本示例)
像素擦除

(位图)
优点:逐笔撤销 / 省内存 / 缩放无损
代价:命令表 + 重放逻辑较复杂
优点:实现直观
代价:撤销难 / 内存高 / 缩放模糊
结论:追求精细撤销与质量 → 选录制-回放
结论:简单场景够用 → 选像素擦除

六、容易踩的坑

  • 鼠标事件必须挂在画板这个子控件上 ,不能挂窗口级 app.events ------ 按下 / 移动 / 抬起落在画板上时消息只送给画板,父窗口收不到(早先挂窗口的写法在真机上"按下拖动毫无反应")。
  • 画板事件里的坐标就是画板内 像素,不用再减 cg_widget_x / cg_widget_y。
  • 画板的绘制是"录制 → 回放"模型,没有"画一笔但不作数"的接口 ,所以拖动预览只能"把临时命令写进命令表空槽 + 整表重放"(见 preview_op)。
  • 命令表是固定数组(上限 kMaxOps = 4096):满了只报一次提示并拒绝起笔;自由笔贴着上一个点 2px 以内的移动不记,免得几秒见底。
  • 画板重绘走双缓冲,所以"每步重放整张画布"的预览不会闪。

七、常见问题排查

Q1:画板无响应,按下拖动毫无反应?

  • 原因 :鼠标事件挂错了对象,挂到了窗口级 app.events 而不是画板子控件 cv_board 上。
  • 解决 :把 mousedown / mousemove / mouseup 全部挂到 cv_board 上;按下时运行时自动捕获鼠标,拖出边界也不断笔。

Q2:拖动预览闪烁?

  • 原因:画板重绘没有走双缓冲,或预览时直接改像素导致整块重绘。
  • 解决:确认画板重绘走双缓冲;预览用「临时命令写进命令表空槽 + 整表重放」的方式,避免逐像素直接改。

Q3:命令表满了,画不进去?

  • 原因 :命令表是固定数组,上限 kMaxOps = 4096,画满后拒绝起笔。
  • 解决:先「清空」或「撤销」释放空间;自由笔贴着上一个点 2px 以内的移动不记,可有效延缓命令表见底。

Q4:导出 PNG 失败?

  • 原因:用户数据目录不可写,或文件名带非法字符。
  • 解决 :确认 cg_app_data_dir 返回的目录存在且可写;导出文件名带时间戳,若仍失败请检查磁盘空间与目录权限。

Q5:文字工具单击落字位置偏移?

  • 原因:文字工具单击落字时,坐标换算用了窗口坐标而不是画板内坐标,或没考虑画板在窗口内的偏移。
  • 解决 :确认落字坐标直接用 cg_event_x / cg_event_y(就是画板内逻辑像素),不要再减 cg_widget_x / cg_widget_y。

问题速查表

问题 现象 原因 解决方案
Q1 画板无响应 按下拖动毫无反应 鼠标事件挂到了窗口级 app.events,而非画板子控件 cv_board 把 mousedown / mousemove / mouseup 全部挂到 cv_board 上
Q2 预览闪烁 拖动中画布闪烁 画板重绘未走双缓冲,或预览时直接改像素 重绘走双缓冲;预览用「临时命令 + 整表重放」
Q3 命令表满 画不进去,拒绝起笔 命令表固定数组上限 kMaxOps = 4096 已满 先「清空」或「撤销」释放空间;自由笔 2px 内移动不记
Q4 导出 PNG 失败 导出报错或文件未生成 用户数据目录不可写,或文件名带非法字符 确认 cg_app_data_dir 目录存在且可写;检查磁盘空间与权限
Q5 文字落字偏移 单击落字位置不对 坐标用了窗口坐标,或没考虑画板偏移 直接用 cg_event_x / cg_event_y,不再减 cg_widget_x / cg_widget_y
相关推荐
新晨单片机设计2 小时前
S022A-基于STM32单片机音乐盒(数码管显示)【Proteus仿真+Keil程序+原理图】
stm32·单片机·proteus·音乐盒
深圳老胡2 小时前
STM32 MCU 国产替代型号简介
笔记·stm32·单片机·代码规范
搁浅小泽4 小时前
通用二极管选型设计规范
单片机·嵌入式硬件·设计规范
HRTOS4 小时前
HRTOS 4.0 将长期维护:为什么版本号不再频繁变化
经验分享·git·单片机·51单片机
深圳老胡4 小时前
STM32 在 VSCode 下 -O0 与 -Os 编译条件的区别
笔记·stm32·单片机·代码规范
新晨单片机设计11 小时前
S021A-基于STM32单片机自动追光系统(步进电机)【Proteus仿真+Keil程序+原理图】
stm32·单片机·proteus·自动追光系统
女神下凡16 小时前
芯参谋(43):DDR4_电路设计指南
arm开发·单片机·嵌入式硬件·设计规范
电子元器件选型哥16 小时前
MOS管驱动电阻怎么选?阻值大小到底影响什么?
单片机·嵌入式硬件·硬件工程师·半导体·电子元器件
lhbweilai17 小时前
深挖FreeRTOS内核实现(三)
c语言·stm32·单片机