Source code for cgl.plugins.CustomMenu

import json
import logging
import os
import sys
from datetime import datetime
from cgl.msd.version import VersionMsd
from cgl.msd.services.event_service import EventService
from cgl.core.config.query import AlchemyConfigManager

CFG = AlchemyConfigManager()


[docs] class CustomMenu(object): """ A Base Class to be used in conjunction with the "Menu Designer". A user simply has to fill in the following functions: get_scene_path() set_menu_parent() create_menu() add_button()f delete_menu() find_menu_by_name() Examples can be found here: 1) `plugins/nuke/custom_menu.py` 1) `plugins/maya/custom_menu.py` """ path_object = None def __init__(self, software, type_): self.software = software self.type = type_ self.scene_path = self.get_scene_path() self.menu_parent = self.set_menu_parent() self.shelf_set_name = None self.shelf_path = None self.shelf_set = None self.path_object = None if not self.software == "unreal": self.set_path_object() # self.cfg = ProjectConfig.getLastConfig() # cookbook_dir = os.path.dirname(self.cfg.cookbook_folder) self.cookbook_dir = CFG.cookbook_dir self.config_root = CFG.cookbook_root print("config root", self.config_root) print("Adding {} to PATH".format(self.config_root)) sys.path.insert(0, self.config_root) self.menus_file = os.path.join( self.cookbook_dir, software, "%s.cgl" % self.type ).replace("\\", "/") self.menus = self.load_cgl() self.menus_folder = os.path.join( os.path.dirname(self.menus_file), type_ ).replace("\\", "/") self.menu_dict = {}
[docs] def set_path_object(self): from cgl.core.path.object import PathObject if self.scene_path: self.path_object = PathObject().from_path_string(str(self.scene_path))
[docs] def load_cgl(self): """ Returns: dict: all the shelves, menus, or publish from the json file """ logging.info("Menus file: {}".format(self.menus_file)) if os.path.exists(self.menus_file): with open(self.menus_file, "r") as stream: result = json.load(stream) if result: return result[self.software] else: return else: logging.info("No menu file found!")
[docs] @staticmethod def order_menus(menus): """Orders the Menus from the json file correctly. This is necessary for the menus to show up in the correct order within the interface. Returns: A list of the menus in the correct order """ for menu in menus: menus[menu]["order"] = menus[menu].get("order", 10) if menus: return sorted(menus, key=lambda key: menus[key]["order"])
[docs] def order_buttons(self, menu): """Orders the buttons correctly within a menu. Args: menu (str): the name of the menu to order Returns: list: a sorted list of buttons """ buttons = self.menus[menu] buttons.pop("order") try: # there is something weird about this - as soon as these are removed "shelves" never is reinitialized buttons.pop("active") except KeyError: pass for button in buttons: if button: buttons[button]["order"] = buttons[button].get("order", 10) if buttons: return sorted(buttons, key=lambda key: buttons[key]["order"]) else: return {}
[docs] @staticmethod def remove_inactive_buttons(menus): to_pop = [] for menu in menus: if menus[menu]["active"] == 0: to_pop.append(menu) for each in to_pop: menus.pop(each) if menus: return menus else: return {}
[docs] def delete_menus(self): if self.menus: for menu in self.menus: if isinstance(menu, dict): menu = menu["name"] logging.info("deleting %s" % menu) self.delete_menu(menu) self.delete_after_menus()
[docs] def delete_menu(self, menu_name): pass
[docs] def remove_inactive_menus(self): menus = self.menus to_pop = [] if menus: for menu in menus: logging.info(menu["name"]) # if menus[menu]['active'] == 0: # to_pop.append(menu) for each in to_pop: menus.pop(each) if menus: return menus else: return {} else: return {}
[docs] def get_icon_path(self, shelf, button): """ returns the icon path within the current menu of the cgl_tools directory of the corresponding icon Args: shelf: button: Returns: icon path string """ icon = self.menus[shelf][button]["icon"] logging.info(icon) if icon: # TODO need to add the root path to this. icon_path = CFG.icon_path print("cookbook icon path", icon_path) if icon_path: logging.info("icon", icon, icon_path) icon_file = os.path.join(icon_path, icon) return icon_file else: return "" else: return ""
[docs] def add_menu_buttons(self, menu_label, buttons, menu_name=None): for button in buttons: label = button["label"] if "icon" in button.keys(): icon_file = button["icon"] if icon_file and not os.path.exists(icon_file): if "cookbook" in icon_file: _, icon_file = icon_file.split("cookbook") new_path = "{}/{}".format( self.cookbook_dir.replace("\\", "/"), icon_file ) if os.path.exists(new_path): icon_file = new_path if icon_file: label = "" else: icon_file = "" if "annotation" in button.keys(): annotation = button["annotation"] else: annotation = "" logging.info(icon_file) self.add_button( menu_label, label=button["label"], annotation=annotation, command=button["module"], icon=icon_file, image_overlay_label=label, menu_name=menu_name, )
[docs] def load_menus(self, test=False): """ Loads all menus Args: test: """ # if test: # self.delete_menus() try: menus = self.remove_inactive_menus() except KeyError: menus = self.menus pass software_menus = menus for menu in software_menus: menu_name = menu["name"] menu_label = menu.get("label", menu_name) logging.info("menu") logging.info(f"\t{menu_label}") _menu = self.create_menu(menu_label, menu_name) self.menu_dict[menu_label] = _menu buttons = menu["buttons"] self.add_menu_buttons(menu_label, buttons, menu_name=menu_name)
# When Starting a new shelf, simply copy all methods below and fill them in with software-specific functions # See Nuke and Maya examples: plugins/nuke/custom_menu.py & plugins/maya/custom_menu.py
[docs] def get_scene_path(self): pass
[docs] def set_menu_parent(self): return False
[docs] def create_menu(self, label, name=None): pass
[docs] def add_button( self, menu_label, label="", annotation="", command="", icon="", image_overlay_label="", hot_key="", menu_name=None, ): """ Adds a button with telemetry wrapping. Subclasses implement `_create_button` to attach to the actual UI. """ def wrapped_command(): self._log_button_event(menu_label, label, command) self._execute_command( command ) # i can add a parent event id - parent_id = event_data["id"] return self._create_button( menu=menu_label, label=label, annotation=annotation, command=wrapped_command, icon=icon, image_overlay_label=image_overlay_label, hot_key=hot_key, )
# --- to be implemented by DCCs --- def _create_button( self, menu, label, annotation, command, icon, image_overlay_label, hot_key ): parts = self._process_cookbook_command(command) print(parts) raise NotImplementedError("_create_button must be implemented by subclass") # --- telemetry and execution helpers --- def _log_button_event(self, menu, label, command): """ Log a button click event to the version.msd file. """ parent_event_id = None action = "menu_button_clicked" repo = VersionMsd(path=self.path_object) svc = EventService(repo) parts = self._process_cookbook_command(command) details = { "tool": label or "unknown", "recipe": ( command if isinstance(command, str) else getattr(command, "__name__", "callable") ), "software": parts["software"], "recipe_type": parts["recipe_type"], "recipe_name": parts["recipe"], "step": parts["step"], "menu": getattr(menu, "objectName", lambda: str(menu))(), "timestamp": datetime.utcnow().isoformat() + "Z", } if parent_event_id: details["parent_event_id"] = parent_event_id try: event_data = svc.log_event( action=action, app=parts["software"], details=details, ) logging.info(f"Logged menu button click: {label}") return event_data except Exception as e: logging.error(f"Failed to log tool event: {e}") details["traceback"] = str(e) svc.log_bug( bug_id="button_clicked_logging_failed", status="error", details=details, persist=True, recipe_type=parts["recipe_type"], app=parts["software"], notify_discord=True, ) def _execute_command(self, command): """ Execute the wrapped tool command. """ try: if callable(command): command() elif isinstance(command, str): exec(command, globals(), locals()) except Exception as e: logging.error(f"Error executing menu command: {e}") def _process_cookbook_command(self, command: str) -> dict: """ Parse a cookbook command string (or import path) into its components: software, recipe_type, recipe, step. Examples: "cookbook.maya.tools.shaderbuddy.ui" → maya / tools / shaderbuddy / ui "cookbook.unreal.layout.build_scene" → unreal / layout / build_scene / None "import cookbook.maya.tools.shaderbuddy.ui" → maya / tools / shaderbuddy / ui "cookbook.maya.tools.shaderbuddy.ui:run()" → maya / tools / shaderbuddy / ui """ result = { "software": "unknown", "recipe_type": "unknown", "recipe": "unknown", "step": "unknown", } if not isinstance(command, str): return result # Strip syntax noise clean = ( command.replace("import ", "") .replace(";", "") .replace("python(", "") .replace(")", "") ) clean = clean.split(":")[0] # handle 'module:func()' parts = [p for p in clean.split(".") if p] # Expect pattern: cookbook.<software>.<recipe_type>.<recipe>.<step?> if len(parts) >= 3 and parts[0] == "cookbook": result["software"] = parts[1] result["recipe_type"] = parts[2] if len(parts) > 3: result["recipe"] = parts[3] if len(parts) > 4: result["step"] = parts[4] return result
[docs] @staticmethod def find_menu_by_name(**kwargs): pass
[docs] def delete_after_menus(self): pass