From 3298d1c6dce2b523162e4919676ec31990208f4f Mon Sep 17 00:00:00 2001 From: forbes Date: Sat, 7 Feb 2026 13:32:23 -0600 Subject: [PATCH] feat: appearance mode toggle UI and observer enhancements - Grouped toolbar dropdown (ZTools_AppearanceMode) replaces two separate buttons - Set Category command (ZTools_SetCategory) with popup menu for batch tagging - Category changes wrapped in undo transactions - Status bar indicator showing current mode, clickable to toggle - Debounced recompute handling (Shape property changes batched at 100ms) - Observer re-applies colors after recompute to prevent reset Closes #21 --- ztools/InitGui.py | 12 ++- ztools/ztools/appearance/manager.py | 67 +++++++++++++-- ztools/ztools/appearance/ui.py | 72 ++++++++++++++++ ztools/ztools/commands/appearance_commands.py | 83 ++++++++++++++++++- ztools/ztools/resources/icons.py | 15 ++++ 5 files changed, 241 insertions(+), 8 deletions(-) create mode 100644 ztools/ztools/appearance/ui.py diff --git a/ztools/InitGui.py b/ztools/InitGui.py index fc6b910..6877f32 100644 --- a/ztools/InitGui.py +++ b/ztools/InitGui.py @@ -258,6 +258,10 @@ class ZToolsWorkbench(Gui.Workbench): # ZTools Appearance Mode Tools # ===================================================================== self.ztools_appearance_tools = [ + "ZTools_AppearanceMode", + "ZTools_SetCategory", + ] + self.ztools_appearance_menu_tools = [ "ZTools_AppearanceRealistic", "ZTools_AppearanceEngineering", ] @@ -307,7 +311,10 @@ class ZToolsWorkbench(Gui.Workbench): self.appendMenu(["Assembly", "Management"], self.assembly_management_tools) self.appendMenu(["Spreadsheet", "Edit"], self.spreadsheet_tools) self.appendMenu(["Spreadsheet", "Format"], self.ztools_spreadsheet_tools) - self.appendMenu(["View", "Appearance Mode"], self.ztools_appearance_tools) + self.appendMenu( + ["View", "Appearance Mode"], + self.ztools_appearance_menu_tools + ["ZTools_SetCategory"], + ) self.appendMenu( "ztools", self.ztools_datum_tools @@ -315,7 +322,8 @@ class ZToolsWorkbench(Gui.Workbench): + self.ztools_pocket_tools + self.ztools_assembly_tools + self.ztools_spreadsheet_tools - + self.ztools_appearance_tools, + + self.ztools_appearance_menu_tools + + ["ZTools_SetCategory"], ) # Register the PartDesign manipulator now that commands exist. diff --git a/ztools/ztools/appearance/manager.py b/ztools/ztools/appearance/manager.py index cce6154..5099950 100644 --- a/ztools/ztools/appearance/manager.py +++ b/ztools/ztools/appearance/manager.py @@ -14,12 +14,16 @@ _MODES = { "engineering": EngineeringMode, } +_RECOLOR_DEBOUNCE_MS = 100 + class _AppearanceObserver: """Document observer that auto-applies appearance mode to new/changed objects.""" def __init__(self, manager: "AppearanceManager"): self._manager = manager + self._pending_recolor: set = set() # object names awaiting recolor + self._debounce_scheduled = False def slotCreatedObject(self, obj) -> None: if self._manager.active_mode.name != "engineering": @@ -35,12 +39,41 @@ class _AppearanceObserver: self._apply_deferred(obj) def slotChangedObject(self, obj, prop: str) -> None: - if prop != "KindredCategory": - return if self._manager.active_mode.name != "engineering": return - if _is_eligible(obj): + if not _is_eligible(obj): + return + + if prop == "KindredCategory": + # Immediate recolor on category change self._manager.active_mode.apply_to_object(obj) + elif prop == "Shape": + # Shape changed (recompute) — debounce to batch multiple updates + self._pending_recolor.add((obj.Document.Name, obj.Name)) + self._schedule_flush() + + def _schedule_flush(self) -> None: + if self._debounce_scheduled: + return + self._debounce_scheduled = True + try: + from PySide.QtCore import QTimer + + QTimer.singleShot(_RECOLOR_DEBOUNCE_MS, self._flush_recolor) + except ImportError: + self._flush_recolor() + + def _flush_recolor(self) -> None: + self._debounce_scheduled = False + pending = list(self._pending_recolor) + self._pending_recolor.clear() + for doc_name, obj_name in pending: + doc = App.getDocument(doc_name) + if doc is None: + continue + obj = doc.getObject(obj_name) + if obj is not None and _is_eligible(obj): + self._manager.active_mode.apply_to_object(obj) def _apply_deferred(self, obj) -> None: if _is_eligible(obj): @@ -78,8 +111,17 @@ class AppearanceManager: for doc in App.listDocuments().values(): self._active_mode.apply(doc) - # Persist + # Persist preference App.ParamGet(_PREF_GROUP).SetString(_PREF_KEY, mode_name) + + # Update status bar + try: + from .ui import show_status_bar + + show_status_bar(mode_name) + except Exception: + pass + App.Console.log(f"ztools: appearance mode set to '{mode_name}'\n") def save_original(self, obj) -> None: @@ -122,12 +164,27 @@ class AppearanceManager: for doc in App.listDocuments().values(): self._active_mode.apply(doc) + # Show status bar + try: + from .ui import show_status_bar + + show_status_bar(self._active_mode.name) + except Exception: + pass + def deactivate(self) -> None: - """Stop observer. Called from Workbench.Deactivated().""" + """Stop observer and remove status bar. Called from Workbench.Deactivated().""" if self._observer is not None: App.removeDocumentObserver(self._observer) self._observer = None + try: + from .ui import hide_status_bar + + hide_status_bar() + except Exception: + pass + # Module-level singleton _manager = None diff --git a/ztools/ztools/appearance/ui.py b/ztools/ztools/appearance/ui.py new file mode 100644 index 0000000..91b9be1 --- /dev/null +++ b/ztools/ztools/appearance/ui.py @@ -0,0 +1,72 @@ +# ztools/appearance/ui.py +# Status bar widget for appearance mode indicator + +import FreeCADGui as Gui +from PySide import QtCore, QtGui + +_MODE_LABELS = { + "realistic": "Realistic", + "engineering": "Engineering", +} + +_MODE_COLORS = { + "realistic": "#7f849c", # overlay1 + "engineering": "#fab387", # peach +} + + +class AppearanceStatusBar(QtGui.QLabel): + """Status bar label showing the current appearance mode. Clickable to toggle.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setCursor(QtCore.Qt.PointingHandCursor) + self.setToolTip("Click to toggle appearance mode") + self.setStyleSheet("padding: 2px 6px;") + self._current_mode = "realistic" + self._update_display() + + def set_mode(self, mode_name: str) -> None: + self._current_mode = mode_name + self._update_display() + + def _update_display(self): + label = _MODE_LABELS.get(self._current_mode, self._current_mode) + color = _MODE_COLORS.get(self._current_mode, "#cdd6f4") + self.setText(f" {label}") + + def mousePressEvent(self, event): + from . import get_manager + + manager = get_manager() + next_mode = "engineering" if self._current_mode == "realistic" else "realistic" + manager.set_mode(next_mode) + super().mousePressEvent(event) + + +_widget = None + + +def show_status_bar(mode_name: str) -> None: + """Add or update the appearance mode status bar widget.""" + global _widget + mw = Gui.getMainWindow() + if mw is None: + return + status_bar = mw.statusBar() + if _widget is None: + _widget = AppearanceStatusBar(status_bar) + status_bar.addPermanentWidget(_widget) + _widget.set_mode(mode_name) + _widget.show() + + +def hide_status_bar() -> None: + """Remove the appearance mode status bar widget.""" + global _widget + if _widget is not None: + mw = Gui.getMainWindow() + if mw is not None: + mw.statusBar().removeWidget(_widget) + _widget.deleteLater() + _widget = None diff --git a/ztools/ztools/commands/appearance_commands.py b/ztools/ztools/commands/appearance_commands.py index 227b7b8..8d44c21 100644 --- a/ztools/ztools/commands/appearance_commands.py +++ b/ztools/ztools/commands/appearance_commands.py @@ -1,10 +1,11 @@ # ztools/commands/appearance_commands.py -# Commands for switching appearance modes +# Commands for switching appearance modes and setting part categories import FreeCAD as App import FreeCADGui as Gui from ztools.appearance import get_manager +from ztools.appearance.engineering import KINDRED_CATEGORIES, ensure_category_property from ztools.resources.icons import get_icon @@ -42,5 +43,85 @@ class ZTools_AppearanceEngineering: return App.ActiveDocument is not None +class ZTools_AppearanceMode: + """Grouped dropdown for appearance mode switching.""" + + def GetCommands(self): + return ("ZTools_AppearanceRealistic", "ZTools_AppearanceEngineering") + + def GetDefaultCommand(self): + return 0 + + def GetResources(self): + return { + "Pixmap": get_icon("appearance_engineering"), + "MenuText": "Appearance Mode", + "ToolTip": "Switch viewport appearance mode", + } + + def IsActive(self): + return App.ActiveDocument is not None + + +class ZTools_SetCategory: + """Set KindredCategory on selected parts via popup menu.""" + + def GetResources(self): + return { + "Pixmap": get_icon("set_category"), + "MenuText": "Set Category", + "ToolTip": "Set KindredCategory on selected parts", + } + + def Activated(self): + from PySide import QtGui + + sel = Gui.Selection.getSelection() + eligible = [ + obj + for obj in sel + if hasattr(obj, "isDerivedFrom") and obj.isDerivedFrom("Part::Feature") + ] + if not eligible: + return + + # Build popup menu with category choices + menu = QtGui.QMenu() + labels = { + "custom_body": "Custom Body", + "fastener": "Fastener", + "structural": "Structural", + "electrical": "Electrical", + "seal_gasket": "Seal/Gasket", + "bearing_bushing": "Bearing/Bushing", + "spring_compliant": "Spring/Compliant", + "moving_part": "Moving Part", + } + for cat in KINDRED_CATEGORIES: + action = menu.addAction(labels.get(cat, cat)) + action.setData(cat) + + chosen = menu.exec_(QtGui.QCursor.pos()) + if chosen is None: + return + + category = chosen.data() + doc = eligible[0].Document + doc.openTransaction("Set KindredCategory") + try: + for obj in eligible: + ensure_category_property(obj) + obj.KindredCategory = category + doc.commitTransaction() + except Exception: + doc.abortTransaction() + raise + + def IsActive(self): + return App.ActiveDocument is not None and bool(Gui.Selection.getSelection()) + + Gui.addCommand("ZTools_AppearanceRealistic", ZTools_AppearanceRealistic()) Gui.addCommand("ZTools_AppearanceEngineering", ZTools_AppearanceEngineering()) +Gui.addCommand("ZTools_AppearanceMode", ZTools_AppearanceMode()) +Gui.addCommand("ZTools_SetCategory", ZTools_SetCategory()) diff --git a/ztools/ztools/resources/icons.py b/ztools/ztools/resources/icons.py index 1d68b3b..c011ad8 100644 --- a/ztools/ztools/resources/icons.py +++ b/ztools/ztools/resources/icons.py @@ -456,6 +456,19 @@ ICON_APPEARANCE_ENGINEERING_SVG = f''' ''' +# Set Category icon - tag with color swatches +ICON_SET_CATEGORY_SVG = f''' + + + + + + + + + +''' + # ============================================================================= # Icon Registry - Base64 encoded for FreeCAD # ============================================================================= @@ -505,6 +518,7 @@ def get_icon(name: str) -> str: "spreadsheet_quick_alias": ICON_SPREADSHEET_QUICK_ALIAS_SVG, "appearance_realistic": ICON_APPEARANCE_REALISTIC_SVG, "appearance_engineering": ICON_APPEARANCE_ENGINEERING_SVG, + "set_category": ICON_SET_CATEGORY_SVG, } if name not in icons: @@ -564,6 +578,7 @@ def save_icons_to_disk(directory: str): "ztools_spreadsheet_quick_alias": ICON_SPREADSHEET_QUICK_ALIAS_SVG, "ztools_appearance_realistic": ICON_APPEARANCE_REALISTIC_SVG, "ztools_appearance_engineering": ICON_APPEARANCE_ENGINEERING_SVG, + "ztools_set_category": ICON_SET_CATEGORY_SVG, } for name, svg in icons.items():