├── README.md ├── __init__.py ├── interface ├── __init__.py ├── window.py └── gui.py ├── .gitignore ├── LICENSE └── state.py /README.md: -------------------------------------------------------------------------------- 1 | melgui 2 | ====== 3 | 4 | A small library for facilitating the creation of GUI tools in Maya. 5 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Provides functionality to simplify the creation of complex GUI tools in Maya. 3 | """ 4 | 5 | from melgui.interface import Control, Gui, Window 6 | from melgui.state import CallbackNotifier 7 | -------------------------------------------------------------------------------- /interface/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Provides core functionality to simplify the creation of tool windows with 3 | complex GUIs. 4 | """ 5 | 6 | from melgui.interface.gui import Control, Gui 7 | from melgui.interface.window import Window 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | parts 13 | bin 14 | var 15 | sdist 16 | develop-eggs 17 | .installed.cfg 18 | lib 19 | lib64 20 | __pycache__ 21 | 22 | # Installer logs 23 | pip-log.txt 24 | 25 | # Unit test / coverage reports 26 | .coverage 27 | .tox 28 | nosetests.xml 29 | 30 | # Translations 31 | *.mo 32 | 33 | # Mr Developer 34 | .mr.developer.cfg 35 | .project 36 | .pydevproject 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Alex Forsythe 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /state.py: -------------------------------------------------------------------------------- 1 | """ 2 | Provides supplementary functionality to simplify the coordination of tools' 3 | user interfaces with separate state objects. 4 | """ 5 | 6 | class CallbackNotifier(object): 7 | """ 8 | Base class for an object that supports a number of events, allowing 9 | callbacks to be registered with those events. Callbacks are executed when 10 | the implementing class executes the _notify method. 11 | """ 12 | def __init__(self, supported_events): 13 | """ 14 | Initializes a new callback notifier that supports the given list of 15 | string event names. 16 | """ 17 | self.__callbacks = {} # str -> [callable] 18 | self.__supported_events = supported_events 19 | 20 | def register(self, event_name, *callbacks): 21 | """ 22 | Adds the ordered list of callback functions to the list of callbacks 23 | registered with the given event. Any callbacks that are already 24 | registered will simply be ignored. 25 | """ 26 | self.__check_support(event_name) 27 | 28 | if event_name not in self.__callbacks: 29 | self.__callbacks[event_name] = [] 30 | 31 | for callback in callbacks: 32 | if callback not in self.__callbacks[event_name]: 33 | self.__callbacks[event_name].append(callback) 34 | 35 | def unregister(self, event_name, *callbacks): 36 | """ 37 | Removes the specified callback functions from the list of callbacks 38 | registered with the given event. Any callbacks not already registered 39 | will simply be ignored. 40 | """ 41 | self.__check_support(event_name) 42 | 43 | if event_name not in self.__callbacks: 44 | return 45 | 46 | for callback in callbacks: 47 | if callback in self.__callbacks[event_name]: 48 | self.__callbacks[event_name].remove(callback) 49 | 50 | def _notify(self, event_name): 51 | """ 52 | Executes all the callbacks registered with the given event. 53 | """ 54 | self.__check_support(event_name) 55 | 56 | if event_name not in self.__callbacks: 57 | return 58 | 59 | for callback in self.__callbacks[event_name]: 60 | callback() 61 | 62 | def __check_support(self, event_name): 63 | """ 64 | Checks the list of supported events to ensure that the event with the 65 | given name is indeed supported by this class. Raises a TypeError if the 66 | event is unsupported; otherwise passes without effect. 67 | """ 68 | if not event_name in self.__supported_events: 69 | raise TypeError('%s does not support an event named "%s"!' % ( 70 | self.__class__.__name__, event_name 71 | )) 72 | -------------------------------------------------------------------------------- /interface/window.py: -------------------------------------------------------------------------------- 1 | """ 2 | Provides functionality for creating and working with tool windows in Maya. 3 | """ 4 | 5 | import maya.cmds as mc 6 | 7 | class Window(object): 8 | """ 9 | Represents the window for a GUI-based tool, created with Maya's window 10 | command. This class encapsulates the boilerplate code necessary to create 11 | and configure a window in Maya. 12 | """ 13 | def __init__(self, name, title, size, flags = None, 14 | remember_size = (True, False)): 15 | """ 16 | Creates a new window with the given parameters. GUI controls created 17 | subsequently will be a part of the new window. Once configured, the 18 | window may be opened with the show method. 19 | 20 | @param name: The internal name used to uniquely identify the window in 21 | Maya. 22 | @param title: The user-facing name to be displayed in the window's 23 | titlebar. 24 | @param size: A pair representing the initial width and height of the 25 | window. 26 | @param flags: A dictionary containing any additional flags to be passed 27 | to the window command. 28 | @param remember_size: A pair representing whether the width and height 29 | of the window should be remembered after it's closed. A value of 30 | False for either dimension indicates that the stored width or 31 | height value should be reset to the initial value each time the 32 | window is opened. 33 | """ 34 | self.name = name 35 | 36 | # Delete the window if it already exists 37 | if mc.window(self.name, query = True, exists = True): 38 | mc.deleteUI(self.name) 39 | 40 | # Reset the window settings to discard stored width and/or height 41 | width, height = size 42 | remember_width, remember_height = remember_size 43 | if mc.windowPref(self.name, exists = True): 44 | get_pref = lambda attr: mc.windowPref(self.name, 45 | **{'query': True, attr: True}) 46 | mc.windowPref(self.name, 47 | leftEdge = get_pref('leftEdge'), 48 | topEdge = get_pref('topEdge'), 49 | width = get_pref('width') if remember_width else width, 50 | height = get_pref('height') if remember_height else height) 51 | 52 | # Prepare the flags to use in creating the window 53 | flags = flags or {} 54 | flags['title'] = title 55 | flags['width'] = width 56 | flags['height'] = height 57 | 58 | # Make these flags True if not provided, overriding the Maya default 59 | for key in ['toolbox', 'resizeToFitChildren']: 60 | if key not in flags: 61 | flags[key] = True 62 | 63 | # Create the window 64 | mc.window(self.name, **flags) 65 | 66 | def attach_callback(self, event_name, callback): 67 | """ 68 | Uses a script job to attach a callback to the window. When the given 69 | event is fired, the callback will be executed. The script job 70 | associated with the callback will be automatically killed when the 71 | window is deleted. 72 | """ 73 | job = mc.scriptJob(event = [event_name, callback]) 74 | detach_callback = lambda: mc.scriptJob(kill = job, force = True) 75 | mc.scriptJob(uiDeleted = [self.name, detach_callback]) 76 | 77 | def show(self): 78 | """ 79 | Opens the window. 80 | """ 81 | mc.showWindow(self.name) 82 | -------------------------------------------------------------------------------- /interface/gui.py: -------------------------------------------------------------------------------- 1 | """ 2 | Provides functionality related to Maya GUI controls. 3 | """ 4 | 5 | import re 6 | 7 | import maya.cmds as mc 8 | import maya.mel as mel 9 | 10 | class Control(object): 11 | """ 12 | Represents a single UI control contained within a Gui. Provides a wrapper 13 | for the MEL command associated with whatever control type, including 14 | methods to edit and query the parameters of the control. 15 | """ 16 | def __init__(self, name, control_type, creation_flags, parent_name): 17 | """ 18 | Initializes a new control declaration with the given name and control 19 | type. creation_flags is a string containing the MEL flags and arguments 20 | used to create the control (excluding the command, the name, and the 21 | -p[arent] flag). parent_name is the name of this control's parent, or 22 | None if no parent is specified. 23 | """ 24 | self.name = name 25 | self.control_type = control_type 26 | self.creation_flags = creation_flags 27 | self.parent_name = parent_name 28 | 29 | def delete(self): 30 | """ 31 | Deletes this control and all of its children. 32 | """ 33 | mc.deleteUI(self.name) 34 | 35 | def create(self): 36 | """ 37 | Executes the MEL command that creates this control based on its 38 | creation parameters. 39 | """ 40 | # Construct the MEL command to create this control based on its 41 | # parameters 42 | parent_flag = (' -p %s' % self.parent_name) if self.parent_name else '' 43 | command = '%s%s %s %s;' % ( 44 | self.control_type, 45 | parent_flag, 46 | self.creation_flags, 47 | self.name) 48 | 49 | # Attempt to execute the command as MEL. If unsuccessful, print the 50 | # full command so we can diagnose the problem. 51 | try: 52 | mel.eval(command) 53 | except RuntimeError, exc: 54 | print '// %s //' % command 55 | raise exc 56 | 57 | def edit(self, **flags): 58 | """ 59 | Edits this control with the given new flag values. The provided 60 | dictionary of flags need not contain the edit flag. 61 | """ 62 | def thunk_commands(flags): 63 | """ 64 | Modifies and returns the given dictionary so that all function 65 | values associated with command flags are thunked into anonymous 66 | functions that ignore the arguments passed to them by Maya. 67 | """ 68 | for flag, value in flags.iteritems(): 69 | if 'command' in flag.lower() and hasattr(value, '__call__'): 70 | flags[flag] = lambda _: value() 71 | return flags 72 | 73 | flags['edit'] = True 74 | self._call_command(thunk_commands(flags)) 75 | 76 | def query(self, flag): 77 | """ 78 | Returns the current value of the specified flag. 79 | """ 80 | return self._call_command({'query': True, flag: True}) 81 | 82 | def _call_command(self, flags): 83 | """ 84 | Private helper method that calls the MEL command associated with the 85 | relevant type of control, passing in this control's name and the given 86 | set of flag mappings. 87 | """ 88 | command = mc.__dict__[self.control_type] 89 | return command(self.name, **flags) 90 | 91 | @classmethod 92 | def from_string(cls, name, command, parent_name): 93 | """ 94 | Instantiates a new Control object from the provided pieces of its 95 | string declaration. 96 | """ 97 | # Capture an explicitly specified parent name in the declaration 98 | parent_name_regex = re.search(r' -p(?:arent)? "?([A-Za-z0-9_]+)"? ?', 99 | command) 100 | 101 | # If a parent name has been specified, extract it from the command 102 | if parent_name_regex: 103 | parent_name = parent_name_regex.group(1) 104 | command = command.replace(parent_name_regex.group(0), ' ') 105 | 106 | # Split the MEL command used to create the control: the first word is 107 | # the control type, and everything after that represents flags 108 | command_tokens = command.split() 109 | control_type = command_tokens[0] 110 | creation_flags = ' '.join(command_tokens[1:]) 111 | 112 | # Instantiate a new control declaration from these parameters 113 | return cls(name, control_type, creation_flags, parent_name) 114 | 115 | class Gui(object): 116 | """ 117 | Represents a set of controls created from a string declaration via the 118 | from_string classmethod. Once a Gui is created (by calling the create 119 | method after a window has been created), individual controls from the 120 | declaration can be accessed with square-bracket notation to be manipulated 121 | individually. In addition, the edit method can be used to process a batch 122 | of edits in a single call. 123 | """ 124 | def __init__(self, controls): 125 | """ 126 | Initializes a new Gui from the given list of Control objects. 127 | """ 128 | self._controls = [] 129 | self._control_lookup = {} 130 | 131 | for control in controls: 132 | self.add(control) 133 | 134 | def __getitem__(self, key): 135 | """ 136 | Allows individual controls to be accessed by name using array-style 137 | indexing into the Gui object. 138 | """ 139 | return self._control_lookup[key] 140 | 141 | def add(self, control): 142 | """ 143 | Adds the specified control object to the Gui. 144 | """ 145 | self._controls.append(control) 146 | self._control_lookup[control.name] = control 147 | 148 | def create(self): 149 | """ 150 | Creates the Gui by creating all of its controls. 151 | """ 152 | for control in self._controls: 153 | control.create() 154 | 155 | def extend(self, other): 156 | """ 157 | Extends this Gui by adding and creating the controls contained in 158 | another Gui object. 159 | """ 160 | for control in other._controls: 161 | self.add(control) 162 | other.create() 163 | 164 | def edit(self, per_control_edits): 165 | """ 166 | Processes an unordered batch of edits for a subset of this Gui's 167 | controls. per_control_edits is a dictionary mapping each control name 168 | with a dictionary containing the flags and values specifying the edits 169 | to be made to that control. 170 | """ 171 | for control_name, edit_flags in per_control_edits.iteritems(): 172 | self[control_name].edit(**edit_flags) 173 | 174 | @classmethod 175 | def from_string(cls, s): 176 | """ 177 | Instantiates a new Gui object from a string declaration. 178 | """ 179 | def strip_comments(line): 180 | """ 181 | Given a line, returns the same line with any comments stripped away. 182 | Comments begin with a hash character ("#") and continue to the end 183 | of the line thereafter. 184 | """ 185 | # Establish some local state to use in scanning the string. 186 | # quote_open indicates whether the characters over which we're 187 | # currently iterating are contained within a quoted span, and 188 | # quote_chars contains the set of characters currently considered 189 | # valid opening or closing characters for a quoted span. 190 | quote_open = False 191 | quote_chars = ['"', "'"] 192 | 193 | def open_quote(quote_char): 194 | """ 195 | Modifies local state to indicate that we're scanning over a 196 | region of the string that's enclosed in quotes. quote_char is 197 | the character that opens the quote. 198 | """ 199 | quote_open = True 200 | quote_chars = [quote_char] 201 | 202 | def close_quote(): 203 | """ 204 | Modifies local state to indicate that we're no longer scanning 205 | over a quoted region of the string. 206 | """ 207 | quote_open = False 208 | quote_chars = ['"', "'"] 209 | 210 | # Iterate over each character in the string. If we encounter an 211 | # unquoted hash character, we can immediately strip it away and 212 | # return the part of the string before it. Otherwise, we keep 213 | # iterating, checking each character to determine if we need to 214 | # open or close a quote. 215 | for i, c in enumerate(line): 216 | if c == '#' and not quote_open: 217 | return line[:i] 218 | elif c in quote_chars: 219 | close_quote() if quote_open else open_quote(c) 220 | 221 | # Return the entire line unmodified if we encounter no hashes. 222 | return line 223 | 224 | def parse_line(lines): 225 | """ 226 | Parses the given line, returning a triple containing the line's 227 | indentation level, the name of the control declared on that line, 228 | and the creation command associated with that control. 229 | """ 230 | def get_indentation_level(line): 231 | """ 232 | Returns the number of spaces at the beginning of the line. 233 | Treats each tab character as four spaces. 234 | """ 235 | match = re.match(r'[ \t]*', line) 236 | if not match: 237 | return 0 238 | return len(match.group(0).replace('\t', ' ')) 239 | 240 | def split_control(line): 241 | """ 242 | Splits the given line at the first colon, returning the pair of 243 | the control name and the creation command associated with that 244 | control. 245 | """ 246 | first_colon_index = line.find(':') 247 | return (line[:first_colon_index].strip(), 248 | line[first_colon_index+1:].strip()) 249 | 250 | declaration_triples = [] 251 | 252 | for line in lines: 253 | indentation_level = get_indentation_level(line) 254 | name, command = split_control(line) 255 | declaration_triples.append((indentation_level, name, command)) 256 | 257 | return declaration_triples 258 | 259 | class ControlStack(object): 260 | """ 261 | Data structure used to keep track of the controls encountered when 262 | parsing the input string. 263 | """ 264 | def __init__(self): 265 | """ 266 | Initializes an empty control stack. 267 | """ 268 | self._controls = [(-1, None)] 269 | 270 | def pop(self, indentation_level): 271 | """ 272 | Pops controls off the top of the stack until the topmost 273 | control is below the given indentation level. 274 | """ 275 | while self._controls[-1][0] >= indentation_level: 276 | self._controls.pop() 277 | 278 | def push(self, indentation_level, control_name): 279 | """ 280 | Pushes a new control onto the stack at the given indentation 281 | level. 282 | """ 283 | assert indentation_level > self._controls[-1][0] 284 | self._controls.append((indentation_level, control_name)) 285 | 286 | @property 287 | def top_control(self): 288 | """ 289 | Returns the topmost control name on the stack. 290 | """ 291 | return self._controls[-1][1] 292 | 293 | # Strip comments and blank lines to give us only the meaningful lines 294 | commentless_lines = [strip_comments(line) for line in s.splitlines()] 295 | meaningful_lines = [line for line in commentless_lines if line.strip()] 296 | 297 | # Iterate over each line to collect control declarations, using a stack 298 | # to infer parent controls based on indentation 299 | controls = [] 300 | control_stack = ControlStack() 301 | 302 | for (indentation_level, 303 | control_name, 304 | control_command) in parse_line(meaningful_lines): 305 | 306 | # Slice off the top of the stack so that we're back to the last-seen 307 | # control that's below the indentation level of the current one 308 | control_stack.pop(indentation_level) 309 | 310 | # Create a new control declaration, using the new top of the stack 311 | # as its parent control 312 | controls.append(Control.from_string(control_name, 313 | control_command, 314 | control_stack.top_control)) 315 | 316 | # Push the current control onto the stack, as it's now the last-seen 317 | # control of its indentation level 318 | control_stack.push(indentation_level, control_name) 319 | 320 | # Instantiate and return a new Gui object from the parsed controls 321 | return cls(controls) 322 | --------------------------------------------------------------------------------