クラス継承を使ってPySideのUIに統一感を与える

こちらは[Maya Advent Calendar 2018](https://qiita.com/advent-calendar/2018/maya) 12月18日の記事です。

# 概要
この記事の目的は、「クラス継承を使うことでUIの統一感と実装効率を高めよう」です。

ツールを多く作っていると見た目に不統一感が生まれてくる事はありませんか？
「今回はアレを試そう！」「今回は時間がないな…」といった様々な事情があるわけです。
そんなとき、独自の標準ウィンドウ基盤を用意すると楽になるかもしれません。

## 全体像
1. QMainWindowを継承したQMayaBasicWindowを用意する
2. QMayaBasicWindowに汎用機能を追加する
3. QWidget用のMixInを作成して拡張機能を提供する
4. 用意した基盤クラスを継承してサンプルウィンドウを実装する


# ウィンドウの統一性とは？
まず初めに「ウィンドウの統一性」についてイメージを合わせておきたいと思います。
![option_window.png](https://qiita-image-store.s3.amazonaws.com/0/21705/dc38397c-9d08-e2aa-840b-440ec7dedc99.png)
上図はMayaのオプションウィンドウですが、画面構成は次のようになっています。

- 上段：Menubar
- 中段：Parameter
- 下段：Button

オプションウィンドウは多くの種類がありますが、決まった構成で作られていて統一感があると感じられます。
本記事では統一感を与える方法の一つとしてMenubarを実装したいと思います。

# 統一感をMenubarで高める
## 必要なMenubarActionは？
MenubarにはMenubarActionが存在するわけですが、
汎用的なMenubarActionを実装するとなれば何が必要でしょうか？
個人的に全てのツールに欲しいと思った下記Actionを実装することにします。

- ヘルプ表示（内包ドキュメントやインターネットページなどを表示する）
- 更新履歴表示（違和感を感じたときにすぐ見れるようにしておきたい）
- バージョン情報表示（ウィンドウタイトルなどに書くと見切れる）
- 開発者へのフィードバック（要望や不具合をレポートするときに）
- ユーザー設定セーブ（オプションウィンドウにもSave Settingsがある）
- ユーザー設定ロード（オプションウィンドウにもLoad Settingsがある）



## Menubar実装
汎用MenubarActionを独自ウィンドウ基盤に組み込んでいきます。
QMainWindowを継承したUiMayaBasicWindowを作成します。

```python:uimayabasicwindow.py
class UiMayaBasicWindow(QMainWindow):
    def __init__(self, parent=None):
        super(UiMayaBasicWindow, self).__init__(parent)

    def create_basic_menu(self):
        self.ui_menu_edit = self.menuBar().addMenu("Edit")
        self.ui_menu_help = self.menuBar().addMenu("Help")
        self.create_menu_actions()

    def create_menu_action(self, label, caption, func, path_icon=None, checkable=False):
        action = QAction(label, self)
        if path_icon is not None:
            action.setIcon(QIcon(path_icon))
        action.setStatusTip(caption)
        action.triggered.connect(func)
        action.setCheckable(checkable)

        return action

    def create_menu_actions(self):
        edit_actions = []
        edit_actions.append(self.create_menu_action(
            "Save Settings", u"ユーザー設定を保存します", self._ui_menu_save_settings))
        edit_actions.append(self.create_menu_action(
            "Reset Settings", u"ユーザー設定をロードします", self._ui_menu_reset_setttings))

        help_actions = []
        help_actions.append(self.create_menu_action(
            "About", u"概要を表示します", self._ui_menu_show_about))
        help_actions.append(self.create_menu_action(
            "Help", u"ヘルプを表示します", self._ui_menu_show_help))
        help_actions.append(self.create_menu_action(
            "ChangeLog", u"更新履歴を表示します", self._ui_menu_show_changelog))
        help_actions.append(self.create_menu_action(
            "Feedback", u"要望や問題を報告します", self._ui_menu_do_feedback))

        self.ui_menu_edit.addActions(edit_actions)
        self.ui_menu_help.addActions(help_actions)

    def _ui_menu_save_settings(self):
        pass

    def _ui_menu_reset_setttings(self):
        pass

    def _ui_menu_show_about(self):
        pass

    def _ui_menu_show_help(self):
        pass

    def _ui_menu_show_changelog(self):
        pass

    def _ui_menu_do_feedback(self):
        pass

```

# 汎用機能の追加
GUIツールで汎用的に使う機能としてメッセージボックスなどのダイアログがあります。
こういった機能も毎回インポートせずに独自ウィンドウ基盤に組み込んでしまいます。

- cmds.confirmDialog（OK, Yes/Noなど）
- cmds.fileDialog2（任意ファイル選択、フォルダ選択など）
- cmds.inViewMessage（カラー別など）

```python:mayadialog.py
class MayaDialog():
    BTN_OK = "OK"

    def _get_icon_text(self, q, i, w, c):
        """メッセージ表示アイコン文字列を取得します"""
        if q:
            return "question"
        if i:
            return "information"
        if w:
            return "warning"
        if c:
            return "critical"
        
        return ""

    def confirm_ok(self, message, title="Confirm", q=False, i=False, w=False, c=False):
        """確認メッセージを表示（Ok）"""
        return cmds.confirmDialog(
            t=title,
            m=message,
            button=[self.BTN_OK],
            db=self.BTN_OK,
            cb=self.BTN_OK,
            ds=self.BTN_OK,
            icn=self._get_icon_text(q, i, w, c)
        ) == self.BTN_OK

# 以下省略
```

```python:uimayabasicwindow.py
class UiMayaBasicWindow(QMainWindow):
    def __self__(self, parent=None):
        super(UiMayaBasicWindow, self).__init__(parent)
        self.dialog = MayaDialog() # 追記

# 以下省略
```

# 拡張機能の追加
継承を使った拡張方法の一つにMixinを使う方法があります。
例えば、QMainWindowのMixinを作成し、dockWidgetの制御メソッドを追加するなど。
本記事ではより簡単な例として、QWidgetを画面中心に移動させるMixin機能を実装します。

```python:qbasicmayawindow.py
class QWidgetMixin(QWidget):
    def center_on_primaryscreen(self):
        """プライマリディスプレイの中心に移動させる"""
        desktop = QtGui.QDesktopWidget()
        displaysize = desktop.screenGeometry(desktop.primaryScreen())

        size = self.size()
        left = (displaysize.width() / 2) - (size.width() / 2)
        top = (displaysize.height() / 2) - (size.height() / 2)
        self.move(left, top)
```

# 基盤クラスを継承したサンプルウィンドウの実装
ここまで、QMainWindowを継承したUiMayaBasicWindowに下記の実装を行いました。

- 汎用Menubarの実装
- ダイアログ表示メソッドの追加
- QWidgetのMixin追加

これらを活用したサンプルウィンドウを実装してみます。
実装した結果は下記のようなイメージとなります。
![sample_window.png](https://qiita-image-store.s3.amazonaws.com/0/21705/97b5197b-da8b-9a3a-4d83-0af61d5ec8fc.png)

```python:samplewindow.py
class UiSampleWindow(UiMayaBasicWindow):
    __VERSION = "2018.12.18.01"

    def __init__(self, parent=None):
        super(UiSampleWindow, self).__init__(parent)

        self.ui = Ui_MainWindow() # QtDesignerで作成して変換したGUI
        self.ui.setupUi(self)
        self.create_basic_menu() # UiMayaBasicWindowの基本メニュー生成処理を呼び出し

        self.center_on_primaryscreen() # Mixin機能を呼び出し

    def _ui_menu_show_about(self):
        # UiMayaBasicWindowの基本メニュー処理をオーバーライドする
        self.dialog.confirm_ok(u"version : " + self.__VERSION, i=True)
```

# まとめ
GUI部分のコードは肥大化しやすく、重複コードも多く発生しやすい部分です。
このように継承を使えば少しはシンプルなコードに出来るのではないでしょうか。

指摘と合わせて「こういう機能も基盤クラスに持たせておくといいよ！」という意見があれば、
是非コメントを頂ければと思います。
# 参考
- [お気楽 Python プログラミング入門：第 6 回 継承と例外処理](http://www.geocities.jp/m_hiroi/light/python06.html)
- [QDesktopWidget — PySide v1.0.7 documentation](https://srinikom.github.io/pyside-docs/PySide/QtGui/QDesktopWidget.html)