从零写一个CAD 02:实体容器、Esc取消与键盘失灵的排查

第一篇结束时,窗口里能画出一条线,代码大概长这样:一个 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 键获得焦点,也能通过鼠标点击获得焦点。

但光设置这个还不够。有两种情况仍然会失效:

  1. 窗口启动后,用户没有点击过画布,焦点可能在别的控件上(比如工具栏的输入框)。
  2. 焦点被某个子控件抢走了。

对于第一种,可以在窗口显示后主动调用 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 设计是否站得住。如果那时候需要大改,说明现在的抽象粒度有问题,反倒是一件好事。

===备用标题===

  1. Python CAD 开发第二篇:实体容器、命令状态机与 Esc 取消实现
  2. PySide6 画 CAD 时键盘收不到事件?从焦点机制讲起
  3. 从零写 CAD:把图形数据从窗口类里拆出来之后
  4. CAD 交互基础:命令状态机怎么处理 Esc 取消和正交约束
  5. PySide6 焦点与键盘事件:为什么 Esc 按了没反应
相关推荐
硫酸锌011 小时前
在手机上运行Python增量备份手机数据到PC电脑
android·windows·python
沐欣工作室_lvyiyi2 小时前
家庭水资源节能系统设计(论文+源码)
c++·python·云平台·单片机毕业设计·单片机仿真·电子信息工程毕业设计·机械电子工程
FelixZhang0282 小时前
量化求真10|回测通过以后,策略就能上场吗?
人工智能·python·深度学习·学习·机器学习·金融·lstm
Ticnix2 小时前
你的 Agent 聊到第 20 轮就"失忆"?你管理的是历史,高手管理的是上下文
后端·python·agent
Raspberry_Pi_官方账号2 小时前
Raspberry Pi 官方分享:利用 14MB 的 Function-Calling LLM 实现端侧自然语言硬件控制
python·树莓派·raspberry pi
醇氧2 小时前
CentOS7.9 Yum 安装 Redis6.x(RPM 包,无需编译,推荐 remi 源)
linux·python
VidDown2 小时前
文件没问题但网页播不了:播放器接入实战与那些“环境问题
python·网络协议·音视频·视频编解码·视频
智购科技自动售货机工厂3 小时前
数字人民币硬钱包支付失败,排查发现是NFC读卡器功率不足~YH
python·面试·架构·eclipse·emacs
维克兜率天3 小时前
【维克】均值回归:跌多了会涨,涨多了会跌
开发语言·笔记·python·算法·均值算法·回归·量化