pyside的焦点机制, 莫名的复杂

原始需求

  1. 界面上有很多可修改文本, 这些文本旁边会有根据文本生成的图标, 比如: 重要, 已完成…..

此时解决方案是定制一个QGraphicsItem, 内部有一个QGraphicsTextItem接文本和一个QPixmap展示图标

然而此时焦点有问题

  1. 点击QGraphicsTextItem会直接进入编辑状态, 但是QGraphicsItem不会有focusinevent触发.
  2. 如果用QGraphicsItem接收鼠标点击, 然后直接转给QGraphicsTextItem触发focus, 那么, QGraphicsItem依旧不会有focusinevent触发.

此时有3个解决方案:

方案一:

  1. 保证焦点到item不到textitem
    self.setFlag(QGraphicsItem.ItemIsFocusable, True)  # 允许接收焦点
    self.setFocusPolicy(Qt.StrongFocus)  # 设置焦点策略
    def mousePressEvent(self, ev):
     if ev.button() == Qt.LeftButton:
         self.setFocus(Qt.MouseFocusReason)  # 只设置TextIconNode的焦点
     super().mousePressEvent(ev)
    
  2. 在focusinevent传递焦点
    def focusInEvent(self, event: QFocusEvent):
     print("TextIconNode: focusInEvent")
     self._editing = True
     self.text_item.setTextInteractionFlags(Qt.TextEditorInteraction)  # 启用编辑
     self.text_item.setFocus()  # 将焦点传递给text_item
     self.update()
     super().focusInEvent(event)
    

方案二:

在textitem中监听焦点

class CustomTextItem(QGraphicsTextItem):
    def __init__(self, text, parent):
        super().__init__(text, parent)
        self.parent = parent

    def focusInEvent(self, event):
        print("CustomTextItem: focusInEvent")
        self.parent._editing = True
        self.parent.update()
        super().focusInEvent(event)
    
    def focusOutEvent(self, event):
        print("CustomTextItem: focusOutEvent")
        self.parent._editing = False
        self.parent.update()
        super().focusOutEvent(event)

class TextIconNode(QGraphicsItem):
    def __init__(self, text, icon_path, right_align=True):
        super().__init__()
        self.right_align = right_align
        self.icon = QPixmap(icon_path).scaled(ICON_W, ICON_W, Qt.KeepAspectRatio)
        self.text_item = CustomTextItem(text, self)  # 使用自定义TextItem
        self.text_item.setFont(QFont("Arial", 11))
        self.text_item.setTextInteractionFlags(Qt.TextEditorInteraction)
        self.text_item.document().contentsChanged.connect(self._update_pos)
        self._update_pos()
        self.setAcceptedMouseButtons(Qt.LeftButton)
        self.text_item.setAcceptedMouseButtons(Qt.NoButton)
        self._editing = False

方案三:

在scene的层面统一处理焦点

def _on_focus_changed(self, new, old, reason):
    if new in (self, self.text_item):
        self._editing = True
    else:
        self._editing = False
    self.update()
* 注意, 这里最佳实践不是每个item去connect, 而是scene统一处理
# 在 scene 初始化时接一次即可
scene.focusItemChanged.connect(on_global_focus)
def on_global_focus(new_item, old_item, reason):
    # 1. 只关心“有数据”的节点
    node = new_item.data(0) if new_item else None
    if not node:
        return
    # 2. 统一视觉:让数据层告诉 UI 层
    node.set_highlight(True)   # 你的数据对象
    if old_item:
        old_node = old_item.data(0)
        if old_node:
            old_node.set_highlight(False)
# 构造item时塞个数据
self.setData(0, self)   # 或塞你的数据对象

然而此时发现一个大问题: 轻触touch和点击click, 表现不同

针对这样的代码做个测试:

import time
from pathlib import Path

from PySide6.QtCore import QRectF, Qt
from PySide6.QtGui import QColor, QFocusEvent, QFont, QPen, QPixmap
from PySide6.QtWidgets import QApplication, QGraphicsItem, QGraphicsScene, QGraphicsTextItem, QGraphicsView

MAX_W = 180
MARG  = 4
ICON_W= 16
GAP   = 6                 # 图标→文字间隙

class TextIconNode(QGraphicsItem):
    def __init__(self, text, icon_path, right_align=True):
        super().__init__()
        self.right_align = right_align
        # 图标
        self.icon = QPixmap(icon_path).scaled(ICON_W, ICON_W, Qt.KeepAspectRatio)
        # 文字
        self.text_item = QGraphicsTextItem(text, self)
        self.text_item.setFont(QFont("Arial", 11))
        self.text_item.setTextInteractionFlags(Qt.TextEditorInteraction)
        self.text_item.document().contentsChanged.connect(self._update_pos)
        self.setAcceptedMouseButtons(Qt.LeftButton)
        self.text_item.setAcceptedMouseButtons(Qt.NoButton)
        self.setFlag(QGraphicsItem.ItemIsFocusable, True)
        self.text_item.setFlag(QGraphicsItem.ItemIsFocusable, True)
        self._editing = False

    # 几何:包含图标 + 文字 + 边距
    def boundingRect(self):
        w = ICON_W + GAP + self.text_item.document().size().width()
        h = max(ICON_W, self.text_item.document().size().height())
        return QRectF(0, 0, w, h).adjusted(-MARG, -MARG, MARG, MARG)

    # 黑底黄字 <-> 透明底白字
    def paint(self, p, _1, _2):
        print(f"更新节点: {self.text_item.toPlainText()}paint", self._editing)
        r = self.boundingRect()
        if self._editing:
            p.fillRect(r, QColor("#222"))
            self.text_item.setDefaultTextColor(QColor("yellow"))
        else:
            p.fillRect(r, QColor(0,0,0,0))   # 透明
            self.text_item.setDefaultTextColor(QColor("white"))
        # 画图标
        p.drawPixmap(20, (r.height()-ICON_W)/2, self.icon)
    def mousePressEvent(self, ev):
        # 写这个, 如果用点的不是textitem而是图标, 那么就生效了.
        print("press at", ev.pos(), flush=True)
        #print("scene items:", self.scene().items())
        print("accepted:", self.acceptedMouseButtons())
        if ev.button() == Qt.LeftButton:
                self.setFocus(Qt.MouseFocusReason)
                print(f"Focus item after setFocus: {self.scene().focusItem()}")
                self.text_item.setFocus()
        super().mousePressEvent(ev)
    
    def focusInEvent(self, event: QFocusEvent):
        print("In ", time.time(), flush=True)   # flush 强制立即输出
        # 拿令牌后的视觉处理
        self._editing = True
        self.update()
        super().focusInEvent(event)

    def focusOutEvent(self, event: QFocusEvent):
        print("OUt ", time.time(), flush=True)   # flush 强制立即输出
        # 交令牌后的视觉擦除
        self._editing = False
        self.update()
        super().focusOutEvent(event)  

# -------------------- demo --------------------
if __name__ == "__main__":
    app = QApplication([])
    scene = QGraphicsScene()
    scene.focusItemChanged.connect(lambda new, old, reason: print(f"Focus changed to: {new}, Reason: {reason}"))
    view = QGraphicsView(scene)
    view.setWindowTitle("TextIconNode - 右对齐+图标不覆盖")
    view.resize(500, 200)

    _path = Path(__file__).with_name("占位.svg")
    
    n1 = TextIconNode("短文本", _path, right_align=True)
    n2 = TextIconNode("很长很长很长很长很长很长很长很长的文字", _path, right_align=True)
    n1.setPos(250, 50)
    n2.setPos(250, 100)
    scene.addItem(n1)
    scene.addItem(n2)
    view.show()
    app.exec()

发现: qt支持macos的轻点touch(无声)和重点click(有鼠标滴答点击声), 也就是轻轻地触碰, 和点击响应, 如果是轻轻触碰, 就会唤醒focusinevent, 如果是重的点击就是进textedit的编辑状态.

  • grok给出了官方文档:
    • https://doc.qt.io/qt-6/qgraphicsscene.html#focusOnTouch-prop
    • https://doc.qt.io/qtforpython-6/PySide6/QtWidgets/QGraphicsScene.html#focusOnTouch-prop
    • Qt 6.9.2 QGraphicsScene文档()描述macOS trackpad“tap”(轻触,等同轻点)生成合成mouse click,导致焦点转移到item(触发focusInEvent),默认focusOnTouch=true。重按(full press)激活QGraphicsTextItem编辑模式(),因setTextInteractionFlags(Qt.TextEditorInteraction)。轻触仅focus,重按编辑。设置scene.setFocusOnTouch(False)可禁用tap焦点。

此时前面的3个方案有高下了

搞笑的是, grok和kimi都推荐3scene方案, 实际这个根本不行, 因为textitem抢到焦点之后, 啥样式都没了. 因此, 唯一的方案是: 2. 扩充textitem. 下面是可以运行的样例代码.

import time
from pathlib import Path

from PySide6.QtCore import QRectF, Qt
from PySide6.QtGui import QBrush, QColor, QFocusEvent, QFont, QPen, QPixmap
from PySide6.QtWidgets import QApplication, QGraphicsItem, QGraphicsRectItem, QGraphicsScene, QGraphicsTextItem, QGraphicsView

MAX_W = 180
MARG  = 4
ICON_W= 16
GAP   = 6                 # 图标→文字间隙

class CustomTextItem(QGraphicsTextItem):
    def __init__(self, text, parent):
        super().__init__(text, parent)
        self.parent = parent
        self._editing = False

    def focusInEvent(self, event):
        self._editing = True
        self.update_style()
        super().focusInEvent(event)

    def focusOutEvent(self, event):
        self._editing = False
        self.update_style()
        super().focusOutEvent(event)

    def update_style(self):
        if self._editing:
            self.setDefaultTextColor(QColor("yellow"))
            self.parent.bg_rect.setBrush(QBrush(QColor("#222")))
        else:
            self.setDefaultTextColor(QColor("white"))
            self.parent.bg_rect.setBrush(QBrush(QColor(0, 0, 0, 0)))  # 透明



class TextIconNode(QGraphicsItem):
    def __init__(self, text, icon_path, right_align=True):
        super().__init__()
        self.right_align = right_align
        # 1. 背景矩形(最先创建,zValue 默认 0)
        self.bg_rect = QGraphicsRectItem(self)
        self.bg_rect.setPen(Qt.NoPen)          # 不要边框
        self.bg_rect.setBrush(QBrush(QColor(0, 0, 0, 0)))
        # 图标
        self.icon = QPixmap(icon_path).scaled(ICON_W, ICON_W, Qt.KeepAspectRatio)
        # 文字
        text=self.text_item = CustomTextItem(text, self)
        text.setFont(QFont("Arial", 11))
        text.setTextInteractionFlags(Qt.TextEditorInteraction)
        text.document().contentsChanged.connect(self._update_pos)
        self._update_pos()            # 初次摆位
        text.setFlag(QGraphicsItem.ItemIsFocusable, True)
        text.setAcceptedMouseButtons(Qt.LeftButton)  # 让它可点

 
        self._editing = False

    # 文字宽度限制 + 摆位
    def _update_pos(self):
        doc = self.text_item.document()
        doc.setTextWidth(-1)
        if doc.idealWidth() > MAX_W:
            doc.setTextWidth(MAX_W)
        else:
            doc.setTextWidth(-1)
        w = doc.size().width()
        # 文字起始 x = 图标右侧 + 间隙
        x_text = ICON_W + GAP
        # 右对齐:把文字右边缘对齐到节点右侧
        if self.right_align:
            x_text -= w                 # 负偏移 → 右边缘贴齐
        self.text_item.setPos(x_text, 0)
        # 同步背景矩形尺寸, 这里错了, 因为上面已经是负数了, 还是正数合理.
        r = self.boundingRect()         
        self.bg_rect.setRect(r)


        
    # 几何:包含图标 + 文字 + 边距
    def boundingRect(self):
        w = ICON_W + GAP + self.text_item.document().size().width()
        h = max(ICON_W, self.text_item.document().size().height())
        return QRectF(0, 0, w, h)#.adjusted(-MARG, -MARG, MARG, MARG)

    def paint(self, p, _1, _2):
        print(f"更新节点: {self.text_item.toPlainText()}paint", self._editing)
        r = self.boundingRect()

        # 画图标
        p.drawPixmap(20, (r.height()-ICON_W)/2, self.icon)
        #p.drawPixmap(20, self.icon_y, self.icon)   # x=0 固定,y 已居中


    def mousePressEvent(self, ev):
        # 写这个, 如果用点的不是textitem而是图标, 那么就生效了.
        print("press at", ev.pos(), flush=True)
        if ev.button() == Qt.LeftButton:
                self.text_item.setFocus()
        super().mousePressEvent(ev)
    




# -------------------- demo --------------------
if __name__ == "__main__":
    app = QApplication([])
    scene = QGraphicsScene()
    scene.focusItemChanged.connect(lambda new, old, reason: print(f"Focus changed to: {new}, Reason: {reason}"))
    view = QGraphicsView(scene)
    view.setWindowTitle("TextIconNode - 右对齐+图标不覆盖")
    view.resize(500, 200)

    _path = Path(__file__).with_name("占位.svg")
    
    n1 = TextIconNode("短文本", _path, right_align=True)
    n2 = TextIconNode("很长很长很长很长很长很长很长很长的文字", _path, right_align=True)
    n1.setPos(250, 50)
    n2.setPos(250, 100)
    scene.addItem(n1)
    scene.addItem(n2)

    view.show()
    app.exec()

并没有结束, 焦点还有两个大问题

  1. tab切换后焦点留在了tab, 没有到view

    这个容易解决, 自己写tab事件
    currentChanged
    focusInEvent

  2. app切走再切回, 焦点就不对了, 这是个大问题

焦点切回问题

当应用失去焦点再切回来时,QGraphicsView(或其中的 item)没有正确恢复焦点状态,导致:

  1. 方向键不再作用于 item,而是作用于整个 view(scene 移动);
  2. 正在编辑的 item(比如一个文本节点)没有正常结束编辑状态
  3. 导致内容丢失、undo 栈未压入、状态错乱。

解决方案:

监听应用焦点变化,强制提交编辑状态

在主窗口或 QGraphicsView 中监听 QApplication.focusChangedQEvent.ApplicationActivate/Deactivate在失去焦点时强制提交当前编辑状态

from PySide6.QtCore import QObject, QEvent
from PySide6.QtWidgets import QApplication

class FocusWatcher(QObject):
    def eventFilter(self, obj, event):
        if event.type() == QEvent.Type.ApplicationDeactivate:
            # 强制提交当前编辑状态
            view = obj  # 假设 obj 是你的 QGraphicsView
            if view and view.scene():
                for item in view.scene().selectedItems():
                    if hasattr(item, "commitEditing"):
                        item.commitEditing()  # 你需要在 item 中实现这个方法
        return super().eventFilter(obj, event)

# 安装事件过滤器
watcher = FocusWatcher()
app.installEventFilter(watcher)

# 在item中实现commitEditing

class MyTextItem(QGraphicsTextItem):
    def commitEditing(self):
        if self.textInteractionFlags() & Qt.TextInteractionFlag.TextEditable:
            self.setTextInteractionFlags(Qt.TextInteractionFlag.NoTextInteraction)
            # 这会触发 focusOutEvent,自动提交内容


# 3. 还可以切回时恢复焦点


self._last_editing_item = None

def commitEditing(self):
    for item in self.scene().items():
        if isinstance(item, MyTextItem) and item.hasFocus():
            self._last_editing_item = item
            item.commitEditing()

def restoreEditing(self):
    if self._last_editing_item:
        self._last_editing_item.setFocus()
        self._last_editing_item.setTextInteractionFlags(Qt.TextInteractionFlag.TextEditable)

所以:

  • 给所有可编辑 item 加一个 commitEditing() 方法;
  • ApplicationDeactivate 时统一调用;
  • 如果用户体验需要,可以在 ApplicationActivate 时恢复编辑状态;
  • 不要依赖 Qt 的“自动恢复”,它只恢复widget 焦点,不恢复item 状态

更简洁的写法:

def _commit(_o, _e):
    if _e.type() == QEvent.Type.ApplicationDeactivate:
        for it in view.scene().items():
            if isinstance(it, TextItem) and it.textInteractionFlags() & Qt.TextInteractionFlag.TextEditable:
                it.commitEditing()
    return False

app.installEventFilter(_commit)