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
This commit is contained in:
forbes
2026-02-07 13:32:23 -06:00
parent 7b1e76c791
commit 3298d1c6dc
5 changed files with 241 additions and 8 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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"<span style='color:{color};'>&#9679;</span> {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

View File

@@ -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())

View File

@@ -456,6 +456,19 @@ ICON_APPEARANCE_ENGINEERING_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" vi
<circle cx="16" cy="16" r="2" fill="{MOCHA["crust"]}"/>
</svg>'''
# Set Category icon - tag with color swatches
ICON_SET_CATEGORY_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
<!-- Tag shape -->
<path d="M5 8 L18 8 L25 16 L18 24 L5 24 Z" fill="{MOCHA["surface1"]}" stroke="{MOCHA["overlay1"]}" stroke-width="1.5"/>
<circle cx="10" cy="16" r="2" fill="{MOCHA["surface0"]}"/>
<!-- Color dots representing categories -->
<circle cx="22" cy="8" r="3" fill="{MOCHA["peach"]}"/>
<circle cx="28" cy="8" r="3" fill="{MOCHA["green"]}"/>
<circle cx="22" cy="14" r="3" fill="{MOCHA["blue"]}"/>
<circle cx="28" cy="14" r="3" fill="{MOCHA["yellow"]}"/>
</svg>'''
# =============================================================================
# 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():