第一篇结束时,窗口里能画出一条线,代码大概长这样:一个QMainWindow子类,重写mousePressEvent和mouseReleaseEvent,在按下时记一个起点,在松开时把线段塞进一个self.lines列表,然后在paintEvent里遍历这个列表画出来。
能跑,但只能跑一次。只要你想加第二个工具、想撤销、想保存文件,就会发现所有东西都黏在窗口类里。这一篇就处理三件事:把图形数据从窗口里搬出来、让 Esc 能取消正在进行的操作、以及搞清楚为什么键盘事件有时候压根收不到。
先把图形数据从窗口类里搬出来
我一开始的想法很简单:self.lines换成self.entities,名字好听点。但真正写下去会发现,问题不在名字,在于窗口类同时承担了三种职责:接收输入、维护数据、负责渲染。这三件事的变更频率完全不同,混在一起之后,每加一个功能都要动同一个文件。
所以第一步是把"实体"这个概念单独定义出来。CAD 里的基本图元无非是点、线、圆、圆弧,它们共享一些属性(颜色、图层、是否选中),又有各自不同的几何参数。用继承是最自然的写法:
# entities.py# Python 3.11, 无第三方依赖fromdataclassesimportdataclass,fieldfromtypingimportLiteral@dataclassclassEntity:"""所有图元的基类,只放通用属性"""color:str="#000000"layer:str="0"selected:bool=Falsedefbounds(self)->tuple[float,float,float,float]:"""返回 (minx, miny, maxx, maxy),用于后续的框选和重绘优化"""raiseNotImplementedError@dataclassclassLine(Entity):x1:float=0.0y1:float=0.0x2:float=0.0y2:float=0.0defbounds(self):return(min(self.x1,self.x2),min(self.y1,self.y2),max(self.x1,self.x2),max(self.y1,self.y2))这里用dataclass是因为图元本质上就是一堆数据的组合,不需要复杂的行为。bounds()现在看起来没什么用,但等你做框选、做视图缩放、做脏矩形重绘的时候,它会反复出现,早点定下来比后面补要省事。
Entity用@dataclass装饰时有个细节:子类Line的字段必须全部带默认值,否则会因为"非默认参数跟在默认参数后面"直接报TypeError。这是 Python dataclass 的继承规则,不是 bug。
接下来是容器。容器要解决的核心问题是:谁持有实体列表,谁负责通知界面刷新。我的做法是让容器只做数据管理,通过一个简单的信号机制通知外部:
# document.pyfromentitiesimportEntityclassDocument:def__init__(self):self._entities:list[Entity]=[]self._listeners:list=[]defadd(self,entity:Entity)->None:self._entities.append(entity)self._notify()defremove(self,entity:Entity)->None:# 用 identity 比较而不是 ==,因为 dataclass 默认按值相等fori,einenumerate(self._entities):ifeisentity:delself._entities[i]self._notify()returndefall(self)->tuple[Entity,...]:returntuple(self._entities)defsubscribe(self,fn)->None:self._listeners.append(fn)def_notify(self)->None:forfninself._listeners:fn()【踩坑提醒】remove这里我用的是e is entity而不是e == entity。因为@dataclass默认生成__eq__,两条坐标完全相同的线在==下是相等的。如果你用==去删,删掉的可能不是你点的那个对象——在撤销、图层删除这些场景下,这个区别会变成很难查的 bug。
现在窗口类只负责"把鼠标事件翻译成对 Document 的调用":
# canvas.pyfromPySide6.QtWidgetsimportQWidgetfromPySide6.QtGuiimportQPainter,QPenfromPySide6.QtCoreimportQt,QPointFfromdocumentimportDocumentfromentitiesimportLineclassCanvas(QWidget):def__init__(self,doc:Document,parent=None):super().__init__(parent)self.doc=doc self.doc.subscribe(self.update)# 数据一变就重绘self._start:QPointF|None=Noneself._preview:QPointF|None=Noneself.setMouseTracking(True)self.setFocusPolicy(Qt.StrongFocus)# 后面讲键盘要用到defmousePressEvent(self,ev):ifev.button()==Qt.LeftButton:self._start=ev.position()self._preview=self._startdefmouseMoveEvent(self,ev):ifself._startisnotNone:self._preview=ev.position()self.update()defmouseReleaseEvent(self,ev):ifev.button()==Qt.LeftButtonandself._startisnotNone:self.doc.add(Line(x1=self._start.x(),y1=self._start.y(),x2=ev.position().x(),y2=ev.position().y()))self._start=Noneself._preview=NonedefpaintEvent(self,ev):p=QPainter(self)p.setRenderHint(QPainter.Antialiasing)pen=QPen(Qt.black,1)p.setPen(pen)foreinself.doc.all():ifisinstance(e,Line):p.drawLine(QPointF(e.x1,e.y1),QPointF(e.x2,e.y2))# 拖动过程中的橡皮筋预览ifself._startisnotNoneandself._previewisnotNone:p.setPen(QPen(Qt.gray,1,Qt.DashLine))p.drawLine(self._start,self._preview)这段代码里self.doc.subscribe(self.update)是关键。QWidget.update()本身是槽函数,可以被当普通可调用对象传递,数据一变就排队一次重绘。这样做的好处是:以后不管是谁改了 Document(比如从文件加载、比如撤销),界面都会自动跟上,不需要在每个修改点手动调用update()。这个模式有时候叫观察者,有时候叫信号,名字不重要,重要的是修改数据和刷新界面这两件事被解耦了。
Esc 取消:为什么不能直接删掉正在画的线
有了上面的结构,Esc 取消看起来很简单:按下 Esc,把self._start置空,重绘一次就完事了。因为预览线根本没进 Document,所以不需要"删除"。
但等一下。现在只有画线一个工具,等你有画圆、画多段线、移动对象、选择对象这些命令之后,"当前正在进行的操作"就变成一个状态了。如果每个工具都在 Canvas 里塞几个self._xxx字段,代码会迅速失控。
我的做法是引入一个最小可用的命令状态机。不引入任何框架,就是普通类:
# commands.pyfromabcimportABC,abstractmethodclassCommand(ABC):"""一个命令的生命周期:start -> (可选)update -> commit 或 cancel"""@abstractmethoddefstart(self,pos,canvas):...@abstractmethoddefupdate(self,pos,canvas):...@abstractmethoddefcommit(self,pos,canvas):...defcancel(self,canvas):canvas.preview=None画线命令:
# commands.py 续fromentitiesimportLineclassLineCommand(Command):def__init__(self):self.p1=Nonedefstart(self,pos,canvas):self.p1=posdefupdate(self,pos,canvas):canvas.preview=(self.p1,pos)defcommit(self,pos,canvas):ifself.p1isNone:return# 过滤掉点击式误触(起终点几乎重合)dx,dy=pos.x()-self.p1.x(),pos.y()-self.p1.y()ifdx*dx+dy*dy>4.0:canvas.doc.add(Line(x1=self.p1.x(),y1=self.p1.y(),x2=pos.x(),y2=pos.y()))canvas.preview=Noneself.p1=Nonedefcancel(self,canvas):canvas.preview=Noneself.p1=NoneCanvas 只保留一个self.active: Command | None:
# canvas.py 修改片段defmousePressEvent(self,ev):ifev.button()==Qt.LeftButton:ifself.activeisNone:self.active=LineCommand()self.active.start(ev.position(),self)else:self.active.commit(ev.position(),self)self.active=NonedefmouseMoveEvent(self,ev):ifself.activeisnotNone:self.active.update(ev.position(),self)self.update()defkeyPressEvent(self,ev):ifev.key()==Qt.Key_Escapeandself.activeisnotNone:self.active.cancel(self)self.active=Noneself.update()returnsuper().keyPressEvent(ev)注意这里我把交互改成了"点一下定起点、再点一下定终点"的两点式,而不是按住拖动。原因很实际:CAD 里绝大多数命令都是这种点击流,拖动式在需要连续绘制时反而别扭。这是设计选择,不是对错问题。
【关键结论】Esc 取消真正要做的不是"删除已画的东西",而是"清掉当前命令的中间状态并重绘"。因为中间状态(预览线)从来没有进入 Document,所以不存在数据一致性问题。这个思路在后面的移动、复制、旋转命令里同样适用。
键盘按了没反应:焦点问题
上面keyPressEvent写完,很多人会立刻发现:按 Esc 毫无反应。
这不是代码逻辑问题,是 Qt 的事件分发机制。键盘事件只会发给当前拥有焦点的 widget。QWidget默认的focusPolicy是Qt.NoFocus,也就是说它根本不会主动获取焦点,键盘事件自然不会落到它身上。
我在 Canvas 构造函数里加的self.setFocusPolicy(Qt.StrongFocus)就是为了解决这个。StrongFocus的含义是:既能通过 Tab 键获得焦点,也能通过鼠标点击获得焦点。
但光设置这个还不够。有两种情况仍然会失效:
- 窗口启动后,用户没有点击过画布,焦点可能在别的控件上(比如工具栏的输入框)。
- 焦点被某个子控件抢走了。
对于第一种,可以在窗口显示后主动调用canvas.setFocus()。对于第二种,需要理解 Qt 的焦点链。
还有一个更隐蔽的坑:如果你在 Canvas 上放了子 widget(比如浮动输入框),键盘事件会先给子 widget。子 widget 如果不处理,事件会冒泡到父 widget——但前提是子 widget 没有accept()它。QLineEdit会接受所有按键,所以焦点在输入框上时,Esc 是不会传到画布的。
我处理这个的方式是在输入框上单独装事件过滤器,或者干脆在输入框里也处理 Esc:
classCommandInput(QLineEdit):defkeyPressEvent(self,ev):ifev.key()==Qt.Key_Escape:self.clearFocus()# 焦点还给画布self.parent().setFocus()ev.accept()returnsuper().keyPressEvent(ev)【注意】setFocusPolicy和setFocus()是两件事。前者决定"能不能获得焦点",后者是"现在就把焦点给它"。只写前者不写后者,程序启动时焦点仍然不在画布上。
键盘事件的另一类问题:修饰键
Esc 通了之后,我接着想加个"按住 Shift 画正交线"。写的时候踩到过一个细节:ev.key()和ev.modifiers()是分开的。
defkeyPressEvent(self,ev):ifev.key()==Qt.Key_Escape:...# 判断 Shift 不要用 key(),要用 modifiers()ifev.modifiers()&Qt.ShiftModifier:...原因很直接:按下 Shift 这个动作本身会触发一次keyPressEvent,其key()是Qt.Key_Shift;但当你按住 Shift 再按 A 时,A 的那次事件里key()是Qt.Key_A,modifiers()里才带Qt.ShiftModifier。如果你用key()判断修饰键,逻辑会时对时错。
正交约束本身的实现不复杂,在LineCommand.update里判断一下就行:
defupdate(self,pos,canvas):ifself.p1isNone:returnifcanvas.ortho:# 由 Shift 或 F8 切换dx=pos.x()-self.p1.x()dy=pos.y()-self.p1.y()# 取绝对值大的方向锁定ifabs(dx)>=abs(dy):pos=QPointF(pos.x(),self.p1.y())else:pos=QPointF(self.p1.x(),pos.y())canvas.preview=(self.p1,pos)数据流梳理
到这里,一次完整的画线交互涉及的对象和调用关系大致是:
- Canvas 接收鼠标/键盘事件
- 事件被翻译成对当前 Command 的 start/update/commit/cancel 调用
- Command 在 commit 时向 Document 添加 Entity
- Document 通知所有订阅者(目前只有 Canvas.update)
- Canvas 在 paintEvent 里遍历 Document 渲染
每一层只做一件事,改动的影响范围就清楚了。想加撤销?在 Document 里加一个操作历史栈,commit 时入栈。想加图层?Entity 已经有 layer 字段,Document 里按 layer 过滤渲染即可。想加选择?在 Command 体系里加一个 SelectCommand,它不改 Document 的实体,只改实体的 selected 字段。
关于版本和依赖
这篇文章的代码在 Python 3.11 + PySide6 上验证过。PySide6 的版本我这边是 6.6.x,但上面用到的 API(QPainter.setRenderHint、ev.position()、Qt.StrongFocus、keyPressEvent)都是长期稳定的接口,从 PySide6 6.0 开始就应该没问题。ev.position()返回QPointF,ev.pos()返回QPoint——PySide6 里两者都存在,用position()更精确,因为它带浮点精度。
有一点我没有验证:在 macOS 上,某些系统级快捷键(比如 Cmd+Tab)会先被系统吃掉,程序收不到。这在跨平台 CAD 里是个需要单独处理的问题,我目前没在 macOS 上实测过。
下一步
实体容器、命令状态机、焦点处理这三块属于基础设施,写起来不显眼,但后面每一个功能都会用到。下一步我打算加选择集和撤销栈——选择集需要处理"点击命中检测",撤销栈需要处理命令的逆操作,两者都会反过来考验现在的 Entity 和 Command 设计是否站得住。如果那时候需要大改,说明现在的抽象粒度有问题,反倒是一件好事。
=备用标题=
- Python CAD 开发第二篇:实体容器、命令状态机与 Esc 取消实现
- PySide6 画 CAD 时键盘收不到事件?从焦点机制讲起
- 从零写 CAD:把图形数据从窗口类里拆出来之后
- CAD 交互基础:命令状态机怎么处理 Esc 取消和正交约束
- PySide6 焦点与键盘事件:为什么 Esc 按了没反应