PySide で QGraphicsView を使ってみる。
ノードエディタっぽいものを試しにつくってみたので、気になったとことかをいくつかメモっておく。
maya_test/tools/node_editor at master · hal1932/maya_test · GitHub
- 基本設計
- QGraphicsItem が QObject 派生ではない
- QGraphicsItem にイベント実装が足りない
- カスタム描画と当たり判定
- コンテキストメニュー
- イベントハンドラの実装がカオス化する
基本設計
- NodeGraphWidget
- ユーザーに公開するWidget
- GraphicsView
- NodeGraphWidgetで必要な各種UI系イベントをまとめてSignal化する
- NodeGraphScene
- シーン内のGraphicsItemに渡す各種イベントの取りまとめ
- NodeView
- シーン内に配置するノードのベースクラス
- PlugView
- ノード間にエッジを張るための接続点
- EdgeView
- ノード間のエッジ
QGraphicsItem が QObject 派生ではない
class QGraphicsItem(__Shiboken.Object): """ QGraphicsItem(self, parent: PySide2.QtWidgets.QGraphicsItem = None) """ def acceptDrops(self): # real signature unknown; restored from __doc__ """ acceptDrops(self) -> bool """ return False ...
QtCore.pyi をみると分かるんだけど、見ての通り __Shiboken.Object を直接継承してる。
マウスとかの各種イベントは QGraphicsItem やその継承クラスに直接実装されてるからいいんだけど、Signal/Slot が使えないのがちょっと痛い。
C++ の Qt であれば 多重継承で解決する のがどうやら正解っぽいんだけど、Python 自体が多重継承をあまりちゃんとサポートしてくれてない*1ので、とりあえず今回は簡易Signalクラス*2を自前で用意してみた。
これはこれで微妙だけど、とはいえ手動でコンストラクタを1個ずつ叩くのとどっちが汚いかと言われると、正直なんとも言えないところだと思う。
class GraphicsItemSignal(object): def __init__(self, *_): self.__slots = [] def connect(self, slot): self.__slots.append(slot) def disconnect(self, slot): self.__slots.remove(slot) def emit(self, *args): for slot in self.__slots: slot(*args)
QGraphicsItem にイベント実装が足りない
たとえば「マウスオーバーしたPlugをハイライトする」みたいなことを実現するには mouseOverEvent() が必要なんだけど、QGraphicsItem はその実装を持ってない。なので、NodeGraphScene から各 Item に向けてカスタムイベントを投げることにした。
カスタムイベントと言ってもそうたいしたことはなく、マウスポインタの下にいる Items たちを取得して QGraphicsSceneMouseEvent を横流しするだけ。Qt のこういうシンプルさは個人的には嫌いじゃない。*3
class NodeGraphScene(QGraphicsScene): def __init__(self, *args, **kwargs): super(_NodeGraphScene, self).__init__(*args, **kwargs) self.__mouse_overed_items = set() def mouseMoveEvent(self, e): # type: (QGraphicsSceneMouseEvent) -> NoReturn all_items = self.items() over_items = self.items(e.scenePos()) for item in all_items: if item in over_items: if hasattr(item, 'mouseOverEvent'): item.mouseOverEvent(e) self.__mouse_overed_items.add(item) elif item in self.__mouse_overed_items: if hasattr(item, 'mouseLeaveEvent'): item.mouseLeaveEvent(e) self.__mouse_overed_items.remove(item) super(_NodeGraphScene, self).mouseMoveEvent(e)
class PlugView(QGraphicsEllipseItem): def mouseOverEvent(self, e): # type: (QGraphicsSceneMouseEvent) -> NoReturn self.setBrush(ItemStyles.PLUG_BACKGROUND_TARGET) def mouseLeaveEvent(self, e): # type: (QGraphicsSceneMouseEvent) -> NoReturn self.setBrush(ItemStyles.PLUG_BACKGROUND_NORMAL)
カスタム描画と当たり判定
Maya とかのノードエディタもそうなんだけど、ふつうこういうのは Node の種類や Plug の個数によって描画範囲が変わってくる。
カスタム描画自体は例によって paint() をオーバーライドしてやればいいんだけど、今回は「マウスクリックでノードを選択する」とかの挙動も実装する必要があるから、boundingRect() も一緒にオーバーライドしてやらないといけない。
class QGraphicsRectItem(QAbstractGraphicsShapeItem): def boundingRect(self): # real signature unknown; restored from __doc__ """ boundingRect(self) -> PySide2.QtCore.QRectF """ pass
boundingRect() と paint() とは本来まったく異なる文脈で呼び出される関数ではあるんだけど、今回のケースでは、当たり判定の範囲が描画結果に完全に依存する。ので、paint() の中で当たり判定も一緒に計算しておくことにした。
class NodeView(QGraphicsRectItem): def boundingRect(self): # type: () -> QRectF return self.__bounding_rect def paint(self, painter, item, widget): # type: (QPainter, QStyleOptionGraphicsItem, QWidget) -> NoReturn painter.drawText(...) painter.drawRoundedRect(...) self.__bounding_rect.setRect(x, y, w, h)
コンテキストメニュー
Scene 上の「何もない場所」で右クリックしたときはノード生成メニューを出したいけど、Node を右クリックしたときは Node 用のメニューを出したい。
これが微妙に面倒だった。シーン上に Item を配置してるときに、Scene 側で contextMenuEvent() の実装をしてしまうと、Item を右クリックしたときに Item ではなく Scene の contextMenuEvent() が呼び出されるようになってしまう。
どうやら Qt 内では Item → Scene の順に bubbling が行われているのではなく、Scene.contextMenuEvent() の中で Item.contextMenuEvent() を呼び出す実装になってるらしい。*4
そういうことであれば回避は簡単で、オーバーライドした Scene.contextMenuEvent() の中で、右クリックした位置に Item がある場合は本来の contextMenuEvent() を呼んであげればいい。
class NodeGraphScene(QGraphicsScene): def contextMenuEvent(self, e): # type: (QGraphicsSceneContextMenuEvent) -> NoReturn item = self.itemAt(e.scenePos(), QTransform()) if item is not None: super(NodeGraphScene, self).contextMenuEvent(e) return # Scene側のMenu生成処理 menu = QMenu() ...
Item が折り重なってるような場合が厄介な気がするけど、そのへんはたぶん zValue をいいかんじにコントロールしてやればどうにかなる気がする。