Displaying Tooltip Widget in Maya's Menu

When executing a menu, it's convenient to have supplementary information about what kind of feature it is before pressing it. Therefore, here's how to display tooltips on hover.


目次


Introduction

Autodesk Maya is equipped with numerous features, and given its ease of expansion, individuals and companies add even more functions. Therefore, for users, it's not uncommon to wonder what each menu item does or how to use it. Maya's menu has something called annotation, which, when a mouse cursor hovers over a menu item, displays its content at the bottom-left of the screen. However, due to character limitations and small font size, it's not particularly user-friendly.

Considering this situation, I believed that having a tooltip for each menu item, which provides a brief description or illustrated usage examples in a flipbook style, would aid in more efficient operations.

Environment

In this article, we've verified the content using Maya windows 2020 - 2024. A certain level of knowledge about Python and Qt is assumed. The code snippets created for this article are available on Github/Gist. Please refer to it as necessary.

What is a Tooltip?

The tooltip we're creating this time refers to something like this:

tooltip

Maya's UI and Menu

Before diving deep, let's touch upon the handling of menus in Maya and its relationship with Qt. Since around 2011 (source needed) , Maya adopted the Qt framework for its GUI. Developers can create custom UI elements using Python bindings like PyQt/PySide or C++ plugins. On the other hand, there are useful mel commands like menu, menuItem, the window command to create windows, and fileDialog2 to display dialogs and return results. This makes you realize that it's possible to create a simple UI. These mel commands are also used to build Maya's built-in interface. If you check the mel files in the local installation folder at C:\Program Files\Autodesk\Maya2023\scripts\startup, you'll see. In other words, Maya is structured as an application by creating Qt objects through mel commands.

There are actually two ways to access the existing interface. You can call the mel command in query mode to retrieve information about existing elements. Alternatively, you can obtain Qt object representations by traversing descendant elements from QApplication or using the find method. Each method has its capabilities and limitations, and they often complement each other in operation. Accessing existing elements as Qt objects can be tricky, but knowing this broadens what you can do, not just in this instance. It's worth knowing.

Maya's menu is also broken down and constructed into elements like QMenu, QWidget, and QWidgetAction through mel commands. The strategy this time is to access them, capture hover events, and incorporate the display of custom Qt elements.

Retrieving Menu Elements

Let's start by fetching the existing menu items. Each menu has its own unique path. It behaves similarly to a typical file path, using | as a delimiter, extending from the root to its child elements. Using this path with the mel command, we can access the attributes of that menu.

For instance, to fetch child elements within the menu with a path like MayaWindow|MENU_ITEM_PATH, you would use:

menu -q -itemArray MayaWindow|MENU_ITEM_PATH;

To obtain the label displayed in the UI, you'd write:

menu -q -label MayaWindow|MENU_ITEM_PATH;

For more details, please refer to the official documentation.

Now, let's examine the case when dealing with Qt objects. In a Qt application, there are several methods to fetch the desired object. Here, we'll explore the method of tracing parent-child relationships. First, let's retrieve the main window of Maya is to do this,


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()

Let's try to enumerate all descendants using QObject.children(). We'll use recursion for this.

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()

I recommend executing this code right after launching the application (reasons to be discussed later). Since this will list all elements of the application, it might take a while to execute. After a bit, logs like the following will start to flow.

 <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>

...
snip

By filtering through these logs with specific conditions, you can retrieve the desired item. When written as code,

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)

It looks like this. Before we dive into the explanation of this code, there are a few pitfalls we need to address. We'll discuss these in the next section.

Creating and Initializing Menu Item Elements

From the overview of the tree shown in the previous logs, we can deduce several things about the menu:

  • Within the main menu QMenuBar, each submenu (File, Edit, Create ...) exists as a QMenu.
  • Although there should be corresponding items inside the QMenu (New Scene, Open Scenes...), they aren't apparent.

The truth is, each item in Maya's (built-in) UI does not exist until it is actually used. The object is created only when it's about to be used. This means if you try to find it before it's created, you won't locate it. In this example, when you click the menu bar button (File), the menuItem is executed, and the object is created. Now, I'd like you to execute the previous code after opening a menu once.

  <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>

You'll likely see fragments of menu items now. This is why I earlier recommended executing right after launching. So, does this mean you need to manually click the button to create a menu item, or can you execute it from the script? Of course, it can be done.


import maya.mel

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

This way, you can fill the menu with menu items without having to click on them yourself.

Finally, let's delve into the explanation of get_qt_object_at_path. If we show a schematic of the Qt menu subtree, it looks like:

Menu content is flat:
    Parent (QMenu)
    - MenuA action (QWidgetAction)
    - MenuA widget (QWidget)
    - MenuB action (QWidgetAction)
    - MenuB widget (QWidget)
    ...

there's folder inside the menu:
    Parent (QMenu)
        - SubParent action (QWidgetAction)
        - SubParent widget (QWidget)
        - SubParent menu   (QMenu)
            - MenuA action (QWidgetAction)
            - MenuA widget (QWidget)
            - MenuB action (QWidgetAction)
            - MenuB widget (QWidget)
    ...

It's structured like this. Therefore, in get_qt_object_at_path, the search for menu items from a menu path (e.g., MayaWindow|mainFileMenu|menuItem557) is divided into two steps: the part that searches for the folder

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))

and the part that searches for the menu item.

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))

Next, let's look at the roles of the QWidgetAction and QWidget retrieved by this code.

QWidgetAction and QWidget

QWidgetAction is commonly used to add custom widgets to parent elements such as menu bars and toolbars. It's designed to provide a widget-accompanied response when the user performs some operation on the parent element. In the case of menus, it returns some response when the user clicks on a menu item. Referring to the schematic diagram mentioned earlier, behavior settings are done in MenuA action, and its appearance is reflected in MenuA widget. To create tooltips, it's necessary to monitor the hover event, making the acquisition of this action crucial.

Now, you might wonder if QWidgetAction and QWidget have a one-to-one correspondence. In reality, they don't. For a single menu item, there is always one Action, but there can be multiple Widgets. For instance, in the case of a trade-off, a new shadow QWidget is created separately from the main one. While the behavior of the menu remains constant, there are multiple pathways to trigger it. It's important to keep this in mind when creating tooltips because, to determine the end of a hover or the position of the tooltip, it's essential not only to retrieve the Action but also the target widget.

Implementing the Tooltip

Let's delve into the core implementation of the tooltip. Let's first summarize what's needed: The outline is

  • When the mouse cursor hovers over a menu item,
  • Display the tooltip widget next to the menu item.
  • When the mouse cursor exits the area,
  • Hide the tooltip widget.

Creating the Tooltip Widget

First, let's define the necessary components. Here's the very basic one.

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()

We've prepared something simple that just displays text. With setWindowFlags, we've adjusted the behavior to resemble a tooltip. We want to register this for the hovered event.

Show the Widget on Hover start

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 = "This is tooltip 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))

From the bottom three lines, we can see the creation of the tooltip, the acquisition of QWidgetAction, and the display of the tooltip widget in response to the hover event. Also, inside show_menu_tooptip, we retrieve the coordinates of the widget under the cursor and move the widget. Instead of referencing the widget directly from QWidgetAction, we obtain the under_cursor_widget using QApplication.widgetAt(QCursor.pos()). This is because there seems to be no way to retrieve the target widget directly from QWidgetAction. If anyone knows a method, I would appreciate hearing about it.

Hide the Widget on Hover End

We want it to disappear when it's no longer needed. There are several methods to achieve this, but here we will hook into the event of the widget under the hover to hide the tooltip. Please refer to the Qt reference for details.

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

By defining the HoverEventFilter that inherits QObject and overriding eventFilter, and then using installFilter on the target widget, we can achieve this. Lines 22-27 determine what action to take for which event. Here, multiple conditions are set for the tooltip to disappear. This is because the Qt event interval is lax, so it doesn't accurately capture all user behavior. It's common for the expected Leave mouse action not to be detected. Therefore, it's a good idea to ensure that the tooltip also disappears when, for example, the menu disappears in case of a missed determination. We'll set this up inside show_menu_tooltip_help.

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()

Lines 7 and 9 create and install the filter. You may notice that MENU_HELP_EVENT_FILTERS is defined globally. This is because if defined in a local scope, the filter instance created would be deleted after exiting the scope. To prevent its deletion, we need to retain a reference to it, hence the global definition.

With this, we've implemented a basic functioning tooltip. Cheers!

Customization and Additional Features

The tooltip we introduced earlier had only the most basic display capabilities. Let's take it a step further and enhance it. Basically, anything you can do with a Qt Widget is possible, so I encourage everyone to experiment on their own. However, as an example, let's look at the following:

  • Markdown support
  • Image display support
  • Rotating multiple images over time

Markdown

Using Markdown package from pypi. If Python2 (盲腸) support is required, use 3.1.1

import markdown
html = markdown.markdown(md)

With this code, you can retrieve a string that has been rendered to html. You can then use setHtml on QTextEdit/QTextBrowser.

Image Display

To display multiple images while switching them over time, use:

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)

prepare a widget like this, and you simply need to addWidget it to the tooltip.

Let's summarize the achievements up to this point as code.

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))

Application

By combining this with the menu registration that I previously explained on [Qiita]((https://qiita.com/yamahigashi@github/items/212ca6a71dcd48ee618d),

@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)

With just this kind of description, it's possible to perform everything from menu registration to the construction of tooltips all at once. The entire view of the code can be checked here.

Moreover, to convert annotations into tooltips for the built-in menus is like,

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))

You can convert them all at once with code like this.

Conclusion

In this article, we explored the background of Maya's menu and the concrete methods to implement tooltips. We clarified that Qt is behind the scenes and that these are constructed using mel. We also discussed the creation of custom UI widgets and their handling.

While we won't provide a detailed explanation of the output in this article, it's likely unnecessary for readers who have followed along this far. Customizing and extending Maya's UI directly connects to the efficiency of software utilization, and improving user convenience is essential. I hope that the methods and concepts I've described in this article will be helpful to everyone.