pyside的焦点机制, 莫名的复杂, 10-20pyside的选中和焦点

1104补充

  1. 全app只有唯一的焦点, 不论有多少个graphiview/mainwindow/scene/item

  2. 离开编辑状态, 可以有几种处理机制:

    • 用scene.focusItem()兜底

    • 用focusoutevent, 此时至少要分别处理2个:

      1. 用item的focusOutEvent, 他不覆盖:
        • 切换window
        • 切换app
      2. view的focusout, 是一个标准的widget的focusoutevent, 完美覆盖所有情况:

        • 离开本widget
        • 切换mainwindow
        • 切换到别的app

1006 补充

item焦点

# 辅助代码
scene = QGraphicsScene()
view = QGraphicsView(scene)
item = QGraphicsRectItem(0, 0, 100, 100)
scene.addItem(item)
view.show()

Item 和 Widget 的焦点是两条平行线,互不相通, 互不干扰


  1. 开关:唯一决定因素是 ItemIsFocusable 标志
    item.setFlag(QGraphicsRectItem.ItemIsFocusable, True)   # ← 能拿焦点
    item.setFlag(QGraphicsItem.ItemIsFocusable, False)  # 不能拿(默认)
    

    默认 False不打开就永远拿不到焦点

常见 Item 是否默认 ItemIsFocusable 备注
QGraphicsRectItem ❌ 否 纯图形,默认关闭
QGraphicsTextItem ✅ 是 文本可编辑,默认开启
QGraphicsProxyWidget ✅ 是 内嵌 QWidget,默认开启
自己继承的 Item ❌ 否 除非你手动打开标志

  1. 查询:谁拿了 item 焦点?
    who = scene.focusItem()      # 返回 QGraphicsItem*,无时 None
    

  1. 强塞:把焦点塞给某个 item
    item.setFocus()              # 等价于 QWidget 的 setFocus()
    # 若 ItemIsFocusable == False 直接忽略。  
    # 场景会自动把焦点从旧 item 移到新 item,并发出 focusItemChanged 信号。
    

  2. 事件:拿到/失去时想干点事
    item.focusInEvent  = lambda e: print('item got focus') # item 得到焦点
    item.focusOutEvent = lambda e: print('item lost focus') # item 失去焦点
    

  1. 焦点切换顺序(Tab 键), 默认按 创建顺序;自定义顺序:
item1.setData(Qt.UserRole, 0)   # 数字越小越先被 tab 到
item2.setData(Qt.UserRole, 1)
scene.setFocusProxy(None)       # 关闭代理,启用 tab 顺序

也可给每个 item 设 setTabOrder(item1, item2)(Qt 5.14+)。


  1. 与 Widget 焦点互不干扰
    • 场景里 item 有焦点 → 不影响顶层 QApplication.focusWidget()
    • 顶层 widget 焦点切走 → 场景内 item 仍保持自己的焦点
    • 唯一桥梁: QGraphicsProxyWidget ——它同时是 Item + Widget,内部把两套焦点翻译成彼此事件。
    • Item 焦点 = 标志打开 → setFocus() → focusIn/OutEvent → scene.focusItem()
      全程跟 QWidget 的 FocusPolicy/focusWidget() 无交集,各玩各的。

widget焦点

# 辅助代码
app = QApplication([])
btn = QPushButton('Test')
btn.show()
app.exec()

可以分成 4 个层面:「策略-事件-顺序-应用」。


  1. 焦点策略(FocusPolicy)——“widget接不接受焦点”
    | 取值 | 一句话说明 |
    | —————- | ————————————— |
    | Qt.NoFocus | 不接受任何焦点(QLabel 默认) |
    | Qt.TabFocus | 只能按 Tab 键获得 |
    | Qt.ClickFocus | 只能鼠标点击获得 |
    | Qt.StrongFocus | Tab + 点击都可以(按钮、输入框默认) |
    | Qt.WheelFocus | 比 StrongFocus 再多一个“滚轮也能拿焦点” |
btn.setFocusPolicy(Qt.StrongFocus)   # ← 改这里

  1. 焦点事件(QFocusEvent)——“获得/失去瞬间要干嘛”
    btn.focusInEvent  = lambda e: print('got focus') # 拿到焦点时
    btn.focusOutEvent = lambda e: print('lost focus') # 失去焦点时
    

  1. 焦点顺序(Tab Order)——“按 Tab 键的跳转路线”
    默认按 创建顺序;想自定义:
from PySide6.QtWidgets import QLineEdit, QVBoxLayout, QWidget

w = QWidget()
lay = QVBoxLayout(w)
ed1 = QLineEdit(); ed2 = QLineEdit(); ed3 = QLineEdit()
lay.addWidget(ed1); lay.addWidget(ed2); lay.addWidget(ed3)

# 手工把顺序 ed1→ed3→ed2
w.setTabOrder(ed1, ed3)
w.setTabOrder(ed3, ed2)
w.show()

  1. 应用级焦点 API——“全局查询/强塞”
    | 功能 | 函数 | 备注 |
    | ————————– | ————————————- | ——————————————– |
    | 当前拥有键盘焦点的控件 | QApplication.focusWidget() | 无时返回 None |
    | 把焦点硬塞给某控件 | widget.setFocus() | 若窗口被遮挡需先 raise_()+activateWindow() |
    | 让顶级窗口获得焦点 | window.activateWindow() | macOS 上受系统限制可能不生效 |
    | 焦点随鼠标移动 | QApplication.setCursorFlashTime(ms) | 控制光标闪烁速度 |

一行调试:

print('who has focus:', QApplication.focusWidget())

常见坑速查

  • macOS 系统限制activateWindow() 并不能保证把键盘焦点抢过来,只能让窗口标题栏变亮。
  • FramelessWindowHint 后标题栏消失,Tab 拖动/聚焦按钮需自己 setFocusPolicy() 并重写鼠标事件。
  • QGraphicsView 里想让 Item 拿焦点:Item 需设 setFlag(QGraphicsItem.ItemIsFocusable),且场景要先 setFocus()
  • setFocus()隐藏disable 控件无效;先 show() / setEnabled(True)
  • widget 焦点 = 策略(收不收)→ 事件(收/失瞬间)→ 顺序(Tab 路线)→ 全局 API(抢/查)
    记住 4 个关键字:setFocusPolicy / focusIn/OutEvent / setTabOrder / setFocus(),就能覆盖 99% 场景。

tab切换焦点 - 全局废弃此能力

# 方案一: 只要这一条就完全屏蔽了
QShortcut(QKeySequence("Tab"), tab).activated.connect(new_son) 

# 方案二: 
def tab_eater(obj, ev):
    if ev.type() == ev.KeyPress and ev.key() == Qt.Key_Tab:
        # 这里调用你的逻辑
        new_son()
        return True          # 吃掉,不再分发
    return False
app.installEventFilter(tab_eater)

  1. 如果你只想禁某一窗口/控件的 Tab 行为,而不影响全局:
w.setFocusPolicy(Qt.NoFocus)        # 单个 Widget 不参与 Tab 链
# 或者
w.setAttribute(Qt.WA_KeyboardFocusChange, False)

  1. QGraphicsView 内部 Item 的 Tab 也可关:
view.setTabChangesFocus(False)      # 让 QGraphicsView 不再把 Tab 转成 item 焦点切换

09-26原始需求

  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)