diff --git a/release/icons/mgear_gear.png b/release/icons/mgear_gear.png new file mode 100644 index 00000000..bd9270e6 Binary files /dev/null and b/release/icons/mgear_gear.png differ diff --git a/release/scripts/mgear/shifter/guide_explorer/guide_explorer_widget.py b/release/scripts/mgear/shifter/guide_explorer/guide_explorer_widget.py index 395e156a..a17ce627 100644 --- a/release/scripts/mgear/shifter/guide_explorer/guide_explorer_widget.py +++ b/release/scripts/mgear/shifter/guide_explorer/guide_explorer_widget.py @@ -16,9 +16,6 @@ logger = logging.getLogger("Guide Explorer") -import importlib -importlib.reload(shifter_utils) - class GuideExplorerWidget(QtWidgets.QWidget): """ @@ -135,41 +132,12 @@ def create_connections(self) -> None: def on_selection_changed_clicked(self) -> None: """ - Update the settings panel based on the current tree selection. - - Displays either the guide settings, component settings or - a placeholder if nothing is selected. + Slot invoked when the tree selection changes. - :return: None + Delegates to "_update_right_panel_from_selection" to refresh + the right-hand settings panel based on the newly selected item. """ - items = self.guide_tree_widget.selectedItems() - if not items: - self.show_placeholder() - return - - # -- Stored information when loading in the guide and components - data = items[-1].data(0, guide_tree_widget.DATA_ROLE) - - # -- Shifter Component Instance - if isinstance(data, ShifterComponent) and data.root_name: - self.open_component_widget(data) - - # -- Safe select via the node uuid - if self.sync_checkbox.isChecked(): - shifter_utils.select_by_uuid(uuid=data.uuid) - - return - - # -- Guide object - if self._is_pymaya_node(data) or isinstance(data, str): - self.open_guide_widget(data) - - if self.sync_checkbox.isChecked(): - shifter_utils.select_items(items=[data]) - return - - # -- Default to showing the placeholder if nothing is selected - self.show_placeholder() + self._update_right_panel_from_selection() def on_item_double_clicked(self) -> None: """ @@ -515,6 +483,40 @@ def refresh(self) -> None: if self.current_key and not self.component_exists(self.current_key): self.show_placeholder() + def _update_right_panel_from_selection(self) -> None: + """ + Update the settings panel based on the current tree selection. + + Displays either the guide settings, component settings or + a placeholder if nothing is selected. + """ + items = self.guide_tree_widget.selectedItems() + if not items: + self.show_placeholder() + return + + data = items[-1].data(0, guide_tree_widget.DATA_ROLE) + + # -- Shifter Component Instance + if isinstance(data, ShifterComponent) and data.root_name: + self.open_component_widget(data) + + if self.sync_checkbox.isChecked(): + shifter_utils.select_by_uuid(uuid=data.uuid) + + return + + # -- Guide + if self._is_pymaya_node(data) or isinstance(data, str): + self.open_guide_widget(data) + + if self.sync_checkbox.isChecked(): + shifter_utils.select_items(items=[data]) + return + + # -- Default to showing the placeholder if nothing is selected + self.show_placeholder() + def _is_pymaya_node(self, obj) -> bool: """ Check if the given object behaves like a PyMaya or PyNode instance. @@ -568,9 +570,22 @@ def showEvent(self, event) -> None: :return: None """ super(GuideExplorerWidget, self).showEvent(event) + # -- Rebuild the tree widget self.refresh() + # -- Load in any saved settings from previous session self._load_settings() + # -- We need to ensure that we have a valid selection for when we open the + # -- widget. + if not self.guide_tree_widget.selectedItems(): + root_item = self.guide_tree_widget.topLevelItem(0) + if root_item is not None: + root_item.setSelected(True) + self.guide_tree_widget.setCurrentItem(root_item) + + # -- Force an update based on our selection here + self._update_right_panel_from_selection() + def hideEvent(self, event) -> None: """ Handle widget hide events. diff --git a/release/scripts/mgear/shifter/guide_explorer/guide_tree_widget.py b/release/scripts/mgear/shifter/guide_explorer/guide_tree_widget.py index 6ae3e7c1..75cc9396 100644 --- a/release/scripts/mgear/shifter/guide_explorer/guide_tree_widget.py +++ b/release/scripts/mgear/shifter/guide_explorer/guide_tree_widget.py @@ -1,6 +1,5 @@ from mgear.vendor.Qt import QtCore, QtWidgets, QtGui -import importlib import logging from dataclasses import dataclass from functools import partial @@ -14,19 +13,21 @@ from mgear.shifter import guide_manager from mgear.shifter import utils as shifter_utils from mgear.shifter.guide_explorer import guide_tree_widget_items +from mgear.shifter.guide_explorer import guide_visibility_delegate from mgear.shifter.guide_explorer.models import ShifterComponent +from mgear.shifter.guide_explorer import utils as guide_explorer_utils from mgear.compatible import compatible_comp_dagmenu -importlib.reload(shifter_utils) -importlib.reload(guide_tree_widget_items) - logger = logging.getLogger("Guide Explorer - Tree Widget") DATA_ROLE = QtCore.Qt.UserRole + 1 -ATTRS_GUIDE = ["rig_name"] -ATTRS_COMP = ["comp_name", "comp_side", "comp_index"] +# -- We use shorthand name for the attribute callback as +# -- the callback manager takes in short names and not long names +# -- "comp_name" and others have the same long and short names. +ATTRS_GUIDE = ["rig_name", "v"] +ATTRS_COMP = ["comp_name", "comp_side", "comp_index", "v"] class GuideTreeWidget(QtWidgets.QTreeWidget): @@ -108,6 +109,10 @@ def add_actions(self) -> None: self.guide_visibility_action.setShortcut(QtGui.QKeySequence("H")) self.guide_visibility_action.setShortcutContext(QtCore.Qt.WidgetWithChildrenShortcut) + self.unhide_all_guide_action = QtWidgets.QAction("Unhide All", self) + self.unhide_all_guide_action.setShortcut(QtGui.QKeySequence("Ctrl+H")) + self.unhide_all_guide_action.setShortcutContext(QtCore.Qt.WidgetWithChildrenShortcut) + self.ctrl_visibility_action = QtWidgets.QAction("Control Visibility", self) self.ctrl_visibility_action.setShortcut(QtGui.QKeySequence("C")) self.ctrl_visibility_action.setShortcutContext(QtCore.Qt.WidgetWithChildrenShortcut) @@ -132,6 +137,7 @@ def add_actions(self) -> None: self.addAction(self.unbuild_action) self.addAction(self.delete_action) self.addAction(self.guide_visibility_action) + self.addAction(self.unhide_all_guide_action) self.addAction(self.ctrl_visibility_action) self.addAction(self.joint_visibility_action) self.addAction(self.select_component_action) @@ -146,7 +152,7 @@ def create_widgets(self) -> None: :return: None """ - self.setColumnCount(2) + self.setColumnCount(3) self.setIndentation(10) self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) @@ -154,17 +160,26 @@ def create_widgets(self) -> None: header.setStretchLastSection(False) header.setMinimumSectionSize(60) - # -- Make columns follow the widget width: - # -- Component name stretches + # -- Column 0: visibility icon header.setSectionResizeMode(0, QtWidgets.QHeaderView.Stretch) - # -- Type hugs content + # -- Column 1: name (stretches) header.setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeToContents) + # -- Column 2: type (hugs content) + header.setSectionResizeMode(2, QtWidgets.QHeaderView.Fixed) # -- Hide the header after setting modes self.setHeaderHidden(True) self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) self.viewport().setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + # -- We assume here that icons sizes are always square and equal + icon_size = QtCore.QSize(17, 17) + self.visibility_delegate = guide_visibility_delegate.VisibilityDelegate(icon_size=icon_size, parent=self) + self.setItemDelegateForColumn(2, self.visibility_delegate) + + # -- Setting the column widget of the visibility icons + self.setColumnWidth(2, 10) + def create_connections(self) -> None: """ Connect UI signals to their handlers and wire actions to slots. @@ -183,6 +198,7 @@ def create_connections(self) -> None: self.delete_action.triggered.connect(self.on_delete_action_clicked) self.guide_visibility_action.triggered.connect(self.on_guide_visibility_action_clicked) + self.unhide_all_guide_action.triggered.connect(self.on_unhide_all_action_clicked) self.ctrl_visibility_action.triggered.connect(self.on_control_visibility_action_clicked) self.joint_visibility_action.triggered.connect(self.on_joint_visibility_action_clicked) @@ -348,6 +364,58 @@ def on_guide_visibility_action_clicked(self) -> None: visibility = node.visibility.get() node.visibility.set(not visibility) + def on_unhide_all_action_clicked(self) -> None: + """ + Unhide the entire guide hierarchy. + + Iterates over all tree items representing the guide root and its + components, forcing their ``visibility`` attribute to True where + available and updating the corresponding visibility buttons. + + :return: None + """ + # -- If there is no guide loaded, nothing to do + if not self._guide: + return + + root = self.invisibleRootItem() + if not root: + return + + # -- Traverse all top-level items and their children + stack: List[QtWidgets.QTreeWidgetItem] = [ + root.child(i) for i in range(root.childCount()) + ] + + while stack: + item = stack.pop() + + # -- Push children to the stack to process the entire subtree + for i in range(item.childCount()): + stack.append(item.child(i)) + + data = item.data(0, DATA_ROLE) + if isinstance(data, ShifterComponent): + node = shifter_utils.node_from_uuid(uuid=data.uuid) + else: + node = shifter_utils.node_from_name(name=data) + + if not node: + continue + + # -- Only touch nodes that actually have a visibility attribute + try: + vis_attr = node.visibility + except Exception: + continue + + # -- Force visible + if not vis_attr.get(): + vis_attr.set(True) + + # -- Keep the UI button in sync + self.update_visibility_widget(item) + def on_control_visibility_action_clicked(self) -> None: """ Toggle the rig control visibility. @@ -436,13 +504,14 @@ def get_guide_from_scene(self) -> None: root_item = guide_tree_widget_items.GuideTreeWidgetItem(parent=self, rig_name=rig_name) - root_item.setText(0, f"guide ({rig_name})") root_item.setData(0, QtCore.Qt.UserRole, "guide") root_item.setData(0, DATA_ROLE, guide_name) self._node_to_item[str(guide_name)] = root_item self._create_attr_callbacks(str(guide_name), ATTRS_GUIDE) + self.create_visibility_widget(root_item) + for comp in components: root_node = comp.node().name() @@ -470,8 +539,12 @@ def get_guide_from_scene(self) -> None: # -- watch naming attrs on this component root self._create_attr_callbacks(root_node, ATTRS_COMP) + self.create_visibility_widget(comp_item) + # -- Select root so settings show on first open - self.setCurrentItem(root_item) + if root_item is not None: + root_item.setSelected(True) + self.setCurrentItem(root_item) def _create_attr_callbacks(self, node_name: str, short_attrs: list[str]) -> None: """ @@ -519,6 +592,11 @@ def _on_attr_changed(self, node_name: str, attr: str) -> None: if not item: return + # -- If only visibility changed, just update the eye icon + if attr == "v": + self.update_visibility_widget(item) + return + data = item.data(0, DATA_ROLE) if isinstance(data, ShifterComponent): @@ -549,10 +627,6 @@ def _on_attr_changed(self, node_name: str, attr: str) -> None: self.blockSignals(False) - self.labelsUpdated.emit() - if self.currentItem() is item: - self.selectionPayloadRenamed.emit() - # -- Either guide or component else: @@ -564,9 +638,12 @@ def _on_attr_changed(self, node_name: str, attr: str) -> None: item.setText(0, f"guide ({rig_name})") self.blockSignals(False) - self.labelsUpdated.emit() - if self.currentItem() is item: - self.selectionPayloadRenamed.emit() + # -- Keep visibility button in sync with the node state + self.update_visibility_widget(item) + + self.labelsUpdated.emit() + if self.currentItem() is item: + self.selectionPayloadRenamed.emit() def _on_scene_selection_changed(self, *args: Any) -> None: """ @@ -738,6 +815,74 @@ def on_update_component_type_action_clicked(self) -> None: finally: self._block_scene_selection_sync = False + def on_visibility_icon_clicked(self, item: QtWidgets.QTreeWidgetItem) -> None: + """ + Toggle visibility for the node represented by the given item. + + :param item: Tree item whose visibility button was clicked. + :return: None + """ + data = item.data(0, DATA_ROLE) + + if isinstance(data, ShifterComponent): + node = shifter_utils.node_from_uuid(uuid=data.uuid) + else: + node = shifter_utils.node_from_name(name=data) + + if not node: + return + + try: + vis_attr = node.visibility + except Exception: + return + + vis_attr.set(not vis_attr.get()) + self.update_visibility_widget(item) + + def create_visibility_widget(self, item: QtWidgets.QTreeWidgetItem) -> None: + """ + Initialize the visibility icon for the given item. + + We no longer use a QPushButton or QToolButton. The icon is set + directly on the tree item in column 2 and made clickable through + mousePressEvent. + """ + self.update_visibility_widget(item) + + def update_visibility_widget(self, item: QtWidgets.QTreeWidgetItem) -> None: + """ + Update the visibility icon for the given item. + + :param item: Tree item to refresh. + """ + data = item.data(0, DATA_ROLE) + + if isinstance(data, ShifterComponent): + node = shifter_utils.node_from_uuid(uuid=data.uuid) + else: + node = shifter_utils.node_from_name(name=data) + + if not node: + item.setDisabled(True) + return + + try: + vis_attr = node.visibility + except Exception: + item.setDisabled(True) + return + + is_visible = bool(vis_attr.get()) + item.setDisabled(False) + + visible_icon_path = str(guide_explorer_utils.get_mgear_icon_path("mgear_eye.svg")) + hidden_icon_path = str(guide_explorer_utils.get_mgear_icon_path("mgear_eye-off.svg")) + + icon = QtGui.QIcon(visible_icon_path) if is_visible else QtGui.QIcon(hidden_icon_path) + item.setIcon(2, icon) + item.setToolTip(2, "Visible" if is_visible else "Hidden") + def _enable_scene_callbacks(self) -> None: """ Ensure scene callbacks are registered. @@ -818,6 +963,7 @@ def show_context_menu(self, point: QtCore.QPoint) -> None: if has_guide: visibility_actions.append(self.guide_visibility_action) + visibility_actions.append(self.unhide_all_guide_action) if has_rig: visibility_actions.append(self.ctrl_visibility_action) @@ -859,6 +1005,20 @@ def clear_items(self) -> None: self._uuid_to_item.clear() self._guide = None + def mousePressEvent(self, event: QtGui.QMouseEvent) -> None: + """ + Handle mouse presses so that clicking the visibility column + toggles the icon instead of using a button widget. + """ + index = self.indexAt(event.pos()) + if index.isValid() and index.column() == 2: + item = self.itemFromIndex(index) + if item: + self.on_visibility_icon_clicked(item) + + # -- Keep normal selection behavior + super(GuideTreeWidget, self).mousePressEvent(event) + def teardown(self) -> None: """ Fully tear down the tree widget before the parent UI is closed. diff --git a/release/scripts/mgear/shifter/guide_explorer/guide_tree_widget_items.py b/release/scripts/mgear/shifter/guide_explorer/guide_tree_widget_items.py index fbd26023..e301e3aa 100644 --- a/release/scripts/mgear/shifter/guide_explorer/guide_tree_widget_items.py +++ b/release/scripts/mgear/shifter/guide_explorer/guide_tree_widget_items.py @@ -1,6 +1,8 @@ from mgear.vendor.Qt import QtCore, QtWidgets, QtGui from typing import Optional +from mgear.shifter.guide_explorer import utils as guide_explorer_utils + class GuideTreeWidgetItem(QtWidgets.QTreeWidgetItem): """ @@ -53,7 +55,8 @@ def __init__(self, self.component_name: str = component self.component_type: str = comp_type - self.setIcon(0, QtGui.QIcon(":advancedSettings.png")) + icon_path = str(guide_explorer_utils.get_mgear_icon_path("mgear_gear.png")) + self.setIcon(0, QtGui.QIcon(icon_path)) self.setToolTip(0, component) diff --git a/release/scripts/mgear/shifter/guide_explorer/guide_visibility_delegate.py b/release/scripts/mgear/shifter/guide_explorer/guide_visibility_delegate.py new file mode 100644 index 00000000..ad30c303 --- /dev/null +++ b/release/scripts/mgear/shifter/guide_explorer/guide_visibility_delegate.py @@ -0,0 +1,66 @@ +from mgear.vendor.Qt import QtCore, QtWidgets, QtGui + + +class VisibilityDelegate(QtWidgets.QStyledItemDelegate): + """ + Item delegate responsible for custom rendering of the visibility column. + + The delegate suppresses the default icon and text drawing performed by + ``QStyledItemDelegate`` for the designated visibility column (logical index 2), + and instead draws a single centered icon at a fixed size. + + :param icon_size: The size at which the visibility icon should be painted. + :type icon_size: QtCore.QSize + :param parent: Optional parent widget. + :type parent: QtWidgets.QWidget or None + :return: None + """ + def __init__(self, icon_size: QtCore.QSize, parent=None) -> None: + super(VisibilityDelegate, self).__init__(parent) + self._icon_size = icon_size + + def paint(self, + painter: QtGui.QPainter, + option: QtWidgets.QStyleOptionViewItem, + index: QtCore.QModelIndex) -> None: + """ + Paint the visibility icon for the corresponding cell. + + For all non-visibility columns, the base delegate paints normally. + For the visibility column, the default text and icon drawing is skipped + and replaced with a manually rendered icon that is visually centered + and scaled to the configured icon size. + + :param painter: Painter used for drawing the item. + :param option: Style options describing the cell. + :param index: Model index of the cell being rendered. + :return: None + """ + # -- Only customize the visibility column (logical index 2) + if index.column() != 2: + super(VisibilityDelegate, self).paint(painter, option, index) + return + + # -- Prepare a style option but clear the icon & text + opt = QtWidgets.QStyleOptionViewItem(option) + self.initStyleOption(opt, index) + # -- Prevent default icon draw + opt.icon = QtGui.QIcon() + # -- No text in the visibility column + opt.text = "" + + style = opt.widget.style() if opt.widget else QtWidgets.QApplication.style() + style.drawControl(QtWidgets.QStyle.CE_ItemViewItem, opt, painter) + + # -- Now draw our scaled icon once (Was getting duplicates) + icon = index.data(QtCore.Qt.DecorationRole) + if not isinstance(icon, QtGui.QIcon): + return + + size = self._icon_size + rect = opt.rect + x = rect.x() + (rect.width() - size.width()) // 2 + y = rect.y() + (rect.height() - size.height()) // 2 + target_rect = QtCore.QRect(x, y, size.width(), size.height()) + + icon.paint(painter, target_rect) diff --git a/release/scripts/mgear/shifter/guide_explorer/utils.py b/release/scripts/mgear/shifter/guide_explorer/utils.py index ea2cba44..f6778359 100644 --- a/release/scripts/mgear/shifter/guide_explorer/utils.py +++ b/release/scripts/mgear/shifter/guide_explorer/utils.py @@ -1,4 +1,6 @@ -from typing import List, Sequence, Union +import os +from typing import List, Optional, Sequence, Union +from pathlib import Path import mgear.pymaya as pm @@ -44,4 +46,45 @@ def __exit__(self, exc_type, exc, tb): else: pm.select(clear=True) except Exception: - pass \ No newline at end of file + pass + + +def get_mgear_icon_search_paths() -> List[Path]: + """ + Return all mGear-related icon search paths from XBMLANGPATH. + + These paths represent the effective icon directories Maya is using + after module resolution. + + :return: List of icon search paths containing "mgear". + """ + paths: List[Path] = [] + + xbm_lang_path = os.environ.get("XBMLANGPATH", "") + if not xbm_lang_path: + return paths + + for entry in xbm_lang_path.split(os.pathsep): + if "mgear" not in entry.lower(): + continue + + p = Path(entry) + if p.is_dir() and p not in paths: + paths.append(p) + + return paths + + +def get_mgear_icon_path(filename: str) -> Optional[Path]: + """ + Resolve a full path to an mGear icon file using Maya's resolved icon paths. + + :param filename: Icon file name (e.g. "visibility_on.svg"). + :return: Absolute Path to the icon if found, otherwise None. + """ + for base_path in get_mgear_icon_search_paths(): + candidate = base_path / filename + if candidate.is_file(): + return candidate + + return None \ No newline at end of file