第一篇结束时,窗口里能画出一条线,代码大概长这样:一个 QMainWindow 子类,重写 mousePressEvent 和 mouseReleaseEvent,在按下时记一个起点,在松开时把线段塞进一个 self.lines 列表,然后在 paintEvent 里遍历这个列表画出来。
能跑,但只能跑一次。只要你想加第二个工具、想撤销、想保存文件,就会发现所有东西都黏在窗口类里。这一篇就处理三件事:把图形数据从窗口里搬出来、让 Esc 能取消正在进行的操作、以及搞清楚为什么键盘事件有时候压根收不到。
先把图形数据从窗口类里搬出来
我一开始的想法很简单:self.lines 换成 self.entities,名字好听点。但真正写下去会发现,问题不在名字,在于窗口类同时承担了三种职责:接收输入、维护数据、负责渲染。这三件事的变更频率完全不同,混在一起之后,每加一个功能都要动同一个文件。
所以第一步是把"实体"这个概念单独定义出来。CAD 里的基本图元无非是点、线、圆、圆弧,它们共享一些属性(颜色、图层、是否选中),又有各自不同的几何参数。用继承是最自然的写法:
python
# entities.py
# Python 3.11, 无第三方依赖
from dataclasses import dataclass, field
from typing import Literal
@dataclass
class Entity:
"""所有图元的基类,只放通用属性"""
color: str = "#000000"
layer: str = "0"
selected: bool = False
def bounds(self) -> tuple[float, float, float, float]:
"""返回 (minx, miny, maxx, maxy),用于后续的框选和重绘优化"""
raise NotImplementedError
@dataclass
class Line(Entity):
x1: float = 0.0
y1: float = 0.0
x2: float = 0.0
y2: float = 0.0
def bounds(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。
接下来是容器。容器要解决的核心问题是:谁持有实体列表,谁负责通知界面刷新。我的做法是让容器只做数据管理,通过一个简单的信号机制通知外部:
python
# document.py
from entities import Entity
class Document:
def __init__(self):
self._entities: list[Entity] = []
self._listeners: list = []
def add(self, entity: Entity) -> None:
self._entities.append(entity)
self._notify()
def remove(self, entity: Entity) -> None:
# 用 identity 比较而不是 ==,因为 dataclass 默认按值相等
for i, e in enumerate(self._entities):
if e is entity:
del self._entities[i]
self._notify()
return
def all(self) -> tuple[Entity, ...]:
return tuple(self._entities)
def subscribe(self, fn) -> None:
self._listeners.append(fn)
def _notify(self) -> None:
for fn in self._listeners:
fn()
【踩坑提醒】remove 这里我用的是 e is entity 而不是 e == entity。因为 @dataclass 默认生成 __eq__,两条坐标完全相同的线在 == 下是相等的。如果你用 == 去删,删掉的可能不是你点的那个对象------在撤销、图层删除这些场景下,这个区别会变成很难查的 bug。
现在窗口类只负责"把鼠标事件翻译成对 Document 的调用":
python
# canvas.py
from PySide6.QtWidgets import QWidget
from PySide6.QtGui import QPainter, QPen
from PySide6.QtCore import Qt, QPointF
from document import Document
from entities import Line
class Canvas(QWidget):
def __init__(self, doc: Document, parent=None):
super().__init__(parent)
self.doc = doc
self.doc.subscribe(self.update) # 数据一变就重绘
self._start: QPointF | None = None
self._preview: QPointF | None = None
self.setMouseTracking(True)
self.setFocusPolicy(Qt.StrongFocus) # 后面讲键盘要用到
def mousePressEvent(self, ev):
if ev.button() == Qt.LeftButton:
self._start = ev.position()
self._preview = self._start
def mouseMoveEvent(self, ev):
if self._start is not None:
self._preview = ev.position()
self.update()
def mouseReleaseEvent(self, ev):
if ev.button() == Qt.LeftButton and self._start is not None:
self.doc.add(Line(x1=self._start.x(), y1=self._start.y(),
x2=ev.position().x(), y2=ev.position().y()))
self._start = None
self._preview = None
def paintEvent(self, ev):
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
pen = QPen(Qt.black, 1)
p.setPen(pen)
for e in self.doc.all():
if isinstance(e, Line):
p.drawLine(QPointF(e.x1, e.y1), QPointF(e.x2, e.y2))
# 拖动过程中的橡皮筋预览
if self._start is not None and self._preview is not None:
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 字段,代码会迅速失控。
我的做法是引入一个最小可用的命令状态机。不引入任何框架,就是普通类:
python
# commands.py
from abc import ABC, abstractmethod
class Command(ABC):
"""一个命令的生命周期:start -> (可选)update -> commit 或 cancel"""
@abstractmethod
def start(self, pos, canvas): ...
@abstractmethod
def update(self, pos, canvas): ...
@abstractmethod
def commit(self, pos, canvas): ...
def cancel(self, canvas):
canvas.preview = None
画线命令:
python
# commands.py 续
from entities import Line
class LineCommand(Command):
def __init__(self):
self.p1 = None
def start(self, pos, canvas):
self.p1 = pos
def update(self, pos, canvas):
canvas.preview = (self.p1, pos)
def commit(self, pos, canvas):
if self.p1 is None:
return
# 过滤掉点击式误触(起终点几乎重合)
dx, dy = pos.x() - self.p1.x(), pos.y() - self.p1.y()
if dx * 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 = None
self.p1 = None
def cancel(self, canvas):
canvas.preview = None
self.p1 = None
Canvas 只保留一个 self.active: Command | None:
python
# canvas.py 修改片段
def mousePressEvent(self, ev):
if ev.button() == Qt.LeftButton:
if self.active is None:
self.active = LineCommand()
self.active.start(ev.position(), self)
else:
self.active.commit(ev.position(), self)
self.active = None
def mouseMoveEvent(self, ev):
if self.active is not None:
self.active.update(ev.position(), self)
self.update()
def keyPressEvent(self, ev):
if ev.key() == Qt.Key_Escape and self.active is not None:
self.active.cancel(self)
self.active = None
self.update()
return
super().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:
python
class CommandInput(QLineEdit):
def keyPressEvent(self, ev):
if ev.key() == Qt.Key_Escape:
self.clearFocus() # 焦点还给画布
self.parent().setFocus()
ev.accept()
return
super().keyPressEvent(ev)
【注意】setFocusPolicy 和 setFocus() 是两件事。前者决定"能不能获得焦点",后者是"现在就把焦点给它"。只写前者不写后者,程序启动时焦点仍然不在画布上。
键盘事件的另一类问题:修饰键

Esc 通了之后,我接着想加个"按住 Shift 画正交线"。写的时候踩到过一个细节:ev.key() 和 ev.modifiers() 是分开的。
python
def keyPressEvent(self, ev):
if ev.key() == Qt.Key_Escape:
...
# 判断 Shift 不要用 key(),要用 modifiers()
if ev.modifiers() & Qt.ShiftModifier:
...
原因很直接:按下 Shift 这个动作本身会触发一次 keyPressEvent,其 key() 是 Qt.Key_Shift;但当你按住 Shift 再按 A 时,A 的那次事件里 key() 是 Qt.Key_A,modifiers() 里才带 Qt.ShiftModifier。如果你用 key() 判断修饰键,逻辑会时对时错。
正交约束本身的实现不复杂,在 LineCommand.update 里判断一下就行:
python
def update(self, pos, canvas):
if self.p1 is None:
return
if canvas.ortho: # 由 Shift 或 F8 切换
dx = pos.x() - self.p1.x()
dy = pos.y() - self.p1.y()
# 取绝对值大的方向锁定
if abs(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 按了没反应