Pyside的焦点
pyside的焦点机制, 莫名的复杂
原始需求
- 界面上有很多可修改文本, 这些文本旁边会有根据文本生成的图标, 比如: 重要, 已完成…..
此时解决方案是定制一个QGraphicsItem, 内部有一个QGraphicsTextItem接文本和一个QPixmap展示图标
然而此时焦点有问题
- 点击QGraphicsTextItem会直接进入编辑状态, 但是QGraphicsItem不会有focusinevent触发.
- 如果用QGraphicsItem接收鼠标点击, 然后直接转给QGraphicsTextItem触发focus, 那么, QGraphicsItem依旧不会有focusinevent触发.
此时有3个解决方案:
方案一:
- 保证焦点到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)
- 在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()
并没有结束, 焦点还有两个大问题
-
tab切换后焦点留在了tab, 没有到view
这个容易解决, 自己写tab事件
currentChanged
focusInEvent -
app切走再切回, 焦点就不对了, 这是个大问题
焦点切回问题
当应用失去焦点再切回来时,QGraphicsView(或其中的 item)没有正确恢复焦点状态,导致:
- 方向键不再作用于 item,而是作用于整个 view(scene 移动);
- 正在编辑的 item(比如一个文本节点)没有正常结束编辑状态;
- 导致内容丢失、undo 栈未压入、状态错乱。
解决方案:
监听应用焦点变化,强制提交编辑状态
在主窗口或 QGraphicsView 中监听 QApplication.focusChanged
或 QEvent.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)