Mayaのメニューにツールチップを表示

メニューを実行する際、押下前にそれがどのような機能であるか補助情報があると便利である。そのためツールチップをホバー表示するためのあれこれ


目次


はじめに

Autodesk Maya には多数の機能が搭載されており、またその拡張容易性からさらに各自各社各々機能の追加を行っている。そのため、ユーザにとっては どのメニューアイテムがなんの機能であるか?や使いかたについて迷ってしまうことが少なくない。maya のメニューには annotation というものが存在し、メニュー項目上をマウスカーソルがホバーすると画面左下にメニュー内容が表示されるようになっている。 しかし、文字数制限や文字の小ささなどが相まりユーザフレンドリとはいいがたい。

このような状況で、メニューアイテムごとに簡潔な説明や、使用例の画像パラパラマンガ的なのツールチップがあれば、効率的な作業の一助になるのではないかと考えた。

環境について

この記事では、執筆時点で maya windows 2020 - 2024 での確認を行っている。またPython や Qt について一定の知識があるものとする。執筆にあたって作成したコード類(抜粋)は Github/Gist にて公開している。適宜参照していただきたい。

ツールチップとは

今回作成するは ツールチップ とはこういうやつのこと

tooltip

Maya のUIとメニュー

詳細に移る前に、mayaでのメニューの扱い、Qtとのかかわりについて触れておく。 Maya では2011頃 (要出典) よりGUIフレームワークに Qt を採用しており開発者は PyQt/PySideといった Qtの Python Binding あるいは C++プラグインを用いて独自UI要素を作成することができる。一方メニューを操作する menumenuItem といったmelコマンド といったコマンドやウィンドを作成する window コマンド、ダイアログを表示し結果を返す fileDialog2 といった便利コマンドも存在しており、簡易なUIを作成できることにも気づくだろう。そしてこれら melコマンドが実は mayaの組み込みのインタフェースを構築するためにも使われている。ご自身のローカルのインストールフォルダ C:\Program Files\Autodesk\Maya2023\scripts\startup 以下のmelファイルなどをご覧いただくとわかるだろう。つまりmayaはmelコマンドにより Qtオブジェクトを作成することで、アプリケーションとして成り立っている。

既存のインタフェースに対しアクセスするには実際この2通りある。つまり melコマンドquery モードで呼び出してやり、既存要素の情報を取得する。また QApplication から子孫要素をたどる、あるいは findメソッドを駆使することで Qtオブジェクトとしての表現を取得できる。それぞれできることできないことがあり、相互に補完し運用することになる。既存要素に対し Qtオブジェクトとしてアクセスすることはだいぶトリッキーなのだが、こういうことが可能であると知っていると、今回に限らずできることの幅が広がる。知っておいて損はないとおもわれる。

maya のメニュー要素も、melコマンドにより QMenu や QWidget, QWidgetAction といった要素に分解され構築されている。こいつにアクセスし、ホバーイベントを取得、乗っ取り独自のQt要素の表示を組み込んでやるのが今回の作戦である。

メニュー要素の取得

では既存のメニューを取得するところから始めよう。メニューはそれぞれ自身を表す path を持っている。いわゆるパスと同じような性質を持ち、パス区切りに | を用いルートから子要素に向かって伸びている。melコマンドでこのパスを用いることでそのメニューの属性にアクセスが可能だ。

例えばMayaWindow|MENU_ITEM_PATH というパスのメニュー内の子要素を取得するには

menu -q -itemArray MayaWindow|MENU_ITEM_PATH;

あるいはUI上に表示されているラベルを取得するには

menu -q -label MayaWindow|MENU_ITEM_PATH;

とする。さらに詳しくはレファレンスにあたっていただきたい。 翻って Qtオブジェクトの場合を見ていこう。

Qtアプリケーション上で、望みのオブジェクトを取得する方法にはいくつかあるのだが、ここでは親子関係をたどる方法を見ていく。まずは Mayaのメインウィンドを取得する。


from PySide2.QtWidgets import (
    QApplication,
)

def get_maya_window():
    # type: () -> QWidget
    for obj in QApplication.topLevelWidgets():
        if obj.objectName() == "MayaWindow":
            return obj
    else:
        raise Exception("Maya main window not found")

main_window = get_maya_window()

このようにする。ではためしに QObject.children() をつかって子孫を全列挙してみよう。それには再帰を用いる。

def debug_print_all_objects():

    def recursive(parent, depth):
        for child in parent.children():

            indent = " " * depth
            print(indent, parent, child.objectName(), child)

            recursive(child, depth + 1)

    recursive(get_maya_window(), 0)

debug_print_all_objects()

このコードをアプリケーション起動直後に実行(理由後述)してみていただきたい。アプリケーションの全要素が列挙されるため実行には多少時間がかかる。しばらくすると以下のようなログが流れる。

 <PySide2.QtWidgets.QMainWindow(0x206f476e520, name="MayaWindow") at 0x00000206AF1FAB40> _layout <PySide2.QtWidgets.QLayout(0x206d6407000, name = "_layout") at 0x00000206AF13D580>
 <PySide2.QtWidgets.QMainWindow(0x206f476e520, name="MayaWindow") at 0x00000206AF1FAB40>  <PySide2.QtWidgets.QShortcut(0x206f47fcf40) at 0x00000206AF1BBF40>
 <PySide2.QtWidgets.QMainWindow(0x206f476e520, name="MayaWindow") at 0x00000206AF1FAB40>  <PySide2.QtWidgets.QMenuBar(0x206f476e0e0) at 0x00000206AF155700>
  <PySide2.QtWidgets.QMenuBar(0x206f476e0e0) at 0x00000206AF155700> qt_menubar_ext_button <PySide2.QtWidgets.QToolButton(0x206f476eee0, name="qt_menubar_ext_button") at 0x00000206AF1F3FC0>
  <PySide2.QtWidgets.QMenuBar(0x206f476e0e0) at 0x00000206AF155700> workspaceSelectorLayout <PySide2.QtWidgets.QWidget(0x206f9879130, name="workspaceSelectorLayout") at 0x00000206AF1F30C0>
   <PySide2.QtWidgets.QWidget(0x206f9879130, name="workspaceSelectorLayout") at 0x00000206AF1F30C0> workspaceSelectorLayout <PySide2.QtWidgets.QHBoxLayout(0x206f450cb80, name = "workspaceSelectorLayout") at 0x00000206AF1FAA80>
   <PySide2.QtWidgets.QWidget(0x206f9879130, name="workspaceSelectorLayout") at 0x00000206AF1F30C0> workspaceLbl <PySide2.QtWidgets.QLabel(0x206f9879c30, name="workspaceLbl") at 0x00000206AF1FAE00>
   <PySide2.QtWidgets.QWidget(0x206f9879130, name="workspaceSelectorLayout") at 0x00000206AF1F30C0> workspaceSelectorMenu <PySide2.QtWidgets.QComboBox(0x206f45b0ef0, name="workspaceSelectorMenu") at 0x00000206AF1FA640>
    <PySide2.QtWidgets.QComboBox(0x206f45b0ef0, name="workspaceSelectorMenu") at 0x00000206AF1FA640>  <PySide2.QtGui.QStandardItemModel(0x206f99abcb0) at 0x00000206AF1FA380>
以下略

この中を特定条件でふるいにかけることで、目的のアイテムを取得することができる。コードにすると

def get_qt_object_at_path(menu_item_path):
    # type: (Text) -> QWidgetAction
    """Retrieves a qt object from given Maya menu item path."""
    
    def _find_recursive(parent, name, offset):
        # type: (QObject, Text, int) -> Optional[QWidgetAction]
        for child in parent.children():

            if child.objectName() == name:

                if isinstance(child, QWidgetAction):
                    index = child.parentWidget().children().index(child)
                    target_widget = child.parentWidget().children()[index + offset]
                    return target_widget
                else:
                    return child

            else:
                x = _find_recursive(child, name, offset)
                if x:
                    return x

    path = menu_item_path.replace("MayaWindow|", "")  # Remove MayaWindow prefix

    # Because the object name may not be unique,
    # we need to find the parent menu widget first by hierarchy.
    parent = get_maya_window()
    for menu in path.split("|")[0:-1]:
        p = _find_recursive(parent, menu, 2)
        if p is not None:
            parent = p
        else:
            print("Menu widget not found: {}, {}".format(parent, menu))

    qobject = _find_recursive(parent, path.split("|")[-1], 0)
    if qobject is None:
        raise Exception("object not found: {}".format(menu_item_path))

    return qobject


path = "MayWindow|MENU_ITEM_PATH"
the_object = get_qt_object_at_path(path)

このようになる。このコードの解説の前にいくつか落とし穴があるため、次節でそれについて述べる。

メニューアイテム要素の作成・初期化

先のログにあらわれるツリーを概観するとメニューについていくつかわかることがある。

  • メインメニューQMenuBar の中に 各サブメニュー(File, Edit, Create ...) が QMenu として存在する
  • QMenu の中に各アイテムに該当するものが存在するはずだが( New Scene, `Open Scenes...) 見当たらない

実は Mayaの(組み込みの)UIの各項目は 実際に使用される前には存在しない。使用するときになって初めてオブジェクトが作成される。つまり作成される前に探そうとしても見つからないわけだ。 この例だと、メニューバーのボタン(File)を押下するタイミングで menuItem が実行されオブジェクトが作成される。では先ほどのコードを、メニューを一回開いた状態で実行してみてほしい。

  <class 'PySide2.QtWidgets.QMenuBar'>()>>	<PySide2.QtWidgets.QMenu(0x206fa099ef0, name="mainFileMenu") at 0x00000206AF1F3300>
   <class 'PySide2.QtWidgets.QMenu'>(mainFileMenu)>>	<PySide2.QtWidgets.QAction(0x206fa00bc50 text="File" toolTip="File" menuRole=TextHeuristicRole visible=true) at 0x00000206AF1FAEC0>
   <class 'PySide2.QtWidgets.QMenu'>(mainFileMenu)>>	<PySide2.QtCore.QObject(0x206fa00be90) at 0x00000206AF1FA040>
   <class 'PySide2.QtWidgets.QMenu'>(mainFileMenu)>>	<PySide2.QtCore.QObject(0x206a5a657e0) at 0x00000206AF1FA880>
   <class 'PySide2.QtWidgets.QMenu'>(mainFileMenu)>>	<PySide2.QtWidgets.QWidgetAction(0x207261844c0 text="New Scene" toolTip="Create a new scene" shortcut=QKeySequence("Ctrl+N") menuRole=TextHeuristicRole visible=true) at 0x00000206AF1FA600>
    <class 'PySide2.QtWidgets.QWidgetAction'>(New Scene)>>	<PySide2.QtCore.QObject(0x206a5a930a0) at 0x00000206AF143CC0>
   <class 'PySide2.QtWidgets.QMenu'>(mainFileMenu)>>	<PySide2.QtWidgets.QWidget(0x207260dcb60) at 0x00000206AF1FAE80>
   <class 'PySide2.QtWidgets.QMenu'>(mainFileMenu)>>	<PySide2.QtWidgets.QWidgetAction(0x20726186de0 text="newFileOptions" toolTip="New scene options" menuRole=TextHeuristicRole visible=false) at 0x00000206AF1FAB00>
    <class 'PySide2.QtWidgets.QWidgetAction'>(newFileOptions)>>	<PySide2.QtCore.QObject(0x206a5a96520) at 0x00000206AF143D80>
   <class 'PySide2.QtWidgets.QMenu'>(mainFileMenu)>>	<PySide2.QtWidgets.QWidget(0x207260dd150) at 0x00000206AF1FA0C0>
   <class 'PySide2.QtWidgets.QMenu'>(mainFileMenu)>>	<PySide2.QtWidgets.QWidgetAction(0x20726186c90 text="Open Scene..." toolTip="Open a scene" shortcut=QKeySequence("Ctrl+O") menuRole=TextHeuristicRole visible=true) at 0x00000206AF1FAD40>
    <class 'PySide2.QtWidgets.QWidgetAction'>(Open Scene...)>>	<PySide2.QtCore.QObject(0x206a5a94cc0) at 0x00000206AF143D40>
   <class 'PySide2.QtWidgets.QMenu'>(mainFileMenu)>>	<PySide2.QtWidgets.QWidget(0x207260dd1a0) at 0x00000206AF1FA940>
   <class 'PySide2.QtWidgets.QMenu'>(mainFileMenu)>>	<PySide2.QtWidgets.QWidgetAction(0x20726185bf0 text="openFileOptions" toolTip="Open scene options" menuRole=TextHeuristicRole visible=false) at 0x00000206AF1FA2C0>
    <class 'PySide2.QtWidgets.QWidgetAction'>(openFileOptions)>>	<PySide2.QtCore.QObject(0x206a5a94f40) at 0x00000206AF143CC0>
   <class 'PySide2.QtWidgets.QMenu'>(mainFileMenu)>>	<PySide2.QtWidgets.QWidget(0x207260ddb00) at 0x00000206AF1FAE00>
   <class 'PySide2.QtWidgets.QMenu'>(mainFileMenu)>>	<PySide2.QtWidgets.QWidgetAction(0x20726186d70 text="" menuRole=TextHeuristicRole visible=true) at 0x00000206AF1FA680>

今度はおそらくこのようなメニューアイテムの断片が出ているはずだ。先ほど起動直後に実行してほしいと書いたのはこのためだ。では メニューアイテムを作成するには実際にボタンを手動で押下する必要があるのか、スクリプトから実行できないのかとなる。当然可能だ。


import maya.mel

menu_path = "MayaWindow|mainFileMenu"
command = cmds.menu(menu_path, q=True, postMenuCommand=True)
maya.mel.eval(command)

このようにすると自分で押下することなくメニューを、メニューアイテムで満たすことができる。

続メニュー要素の取得

ようやく get_qt_object_at_path の解説にはいる。Qtのメニュー部分ツリーの模式図を示すと

メニュー内がフラット
    Parent (QMenu)
    - MenuA action (QWidgetAction)
    - MenuA widget (QWidget)
    - MenuB action (QWidgetAction)
    - MenuB widget (QWidget)
    ...

メニュー内にさらにフォルダがありサブメニューがある場合
    Parent (QMenu)
        - SubParent action (QWidgetAction)
        - SubParent widget (QWidget)
        - SubParent menu   (QMenu)
            - MenuA action (QWidgetAction)
            - MenuA widget (QWidget)
            - MenuB action (QWidgetAction)
            - MenuB widget (QWidget)
    ...

このようになっている。そのため get_qt_object_at_path ではメニューパス(例えば MayaWindow|mainFileMenu|menuItem557 からのメニューアイテムの探索を2段階に分け、フォルダを探索する箇所

1 parent = get_maya_window()
2 for menu in path.split("|")[0:-1]:
3 p = _find_recursive(parent, menu, 2)
4 if p is not None:
5 parent = p
6 else:
7 print("Menu widget not found: {}, {}".format(parent, menu))

と、メニューアイテムを探索する箇所

1 parent = get_maya_window()
2 for menu in path.split("|")[0:-1]:
3 p = _find_recursive(parent, menu, 2)
4 if p is not None:
5 parent = p
6 else:
7 print("Menu widget not found: {}, {}".format(parent, menu))
8
9 qobject = _find_recursive(parent, path.split("|")[-1], 0)
10 if qobject is None:
11 raise Exception("object not found: {}".format(menu_item_path))

をわけている。では次にこのコードで取得している QWidgetACtionQWidget の役割についてみていこう。

QWidgetAction と QWidget

QWidgetAction は一般に、メニューバーやツールバーといった親要素に、カスタムウィジェットを追加するために使用する。親要素に対しユーザが何らかの操作を行った場合に、何らかのウィジェットを伴ったレスポンスをするためのものだ。メニューにおいてはユーザがメニュー要素に対しクリックなどを行った場合、何らかのレスポンスを返している。上記の模式図でいうと MenuA action で挙動の設定が行われ、 MenuA widget で見た目に反映されていると考えればよい。 ツールチップを作成するにはホバーイベントを見張る必要がある ため、このアクションを取得することが重要となる。

では QWidgetAction と QWidget が1対1対応するのかというと実はそうではない。1つのメニューアイテムに対しActionは常に1なのだが、ウィジェットのほうは複数存在することがありうる。tearoff している場合など本体とは別に影武者QWidgetが新たに作成される。メニューの挙動は常に一定だが、それを発動するための経路はいくつかあるわけだ。ツールチップを作成する際にはこのことを念頭に入れる必要がある。なぜならホバー終了の判定や、ツールチップの表示位置を決定するには、Actionだけでなく目標とするウィジェットを取得することが不可欠だからだ。

Tooltip の実装

それではツールチップ本体の実装にはいっていく。何が必要であるか一回整理しよう

  • メニュー項目をマウスカーソルがホバーしたとき

  • メニュー項目の横に

  • ツールチップウィジェットを表示

  • マウスカーソルが領域外に出たときに

  • ツールチップウィジェットを非表示

となる

ツールチップWidget の作成

まずは必要な部品を定義していく。ここでは最低限の構成を示す。

class HoverHelpWidget(QWidget):
    """Hover tooltip widget"""

    def __init__(self, message):
        super(HoverHelpWidget, self).__init__()
        self.setWindowFlags(
            C.WindowStaysOnTopHint |
            C.ToolTip
        )

        layout = QVBoxLayout()

        self.contents = QLabel(self)
        self.contents.setText(message)
        layout.addWidget(self.contents)

        self.setLayout(layout)
        self.hide()

単に文字を表示するだけのシンプルなものを用意した。また setWindowFlags でツールチップっぽい挙動になるようにしている。これを hovered イベントに登録してやりたい。

ホバー開始でウィジェットの表示

def show_menu_tooptip(tooltip_widget):
    # type: (QWidget) -> None
    """Shows menu help widget on right side of given widget."""

    under_cursor_widget = QApplication.widgetAt(QCursor.pos())
    container_global_pos = under_cursor_widget.mapToGlobal(QPoint(0, 0))
    help_widget_x = container_global_pos.x() + under_cursor_widget.width() + 2
    help_widget_y = container_global_pos.y() - 20
    tooltip_widget.move(help_widget_x, help_widget_y)
    tooltip_widget.show()


message = "これはツールチップ本文です"
tooltip_widget = HoverTooltipWidget(message)
target_action = get_qt_object_at_path(menu_item_path)
target_action.hovered.connect(lambda: show_menu_tooptip(tooltip_widget))

下から3行でそれぞれツールチップの作成、QWidgetACtionの取得、ホバーイベントへツールチップウィジェットの表示を行っているのがわかるだろう。また、 show_menu_tooptip 内でカーソル下のウィジェットの座標を取得し、ウィジェットの移動を行っている。QWidgetAction から直接 widgetを参照するのではなく、under_cursor_widgetQApplication.widgetAt(QCursor.pos()) で取得しているのには理由がある。QWidgetAction から対象widgetを取得する手段が(どうやら)ないようだからだ。もしご存じの方がいらしたら教えていただけると幸いだ。

ホバー終了時に非表示にするには

不要になったタイミングで消えてほしい。いくつか方法があるのだが、ここではホバー下にあるウィジェットのeventにフックしてツールチップを消してやることにする。詳しくは Qt のレファレンスを参照していただきたい。

1# avoid garbage collection, store filters in global variable
2__MENU_HELP_EVENT_FILTERS__ = [] # type: List[HoverEventFilter]
3
4
5class HoverEventFilter(QObject):
6 """Hover event filter.
7
8 This filter shows and hides given widget on hover events.
9 """
10
11 def __init__(self, hover_widget):
12 super(HoverEventFilter, self).__init__()
13 self.hover_widget = hover_widget
14 self.delete_later = False
15
16 def eventFilter(self, watched, event):
17 # type: (QObject, QEvent) -> bool
18 """Event filter."""
19 global __MENU_HELP_EVENT_FILTERS__ # pylint: disable=global-statement
20
21 if any((
22 event.type() == QEvent.HoverLeave,
23 event.type() == QEvent.Leave,
24 event.type() == QEvent.Hide,
25 event.type() == QEvent.FocusOut,
26 event.type() == QEvent.WindowDeactivate,
27 event.type() == QEvent.Enter, # Explicitly squash, quick focus change will trigger this unintentionally
28 )):
29 self.hover_widget.hide()
30 self.deleteLater()
31 self.delete_later = True
32
33 try:
34 __MENU_HELP_EVENT_FILTERS__.remove(self)
35 except ValueError:
36 pass
37
38 return True
39
40 if any((
41 event.type() == QEvent.HoverEnter,
42 )):
43 if self.delete_later:
44 return True
45
46 self.hover_widget.show()
47 return True
48
49 return False

このように QObject を継承し, eventFilter をオーバライドした HoverEventFilter を定義してやり、対象ウィジェットに installFilter してやる。22-27行目でどのようなイベントに対し何を行うかを判定している。ここでは複数条件で消えるように設定している。つまりこれは Qtのイベントの判定間隔が緩いため、正確にすべてのユーザの挙動を拾ってくれるわけではないからだ。ユーザのマウスがLeaveしたはずなのに届かないなんてことはざらだ。そのため判定抜けた場合でも最悪メニューが消えるときなどにツールチップも消えるようにしておくと良い。これを先ほどの show_menu_tooltip_hel 内で設定してやる。

1def show_menu_tooltip_help(tootip_widget):
2 # type: (QWidget) -> None
3 """Shows menu help widget on right side of given widget."""
4
5 global __MENU_HELP_EVENT_FILTERS__ # pylint: disable=global-statement
6
7 event_filter = HoverEventFilter(tootip_widget)
8 under_cursor_widget = QApplication.widgetAt(QCursor.pos())
9 under_cursor_widget.installEventFilter(event_filter)
10 __MENU_HELP_EVENT_FILTERS__.append(event_filter)
11
12 container_global_pos = under_cursor_widget.mapToGlobal(QPoint(0, 0))
13 help_widget_x = container_global_pos.x() + under_cursor_widget.width() + 2
14 help_widget_y = container_global_pos.y() - 20
15 tootip_widget.move(help_widget_x, help_widget_y)
16 tootip_widget.show()

7,9行目でフィルタの作成及び、インストールをおこなっている。 __MENU_HELP_EVENT_FILTERS__ をグローバルに定義しているのがわかると思うが、これはローカルスコープで定義を行うとスコープ抜けた後、作成したフィルタインスタンスが削除されてしまうからだ。削除されないよう参照を保持しておく必要があるためこのようにしている。

ここまでで最低限の動作をするツールチップの実装ができた。めでたし。

カスタマイズ・機能追加

先にあげたツールチップは本当に最低限の表示機能しか持たないものであった。ここでは一歩進んでもう少し便利にしてみよう。基本的に Qt の Widget にできることなら何でもできるので各自いろいろ試してほしいが、ここでは一例を示す。

  • markdown の対応
  • 画像表示の対応
  • 複数枚画像を時間でローテート

順にみていこう。

markdown

pypi より Markdownパッケージを用いる。Python2 (盲腸) への対応が必要であれば 3.1.1 を用いる。

import markdown
html = markdown.markdown(md)

とすると htmlにレンダリングされた文字列を取得できる。QTextEdit/QTextBrowser に setHtml してやればよい。

画像表示

複数枚の画像を時間で切り替えつつ表示するには、

class ImageRotatingWidget(QWidget):
    """Rotating image widget"""

    DURATION_MILLISECOND = 800  # 0.8 seconds

    def __init__(self, parent, image_paths):
        # type: (QWidget, List[Text]) -> None

        super(ImageRotatingWidget, self).__init__(parent)

        self.layout = QVBoxLayout(self)  # type: QVBoxLayout
        self.image_label = QLabel(self)
        self.layout.addWidget(self.image_label)

        self.image_paths = image_paths
        self.current_image_index = 0

        # start timer
        self.timer = QTimer(self)
        self.timer.timeout.connect(self.update_image)
        self.timer.start(self.DURATION_MILLISECOND)

        self.update_image()

    def update_image(self):
        current_image_path = self.image_paths[self.current_image_index]
        pixmap = QPixmap(current_image_path)
        self.image_label.setPixmap(pixmap)

        self.current_image_index = (self.current_image_index + 1) % len(self.image_paths)

    def showEvent(self, event):
        super(ImageRotatingWidget, self).showEvent(event)

        self.current_image_index = 0
        current_image_path = self.image_paths[self.current_image_index]
        pixmap = QPixmap(current_image_path)
        self.image_label.setPixmap(pixmap)

        self.current_image_index = (self.current_image_index + 1) % len(self.image_paths)

このような Widgetを用意し、tooltip widgetに addWidgetしてやればよい。

少し長いが、ここまでの成果をいったんまとめる

1class HoverTooltipWidget(QWidget):
2 """Hover help widget"""
3
4 MAX_IMAGE_WIDTH = 600
5
6 def __init__(self, message):
7 super(HoverTooltipWidget, self).__init__()
8 self.setWindowFlags(
9 # C.FramelessWindowHint |
10 # C.WindowDoesNotAcceptFocus |
11 C.WindowStaysOnTopHint |
12 C.ToolTip
13 )
14
15 layout = QVBoxLayout()
16
17 self.contents = QTextEdit(self)
18 layout.addWidget(self.contents)
19
20 # to avoid image display issue, use QLabel instead of QTextEdit with html
21 self.images = re.findall(r'<img alt="" src="([^"]+)" />', message)
22 message = re.sub(r'<p>(<img alt="" src="([^"]+)" />[ \n]*)+</p>', "", message)
23 self.contents.setHtml(message)
24
25 if len(self.images) == 0:
26 pass
27
28 elif len(self.images) == 1:
29 pixmap = QPixmap(self.images[0])
30 width = min(pixmap.width(), self.MAX_IMAGE_WIDTH)
31 pixmap = pixmap.scaledToWidth(width)
32 img = QLabel(self)
33 img.setPixmap(pixmap)
34 layout.addWidget(img)
35
36 else:
37 self.rotate_images = ImageRotatingWidget(self, self.images)
38 layout.addWidget(self.rotate_images)
39
40 self.setLayout(layout)
41 self.hide()
42
43 def showEvent(self, event):
44 super(HoverTooltipWidget, self).showEvent(event)
45 self.adjust_contets_size()
46
47 def adjust_contets_size(self):
48 # type: () -> None
49 """Adjust contents size"""
50
51 doc_height = self.contents.document().size().height()
52 doc_height = doc_height + 30
53
54 self.contents.setFixedHeight(doc_height)
55 self.contents.adjustSize()
56
57 self.adjustSize()
58
59
60# avoid garbage collection, store filters in global variable
61__MENU_HELP_EVENT_FILTERS__ = [] # type: List[HoverEventFilter]
62
63
64class HoverEventFilter(QObject):
65 """Hover event filter.
66
67 This filter shows and hides given widget on hover events.
68 """
69
70 def __init__(self, hover_widget):
71 super(HoverEventFilter, self).__init__()
72 self.hover_widget = hover_widget
73 self.delete_later = False
74
75 def eventFilter(self, watched, event):
76 # type: (QObject, QEvent) -> bool
77 """Event filter."""
78 global __MENU_HELP_EVENT_FILTERS__ # pylint: disable=global-statement
79
80 if any((
81 event.type() == QEvent.HoverLeave,
82 event.type() == QEvent.Leave,
83 event.type() == QEvent.Hide,
84 event.type() == QEvent.FocusOut,
85 event.type() == QEvent.WindowDeactivate,
86 event.type() == QEvent.Enter, # Explicitly squash, quick focus change will trigger this unintentionally
87 )):
88 self.hover_widget.hide()
89 self.deleteLater()
90 self.delete_later = True
91
92 try:
93 __MENU_HELP_EVENT_FILTERS__.remove(self)
94 except ValueError:
95 pass
96
97 return True
98
99 if any((
100 event.type() == QEvent.HoverEnter,
101 )):
102 if self.delete_later:
103 return True
104
105 self.hover_widget.show()
106 return True
107
108 return False
109
110
111class ImageRotatingWidget(QWidget):
112 """Rotating image widget"""
113
114 DURATION_MILLISECOND = 800 # 1.2 seconds
115
116 def __init__(self, parent, image_paths):
117 # type: (QWidget, List[Text]) -> None
118
119 super(ImageRotatingWidget, self).__init__(parent)
120
121 self.layout = QVBoxLayout(self) # type: QVBoxLayout
122 self.image_label = QLabel(self)
123 self.layout.addWidget(self.image_label)
124
125 self.image_paths = image_paths
126 self.current_image_index = 0
127
128 # start timer
129 self.timer = QTimer(self)
130 self.timer.timeout.connect(self.update_image)
131 self.timer.start(self.DURATION_MILLISECOND)
132
133 self.update_image()
134
135 def update_image(self):
136 current_image_path = self.image_paths[self.current_image_index]
137 pixmap = QPixmap(current_image_path)
138 self.image_label.setPixmap(pixmap)
139
140 self.current_image_index = (self.current_image_index + 1) % len(self.image_paths)
141
142 def showEvent(self, event):
143 super(ImageRotatingWidget, self).showEvent(event)
144
145 self.current_image_index = 0
146 current_image_path = self.image_paths[self.current_image_index]
147 pixmap = QPixmap(current_image_path)
148 self.image_label.setPixmap(pixmap)
149
150 self.current_image_index = (self.current_image_index + 1) % len(self.image_paths)
151
152
153def get_maya_window():
154 # type: () -> QWidget
155 for obj in QApplication.topLevelWidgets():
156 if obj.objectName() == "MayaWindow":
157 return obj
158 else:
159 raise Exception("Maya main window not found")
160
161
162def get_qt_object_at_path(menu_item_path):
163 # type: (Text) -> Tuple[QWidgetAction, QWidget]
164 """Retrieves a qt object from given Maya menu item path.
165
166 In Qt, menu item name is in format of "menu_item_path".
167 But in Maya, menu item name is in format of "menu|sub_menu|menu_item".
168
169 And the menu hierarchy represented in Qt is,
170
171 Case 1: Menu item is a menu
172 Parent (QMenu)
173 - MenuA action (QWidgetAction) # This contains some attributes for subsequently listed widgets
174 - MenuA widget (QWidget) # This is the actual widget that we want to get
175 - MenuB action (QWidgetAction)
176 - MenuB widget (QWidget)
177 ...
178
179 Case 2: Menu item is a sub menu
180 Parent (QMenu)
181 - SubParent action (QWidgetAction)
182 - SubParent widget (QWidget)
183 - SubParent menu (QMenu)
184 - MenuA action (QWidgetAction) # This contains some attributes for subsequently listed widgets
185 - MenuA widget (QWidget) # This is the actual widget that we want to get
186 - MenuB action (QWidgetAction)
187 - MenuB widget (QWidget)
188 ...
189 ...
190 """
191
192 def _find_recursive(parent, name, offset):
193 # type: (QObject, Text, int) -> Optional[Union[QWidget, QWidgetAction]]
194 for child in parent.children():
195 if child.objectName() == name:
196 if isinstance(child, QWidgetAction):
197 index = child.parentWidget().children().index(child)
198 target_widget = child.parentWidget().children()[index + offset]
199 return target_widget
200 else:
201 return child
202
203 else:
204 x = _find_recursive(child, name, offset)
205 if x:
206 return x
207
208 path = menu_item_path.replace("MayaWindow|", "") # Remove MayaWindow prefix
209
210 # Because the object name may not be unique,
211 # we need to find the parent menu widget first by hierarchy.
212 parent = get_maya_window()
213 for menu in path.split("|")[0:-1]:
214 p = _find_recursive(parent, menu, 2)
215 if p is not None:
216 parent = p
217 else:
218 print("Menu widget not found: {}, {}".format(parent, menu))
219
220 action = _find_recursive(parent, path.split("|")[-1], 0)
221 widget = _find_recursive(parent, path.split("|")[-1], 1)
222 if action is None or widget is None:
223 raise Exception("Menu widget not found: {}".format(menu_item_path))
224
225 if not isinstance(action, QWidgetAction):
226 raise Exception("Menu widget not found: {}".format(menu_item_path))
227
228 if not isinstance(widget, QWidget):
229 raise Exception("Menu widget not found: {}".format(menu_item_path))
230
231 return action, widget
232
233
234def show_menu_tooltip_help(tootip_widget):
235 # type: (QWidget) -> None
236 """Shows menu help widget on right side of given widget."""
237
238 global __MENU_HELP_EVENT_FILTERS__ # pylint: disable=global-statement
239
240 event_filter = HoverEventFilter(tootip_widget)
241 under_cursor_widget = QApplication.widgetAt(QCursor.pos())
242 under_cursor_widget.installEventFilter(event_filter)
243 __MENU_HELP_EVENT_FILTERS__.append(event_filter)
244
245 container_global_pos = under_cursor_widget.mapToGlobal(QPoint(0, 0))
246 help_widget_x = container_global_pos.x() + under_cursor_widget.width() + 2
247 help_widget_y = container_global_pos.y() - 20
248 tootip_widget.move(help_widget_x, help_widget_y)
249 tootip_widget.show()
250
251
252def render_markdown(md_text, script_directory):
253 # type: (Text, Text) -> Text
254 """Renders markdown text to HTML."""
255
256 import markdown
257 if sys.version_info[0] == 2:
258 try:
259 md_text = md_text.decode("cp932")
260 except UnicodeEncodeError:
261 pass
262 html = markdown.markdown(md_text)
263 resolved_html = re.sub(
264 r'(src|href)="/?(.*)"',
265 r'\1="{}/\2"'.format(script_directory.replace(os.sep, "/")),
266 html
267 )
268
269 resolved_html = resolved_html.replace(os.sep, "/")
270
271 return resolved_html
272
273
274def register_menu_help(script_dir, menu_item_path, message):
275 # type: (Text, Text, Text) -> None
276 """Registers menu help widget to Maya menu."""
277
278 html = render_markdown(message, script_dir)
279 tootip_widget = HoverTooltipWidget(html)
280
281 target_action, _ = get_qt_object_at_path(menu_item_path)
282 target_action.hovered.connect(lambda: show_menu_tooltip_help(tootip_widget))

応用

これを以前 Qiitaで解説したメニュー登録と組み合わせると

@menu.command_item("Reset bindpose", divider="Skinning")
def reset_bindpose():
    """
    ### Reset Bindpose
    
    - select bone
    - execute this command
    
    ![](images/reset_bindpose.png)
    """
    import maya.cmds as cmds

    selection = cmds.ls(sl=True, long=True)
    bones = cmds.ls(sl=True) or []
    bindposes = list(set(cmds.listConnections(bones, d=True, type="dagPose")))
    cmds.delete(bindposes)
    cmds.dagPose(bp=True, save=True)

このような記述をするだけでメニューの登録から、ツールチップの構築までを一括で行うことが可能だ。コードの全体像はこちら確認可能だ。

また、組み込みメニューに対し、アノテーションをツールチップ化するには

def inject_annotation_as_menu_tooltip_to_maya_menu(main_menu_name):
    # type: (Text) -> None
    """Injects help tooltip to Maya menu items."""

    import maya.mel as mel
    import gml_maya.menu as m

    # Get menu items
    kids = []  # type: List[Text]
    kids = cmds.menu(main_menu_name, q=True, itemArray=True)  # type: ignore
    if not kids:
        command = cmds.menu(main_menu_name, q=True, postMenuCommand=True)  # type: ignore
        mel.eval(command)
        kids = cmds.menu(main_menu_name, q=True, itemArray=True)  # type: ignore

    if not kids:
        raise RuntimeError("Failed to get menu items: {}".format(main_menu_name))

    if not isinstance(kids, list):
        kids = [kids]

    # Register menu help
    for kid in kids:
        path = main_menu_name + "|" + kid
        annotation = cmds.menuItem(path, q=True, annotation=True)  # type: Any

        try:
            inject_annotation_as_menu_tooltip_to_maya_menu(path)
        except RuntimeError:
            pass

        if not annotation.strip():
            continue

        register_menu_help(m, path, annotation)


menu_items = [
    "MayaWindow|mainAudioMenu",
    "MayaWindow|mainCartoonMenu",
    "MayaWindow|mainConstraintsMenu",
    "MayaWindow|mainCreateMenu",
    "MayaWindow|mainCreatorMenu",
    "MayaWindow|mainCurvesMenu",
    "MayaWindow|mainDeformMenu",
    "MayaWindow|mainDeformationMenu",
    "MayaWindow|mainDisplayMenu",
    "MayaWindow|mainDynEffectsMenu",
    "MayaWindow|mainEditMenu",
    "MayaWindow|mainEditMeshMenu",
    "MayaWindow|mainFieldsSolverMenu",
    "MayaWindow|mainFileMenu",
    "MayaWindow|mainFluidsMenu",
    "MayaWindow|mainGenerateMenu",
    "MayaWindow|mainHairMenu",
    "MayaWindow|mainHelpMenu",
    "MayaWindow|mainKeysMenu",
    "MayaWindow|mainLightingMenu",
    "MayaWindow|mainMeshDisplayMenu",
    "MayaWindow|mainMeshMenu",
    "MayaWindow|mainMeshToolsMenu",
    "MayaWindow|mainModifyMenu",
    "MayaWindow|mainNCacheMenu",
    "MayaWindow|mainNClothMenu",
    "MayaWindow|mainNConstraintMenu",
    "MayaWindow|mainOpenFlightMenu",
    "MayaWindow|mainOptionsMenu",
    "MayaWindow|mainParticlesMenu",
    "MayaWindow|mainPlaybackMenu",
    "MayaWindow|mainRenTexturingMenu",
    "MayaWindow|mainRenderMenu",
    "MayaWindow|mainSelectMenu",
    "MayaWindow|mainShadingMenu",
    "MayaWindow|mainStereoMenu",
    "MayaWindow|mainSurfacesMenu",
    "MayaWindow|mainUVMenu",
    "MayaWindow|mainVisualizeMenu",
    "MayaWindow|mainWindowMenu",
]

for item in menu_items:
    try:
        inject_annotation_as_menu_tooltip_to_maya_menu(item)
    except RuntimeError:
        print("pass {}".format(item))

このようなコードで一括してツールチップ化できる。

まとめ

本稿では、mayaのメニューに関する背景と、ツールチップを実装する具体的な手段について考察した。Qtが背後にあること、melを利用しそれらを構築していることを明らかにした。また、独自UIウィジェットの作成や、それらのハンドリングについても述べた。

今回は成果物についての詳細な解説は行わないが、ここまでお読みいただいた読者のかたには不要であろう。Maya のUIのカスタマイズ、拡張はソフトウェア利活用の効率に直結しており、ユーザの利便性の向上は重要である。今回提案した手法や、説明した概念が皆様の役に立てば幸いである。