【Maya】PySideで検索つきのTableViewをつくる(+小ネタ)

この記事はMaya Python Advent Calendar 2017の14日目の記事です。

前回がアレだったので今回はもうちょっと実用的なネタを書きます!よ!

今回作るもの

f:id:tm8r:20171213220538g:plain
こんなやつを作ります。

ざっくり仕様としてはこんな感じ。

  • 左のメニューアイコンで右のエリアが切り替わる
  • シーン上のファイルノードのサムネイル、名前、ファイルパスを表で出力
  • 表は文字列による絞り込みができる
  • セルを選択すると該当のファイルノードが選択される

メニューボタンをクリックすると表示が切り替わる機構を作る

表示が切り替わる機構を作る

この仕様はQStackedLayoutを利用することで実現できます。

具体的には、QStackedLayoutに対してaddWidgetしていくと、その順番にindexが振られます。

# ホーム用Widgetを追加
self.content_stacked_layout.addWidget(self._create_home_ui())

# ファイルノード用Widgetを追加
self.content_stacked_layout.addWidget(self._create_file_nodes_ui())

このようにすると、ホーム用Widgetには0が、ファイルノード用Widgetには1が振られる、という感じです。

あとはQStackedLayoutのsetCurrentIndexに対して対象のindex番号を渡せば対象のWidgetに表示が切り替わります。べんり。

メニューボタンを作る

f:id:tm8r:20171213231506g:plain
この部分ですが、表示と振る舞いをボタンごとに定義するのは面倒なのでQPushButtonを継承したクラスを作っています。
繰り返し利用する同じ機能を持つWidgetはどんどん継承したクラスを作っていきましょう。

class MenuButton(QtWidgets.QPushButton):
    u"""メニューのボタン"""

    def __init__(self, icon_name, stacked_layout, index, parent=None):
        super(MenuButton, self).__init__(parent=parent)
        self.index = index
        self.setCheckable(True)
        self.setChecked(False)
        self.setFixedWidth(50)
        self.setFixedHeight(50)
        pix_map = QtGui.QPixmap(os.path.join(ICON_DIR, "menu_{0}.png".format(icon_name)))
        icon = QtGui.QIcon(pix_map)
        self.setIcon(icon)
        self.setIconSize(QtCore.QSize(30, 30))
        self.stacked_layout = stacked_layout
        self.setStyleSheet("""
        MenuButton {background-color:#666;}
        MenuButton:checked {background-color:#4f4f4f;}
        """)
        self.checkStateSet()

    # override
    def mouseReleaseEvent(self, event):
        super(MenuButton, self).mouseReleaseEvent(event)
        if not self.isChecked() or not self.stacked_layout:
            return
        self.set_active()

    def set_active(self):
        u"""ボタンを選択状態にし、StackedLayoutのindexを切り替える"""
        self.setChecked(True)
        self.stacked_layout.setCurrentIndex(self.index)

チェックを可能にし、チェック状態の切替はQSSで行っています。
また、mouseReleaseEventメソッドを実装することで、マウスリリース時にチェック状態をみて上で説明したQStackedLayoutのindex切替を行っています。

ただ、このクラスを用いるだけだと、各ボタンがチェック可能になっただけで、いわゆるラジオボタンのような振る舞いをしてくれません。

というわけで、QButtonGroupを以下のように用いて解決します。

menu_group = QtWidgets.QButtonGroup(self)

menu_widget = QtWidgets.QFrame(self)
menu_layout = QtWidgets.QVBoxLayout(menu_widget)

info_button = MenuButton("home", self.content_stacked_layout, 0)
menu_layout.addWidget(info_button)
menu_group.addButton(info_button)
info_button.set_active()

nodes_button = MenuButton("filer", self.content_stacked_layout, 1)
menu_layout.addWidget(nodes_button)
menu_group.addButton(nodes_button)

これで上のgifのような動きをするボタンが作れました。

表を作る

Modelを作る

PySideにはQTableViewという便利なクラスが用意されているので、これを利用するだけで割と簡単に表が作れます。
が、初見だと厄介なのがModelです。

Modelはデータの入出力を担うもので、QTableViewを作ったらModelを渡してやる必要があります。
今回はQTableViewを利用するので、QAbstractTableModelを継承したクラスを用意してみます。

このクラスでは、リファレンスに以下のように書いてある通り、rowCount、columnCount、dataの3つのメソッドを必ず実装する必要があります。

you must implement rowCount(), columnCount(), and data()
http://doc.qt.io/qt-5/qabstracttablemodel.html

rowCountとcolumnCountは見たまま行と列の数を返すメソッドです。
dataは引数にindexをroleを受け、indexは要求されている行列の位置を、roleは要求されているデータの種類を意味します。

というわけで、Itemというオブジェクトの情報を表示したいという前提で素直に実装するとこんな感じになります。

class Item(object):
    def __init__(self, hoge, piyo, fuga):
        self.hoge = hoge
        self.piyo = piyo
        self.fuga = fuga

def data(self, index, role):
    u"""カラムのデータを返す"""
    if not index.isValid():
        return None

    item = self.items[index.row()]
    if role == QtCore.Qt.DisplayRole:
        if index.column() == 1:
            return item.hoge
        elif index.column() == 2:
            return item.piyo
        elif index.column() == 3:
            return item.fuga
    elif role == QtCore.Qt.TextAlignmentRole:
        return int(QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter)
    return None

QtCore.Qt.DisplayRoleがデータの表示に対する要求を意味するので、文字列を返してやります。
QtCore.Qt.TextAlignmentRoleは見ての通り文字の位置揃えを要求するものになります。

QtCore.Qt.DisplayRoleのところを見てみると、index.column()の結果に応じて分岐を書いていて、とてもフィールド追加とか考えたくない感じになっています。

Modelでちょっとマシなデータ管理方法を考えてみる

というわけで、ちょっとマシな管理方法を考えてみます。
具体的には以下のようなコードになります。

こうすることで、モデルのdataメソッドでは特に分岐をする必要がなくなります。やったぜ。

今はcolumnにindexとvalueの2つのフィールドしかありませんが、対応するRoleを増やしたい場合はあわせてフィールドも増やしてやればある程度対応できるのかなーと思います。

Roleについて補足

Roleごとに返却すべき値の型は異なるので、以下を参考にしつつ適切なRoleの分岐の追加を行ってください。
Qt Namespace | Qt Core 5.10

凝ったことをしないならDisplayRoleとTextAlignmentRoleを返しておけばおっけーですが、ToolTipRoleやBackgroundRoleも便利です。

また、自分でRoleを追加したい場合、UserRoleという値が用意されているので、デフォルトのものと被らないよう、この値に加算するようにして使うとよいです。

SORT_ROLE = QtCore.Qt.UserRole + 1

検索とソートを有効にする

これでもう表の出力は出来るんですが、やっぱり検索とかしたいですよね。
というわけでここで登場するのがQSortFilterProxyModelです。

ただただ表を出力するだけならQTableViewにModelを渡してやるだけでしたが、検索やソートも対応する場合、代わりにQSortFilterProxyModelをQTableViewに渡して、QSortFilterProxyModelのsetSourceModelメソッドでModelを渡すというような形になります。

具体的にはこう。

self.model = FileTableModel(self)

# 検索、ソート用のプロキシモデル
self.proxy_model = QtCore.QSortFilterProxyModel()
self.proxy_model.setDynamicSortFilter(True)
self.proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive)
self.proxy_model.setSourceModel(self.model)
self.table_view.setModel(self.proxy_model)

かんたん。

セルを選択したときの振る舞いを定義する

これで検索もソートもできるようになるので大変お手軽でよいのですけど、注意点としては、このプロキシモデルを適用したViewから取得できるindexはソースのModelのindexとは合致しない、というところです。

具体的に言うと、TableViewのセルを選択したときの振る舞いは以下のようにすることで定義できます。

    self.table_view.clicked.connect(self._select_file_node)

(略)
def _select_file_node(self, index):
    # なんか処理

しかし、これで取得できるのはあくまでプロキシモデルにおけるindexなので、ソートや検索フィルターが適用されている場合、ソースのModelのindexとは合致しません。

したがって、以下のようにmapToSouceを噛ましてやることでこの問題を解決できます。

def _select_file_node(self, index):
    index = self.proxy_model.mapToSource(index)
    if not index.isValid():
        return
    model = self.model.items[index.row()]

というわけで、これでソートやフィルタの適用有無に関わらず、セル選択時の正しい振る舞いを定義できるようになりました。

ファイルのサムネイルを表示する

ファイルのサムネイルを表示する際、なんとなくPySideを使ったことある方ならQPixmapが使えそう、となると思います。
実際、Modelのdataメソッドにおいて、DecorationRoleであればQPixmapが返せるのですが、QPixmapは残念ながらpsdやtgaに対応していません。

一体何なら普段使うテクスチャを表示できるんだ…と考えてみると、AttributeEditorによく出ているアレが思い浮かびますね…!
そう、swatchDisplayPortです。

が、Roleの一覧を見てみてもswatchDisplayPortを表示できそうなものはない…そんなときに使えるのがDelegate…!
DelegateはViewにおける表示を細かくカスタマイズすることができます。QWidgetだって返せます。

というわけで出来上がったのがこちらになります。

class SwatchDisplayPortDelegate(QtWidgets.QItemDelegate):
    u"""swatchDisplayPortを表示するDelegateクラス"""

    def __init__(self, parent, items, proxy_model):
        super(SwatchDisplayPortDelegate, self).__init__(parent)
        self.items = items
        self.proxy_model = proxy_model

    # override
    def paint(self, painter, option, index):
        item = self.items[self.proxy_model.mapToSource(index).row()]
        if not self.parent().indexWidget(index) and item.node:
            self.parent().setIndexWidget(
                index,
                _create_swatch_display_port_widget(item.node, self.parent())
            )

    # override
    def sizeHint(self, option, index):
        return QtCore.QSize(64, 64)


def _create_swatch_display_port_widget(file_node, parent):
    u"""swatchDisplayPortをQWidgetでラップしたオブジェクトを返す"""
    # swatchDisplayPortにはレイアウトが必要なので、一時的にwindowを作成して削除する
    tmp = cmds.window()
    cmds.columnLayout()
    sw = cmds.swatchDisplayPort(h=64, w=64, sn=file_node)
    ptr = om.MQtUtil.findControl(sw)
    sw_widget = QtCompat.wrapInstance(long(ptr), QtWidgets.QWidget)
    sw_widget.setParent(parent)
    sw_widget.resize(64, 64)
    cmds.deleteUI(tmp)
    return sw_widget

DelegateはView全体に適用できるのですが、今回は以下のように特定のカラムだけに適用するので、描画を担当するpaintメソッドが割とシンプルになってます。

self.table_view.setItemDelegateForColumn(0,
                                         SwatchDisplayPortDelegate(self.table_view, self.model.items,
                                                                   self.proxy_model))

やっていることは単純ですが、swatchDisplayPortを作るにはレイアウトが必要で、レイアウトにはwindowが必要なので、それぞれ作成して破棄する、というちょっと面倒な処理をしないといけないのがハマりどころ。

その他の小ネタ

水平線を作る

QSSで水平線くらい作れるでしょう、と思ってたんですが、border-bottomが効かなかったのでこんなやつで回避してます。

class QHLine(QtWidgets.QFrame):
    u"""水平線表示要のWidget"""

    def __init__(self):
        u"""initialize"""
        super(QHLine, self).__init__()
        self.setFrameShape(QtWidgets.QFrame.HLine)
        self.setFrameShadow(QtWidgets.QFrame.Sunken)

つらい。

完成

というわけで出来たのが以下のものになります。
github.com

ここまで作れれば、発展して自前のファイルエクスプローラーなんかも作れそうですね!
cmdsによる地獄のようなレイアウトからの卒業に少しでも貢献できれば幸い。

ちなみに以前twitterに上げたこちらは概ね今回書いたようなものを使って作っています。
f:id:tm8r:20171213234116g:plain

おわり

というわけでMaya Python Advent Calendar 2017の14日目の記事でした。
だいぶ駆け足で色々端折ってしまった感が否めないので、また時間があるときにいくつかの記事に分けて紹介できたらなーという気持ちはあります。気持ちは。

他にも色々ネタを考えてはいたんですけど、1記事目からの落差がありそうに感じたのでボツりました。またの機会に。

  • Traxエディタに配置したオーディオファイルを1つのオーディオファイルに結合する
  • File Path Editorを利用した自動パス解決ツール
  • アーティストのためのmel入門
  • PySideでフレームレスウィンドウ

明日はお誕生日のリンゴ酸さんによる恐らくPySideの素敵な記事です。おたのしみに!
qiita.com

スポンサーリンク