Modifying the Maya Timeline R-Click menu

I want to set up a tool that allows animators to add meta data to a frame in the timeline.
This would be for exporting animation events to our game animation system.

The simple solution is a UI with a field for frame number and a field for data. But I was thinking of trying to hijack the timeline r-click menu to add a command. Is this possible?

A lot of the UI is built via MEL commands and can be traced down to where it exists.

  1. Open Maya Script Editor
  2. Enable Echo all Commands
  3. Right click in the maya timeline (to show the menu)

You’ll now see what maya does in code, at that moment.
One of those commands is:

updateTimeSliderMenu TimeSlider|MainTimeSliderLayout|TimeSliderMenu;

Now, where is that? Let’s find out.
Run in MEL:

whatIs updateTimeSliderMenu;

Which returns e.g.:

// Result: Mel procedure found in: C:/Program Files/Autodesk/Maya2024/scripts/others/TimeSliderMenu.mel

Opening that file shows a command updateTimeSliderMenu at the bottom that implements the menu it shows. It also shows that TimeSlider|MainTimeSliderLayout|TimeSliderMenu is the actual menu name.

print(cmds.menu("TimeSlider|MainTimeSliderLayout|TimeSliderMenu", query=True, exists=True))
# True

So using that name we can modify the menu, e.g.

cmds.setParent("TimeSlider|MainTimeSliderLayout|TimeSliderMenu", menu=True)
cmds.menuItem(label="Hello World")

Or:

parent = "TimeSlider|MainTimeSliderLayout|TimeSliderMenu"
cmds.menuItem(label="Not again", parent=parent)

Making:
image

:warning: :point_down:

If you add something to the menu item before first show maya will not populate it - since their code basically checks “if empty: populate()” on first show.

You can either force populate it:

import maya.mel

maya.mel.eval("updateTimeSliderMenu TimeSlider|MainTimeSliderLayout|TimeSliderMenu;")

# Then do your magic stuff after

Or make sure your code runs after in another way.

:warning: :point_up:

Callback on menu about to show?

You can also add a callback to the menu to do more dynamic stuff:

from maya import cmds
from functools import partial

parent = "TimeSlider|MainTimeSliderLayout|TimeSliderMenu"
my_item = cmds.menuItem("MySpecialMenuItem", parent=parent)


def update_my_item(item, *args):
    frame = cmds.currentTime(query=True)
    cmds.menuItem(item, edit=True, label=f"Current frame is {frame}")
    
    
cmds.menu("TimeSlider|MainTimeSliderLayout|TimeSliderMenu", edit=True, postMenuCommand=partial(update_my_item, my_item))

But note that each menu will always have at most one pre-show command set via this - so might not give you all that you need plus you might be hitting issues with others relying on the callback to.

But there’s also Qt we can use.

from maya import cmds
import maya.OpenMayaUI
import shiboken2
from PySide2 import QtWidgets


menu_ptr = maya.OpenMayaUI.MQtUtil.findControl("TimeSlider|MainTimeSliderLayout|TimeSliderMenu")
menu = shiboken2.wrapInstance(int(menu_ptr), QtWidgets.QMenu)


def callback():
    # Do something with the menu on show
    import random
    actions = menu.actions()
    
    for action in actions:
        menu.removeAction(action)    
    random.shuffle(actions)
    for action in actions:
        menu.addAction(action)

menu.aboutToShow.connect(callback)

And yes, this randomly shuffles the menu entries around.

Anyway, you might see some errors now:

// Error: file: C:/Program Files/Autodesk/Maya2024/scripts/others/TimeSliderMenu.mel line 1104: menuItem: Object 'playbackRealtimeItem' not found.

Apparently I removed something in the menu that Maya expected to exist and it didn’t like what I did with it.
But maybe we shouldn’t be removing entries, and such. However. You could use the QtWidgets.QMenu to do your own stuff.

Now that the Qt aboutToShow callback can have as many listeners as you’d like. So you wouldn’t be fighting over someone else’s possible code of the menu.

Plus, you can do crazy stuff:

from maya import cmds
import maya.OpenMayaUI
import shiboken2
from PySide2 import QtWidgets


menu_ptr = maya.OpenMayaUI.MQtUtil.findControl("TimeSlider|MainTimeSliderLayout|TimeSliderMenu")
menu = shiboken2.wrapInstance(int(menu_ptr), QtWidgets.QMenu)



class MyActionWidget(QtWidgets.QWidgetAction):
    def __init__(self, parent):
        super(MyActionWidget, self).__init__(parent)
        
        widget = QtWidgets.QWidget()
        layout = QtWidgets.QHBoxLayout(widget)
        edit = QtWidgets.QLineEdit()
        layout.addWidget(edit)
        
        self.setDefaultWidget(widget)
        
action_widget = MyActionWidget(menu)
menu.addAction(action_widget)

image

Or have very strong styling opinions:

menu.setStyleSheet("* {margin: 10px; padding: 10px; background-color: #FFFFFF}")


But admittedly nothing beats my personal preference:

from PySide2 import QtWidgets

QtWidgets.QApplication.instance().setStyleSheet("""
* { font-family: "Comic Sans MS", "Comic Sans", cursive; color: #FFC0CB; font-size: 12px; }
""")

Sorry, what was your question again?

I guess you are now officially a Jedi.

3 Likes

I would basically copy the TimeSliderMenu.mel to your studio’s Maya scripts folder and modify it, and add python(“from my_library import module”) kind of calls that will add your modifications there, as in: anima/anima/dcc/mayaEnv/config/2022 at master · eoyilmaz/anima · GitHub

Be aware - that when doing so you’d need to maintain it per Maya version to make sure the menu file is updated to that particular maya version.

It does give full control, but also potentially requires more maintenance. Also wouldn’t recommend this for a public plug-in, but it makes sense for a studio to do so.

Thanks for the responses!

Yeah I’d avoid touching the MEL files also on the grounds that technically this is violating copyright. No harm among friends but that it is the kind of thing that could add a month and several tens of thousands of dollars of lawyer time if for example your studio gets sold and you have to do a due-diligence sweep.

When I’ve run into this in the past I generally suggest just adding a start up script which overrwrites the original MEL in memory w/o touching the install itself. Something along these lines will let you grab the original mel :

def clone_mel_procedure(procedure : str) -> str:
    """
    Grab the original text of mel proc as a string - you can re-eval to get the same functionailty
    """
    
    s = maya.mel.eval(f"whatIs {procedure}")
    if not "found in" in s:
        raise RuntimeError (f"{procedure} is not an original Mel procedure")
    
    local_copy = s.split("found in: ")[-1]
    
    depth = 0
    with open(local_copy, "rt") as melFile:        
        text = melFile.read()

    header = re.search("global\W+proc\W+" + procedure + ".*\n", text)
    counter = header.end()
    for char in text[header.end():]:
        depth += char == "{"
        depth -= char == "}"
        counter += 1
        if depth == 0:
            break
    
    return text[header.start(): counter]   

and you can use that to patch the command with a pre/post execute hook or to replace it altogether:

def patch_mel_procedure(procName : str, before_proc : str = None, after_proc : str = None, replace_proc: str = None):
    """
    Patch a mel command adding  callbacks which will fire before and/or after the original code.
    """

    # reset the command to its original state
    maya.mel.eval(f"source {procName}")

    proc = extract_mel_definition(procName)
    replacement_name = procName + "_orig"
    pre_callback_name = procName + "_before"
    post_callback_name = procName + "_after"

    if before_proc and pre_callback_name not in before_proc:
        raise ValueError(f"callback name should be: {pre_callback_name}")
    
    if after_proc and post_callback_name not in after_proc:
        raise ValueError(f"callback name should be: {post_callback_name}")
    
    if replace_proc and f"global proc {procName}" not in replace_proc:
        raise ValueError(f"callback name should be: {procName}")
        

    # the original proc logic becomes the "_orig" function,
    maya.mel.eval(proc.replace("proc " + procName, "proc " + replacement_name))
    success = "entered interactively" in maya.mel.eval("whatIs " + replacement_name)
    if not success: 
        raise RuntimeError (f"failed to redefine {procName}")
    
    # synthesize a new procedure with same signature;
    signature = re.search("(.*)\(.*\)", proc).group(0)          # -> "global proc (string $a, string $b)"
    arg_forward = re.search("\(.*\)", signature).group(0)       
    arg_forward = re.sub("(string \$)|(int \$)|(float \$)", "$", arg_forward)
    arg_forward = re.sub("\[\]", "", arg_forward)               # ->  ($a, $b)
    
    
    if not replace_proc:
        # callback genertor
        replace_proc = signature
        replace_proc += "{\n"
        replace_proc += '\t' + pre_callback_name + arg_forward + ";\n"
        replace_proc += '\t' + replacement_name + arg_forward + ";\n"
        replace_proc += '\t' + post_callback_name + arg_forward + ";\n"
        replace_proc += "}\n"    
    
    local_signature = signature.replace("global proc", "proc")
    
    pre_callback = before_proc or local_signature.replace(procName, pre_callback_name) + "{}\n"
    post_callback = after_proc or local_signature.replace(procName, post_callback_name) + "{}\n"         

    for cb in (pre_callback, post_callback, replace_proc):
        for line in cb.splitlines():
            _logger.debug(line)

    maya.mel.eval(pre_callback)
    maya.mel.eval(post_callback)
    maya.mel.eval(replace_proc)
2 Likes