commit 658a427132a2a99f852accf943a05822b1d53862 Author: forbes-0023 Date: Mon Feb 23 14:11:06 2026 -0600 feat: QuickNav Phase 1 — core infrastructure (#320) Keyboard-driven command navigation addon for Kindred Create. - Event filter with key routing (0=toggle, 1-9=commands, Shift+1-9=groupings, Ctrl+1-9=workbenches) - Navigation bar (QToolBar) with workbench/grouping/command display - QuickNavManager singleton with workbench switching, grouping selection, command execution - Hardcoded workbench slots (Sketcher, PartDesign, Assembly, Spreadsheet, TechDraw) - Input widget safety (QLineEdit, QTextEdit, QAbstractSpinBox, TaskView) - Numpad support via KeypadModifier stripping - Conditional Catppuccin theming via SDK (Qt defaults on standalone FreeCAD) - QuickNavWorkbench with transparent overlay pattern - Preference persistence (BaseApp/Preferences/Mod/QuickNav) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/Init.py b/Init.py new file mode 100644 index 0000000..9ad3834 --- /dev/null +++ b/Init.py @@ -0,0 +1,3 @@ +import FreeCAD + +FreeCAD.Console.PrintLog("quicknav addon loaded\n") diff --git a/InitGui.py b/InitGui.py new file mode 100644 index 0000000..cc49b17 --- /dev/null +++ b/InitGui.py @@ -0,0 +1,37 @@ +import FreeCAD as App +import FreeCADGui as Gui + + +class QuickNavWorkbench(Gui.Workbench): + """Invisible workbench that installs QuickNav on load. + + QuickNav does not replace the active workbench -- it layers on top. + Loading QuickNav installs the event filter and nav bar, then + immediately re-activates the previously active workbench. + """ + + MenuText = "QuickNav" + ToolTip = "Keyboard-driven command navigation" + + def Initialize(self): + from quicknav.core import QuickNavManager + + QuickNavManager.instance().install() + App.Console.PrintMessage("QuickNav workbench initialized\n") + + def Activated(self): + from quicknav.core import QuickNavManager + + QuickNavManager.instance().handle_workbench_activated() + + def Deactivated(self): + pass + + def GetClassName(self): + return "Gui::PythonWorkbench" + + +Gui.addWorkbench(QuickNavWorkbench()) + +# Eager command registration +from quicknav.commands import QuickNav_Toggle # noqa: F401 diff --git a/package.xml b/package.xml new file mode 100644 index 0000000..4836cff --- /dev/null +++ b/package.xml @@ -0,0 +1,25 @@ + + + QuickNav + Keyboard-driven command navigation + 0.1.0 + Kindred Systems + LGPL-2.1 + https://git.kindred-systems.com/kindred/quicknav + + + + QuickNavWorkbench + ./ + + + + + 0.1.0 + 20 + true + + sdk + + + diff --git a/quicknav/__init__.py b/quicknav/__init__.py new file mode 100644 index 0000000..4ece8f6 --- /dev/null +++ b/quicknav/__init__.py @@ -0,0 +1 @@ +"""QuickNav -- keyboard-driven command navigation for FreeCAD and Kindred Create.""" diff --git a/quicknav/commands.py b/quicknav/commands.py new file mode 100644 index 0000000..fb71108 --- /dev/null +++ b/quicknav/commands.py @@ -0,0 +1,24 @@ +"""FreeCAD command registrations for QuickNav.""" + +import FreeCADGui as Gui + + +class QuickNav_Toggle: + """Toggle QuickNav keyboard navigation on/off.""" + + def GetResources(self): + return { + "MenuText": "Toggle QuickNav", + "ToolTip": "Toggle keyboard-driven command navigation on/off", + } + + def IsActive(self): + return True + + def Activated(self): + from quicknav.core import QuickNavManager + + QuickNavManager.instance().toggle_active() + + +Gui.addCommand("QuickNav_Toggle", QuickNav_Toggle()) diff --git a/quicknav/core.py b/quicknav/core.py new file mode 100644 index 0000000..14bca92 --- /dev/null +++ b/quicknav/core.py @@ -0,0 +1,208 @@ +"""QuickNav manager singleton. + +Orchestrates the event filter, navigation bar, and workbench/grouping +state. Created once when the QuickNavWorkbench is first activated. +""" + +import FreeCAD as App +import FreeCADGui as Gui + +from quicknav.workbench_map import ( + get_command, + get_grouping, + get_groupings, + get_workbench_slot, +) + +_PREF_PATH = "User parameter:BaseApp/Preferences/Mod/QuickNav" + + +class QuickNavManager: + """Singleton managing QuickNav lifecycle and state.""" + + _instance = None + + @classmethod + def instance(cls): + if cls._instance is None: + cls._instance = cls() + return cls._instance + + def __init__(self): + self._active = False + self._installed = False + self._event_filter = None + self._nav_bar = None + self._current_workbench_slot = 2 # PartDesign default + self._current_grouping_idx = 0 + self.previous_workbench = None + self._reactivating = False + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + def install(self): + """Install the event filter and navigation bar.""" + if self._installed: + return + + mw = Gui.getMainWindow() + if mw is None: + App.Console.PrintWarning("QuickNav: main window not available\n") + return + + # Capture which workbench was active before QuickNav loaded. + try: + wb = Gui.activeWorkbench() + if wb: + self.previous_workbench = wb.__class__.__name__ + except Exception: + pass + + # Event filter + from quicknav.event_filter import QuickNavEventFilter + + self._event_filter = QuickNavEventFilter(manager=self, parent=mw) + mw.installEventFilter(self._event_filter) + + # Navigation bar + from quicknav.nav_bar import QuickNavBar + + self._nav_bar = QuickNavBar(manager=self, parent=mw) + mw.addToolBar(self._nav_bar) + mw.insertToolBarBreak(self._nav_bar) + + # Read saved preference + self._active = self._load_preference() + if not self._active: + self._nav_bar.hide() + + self._installed = True + self._update_nav_bar() + App.Console.PrintLog("QuickNav: installed\n") + + def uninstall(self): + """Remove the event filter and navigation bar.""" + if not self._installed: + return + + mw = Gui.getMainWindow() + if mw and self._event_filter: + mw.removeEventFilter(self._event_filter) + self._event_filter = None + + if self._nav_bar: + self._nav_bar.hide() + self._nav_bar.setParent(None) + self._nav_bar.deleteLater() + self._nav_bar = None + + self._installed = False + App.Console.PrintLog("QuickNav: uninstalled\n") + + # ------------------------------------------------------------------ + # State queries + # ------------------------------------------------------------------ + + def is_active(self): + return self._active + + # ------------------------------------------------------------------ + # Actions + # ------------------------------------------------------------------ + + def toggle_active(self): + """Toggle QuickNav on/off.""" + self._active = not self._active + self._save_preference() + if self._nav_bar: + if self._active: + self._nav_bar.show() + self._update_nav_bar() + else: + self._nav_bar.hide() + state = "on" if self._active else "off" + App.Console.PrintMessage(f"QuickNav: {state}\n") + + def switch_workbench(self, n): + """Switch to the workbench assigned to Ctrl+N.""" + slot = get_workbench_slot(n) + if slot is None: + return + self._current_workbench_slot = n + self._current_grouping_idx = 0 + + # Activate the target workbench in FreeCAD. + try: + Gui.activateWorkbench(slot["class_name"]) + except Exception as e: + App.Console.PrintWarning(f"QuickNav: could not activate {slot['class_name']}: {e}\n") + + self._update_nav_bar() + + def switch_grouping(self, n): + """Switch to the Nth grouping (1-based) in the current workbench.""" + slot = get_workbench_slot(self._current_workbench_slot) + if slot is None: + return + groupings = get_groupings(slot["key"]) + idx = n - 1 + if 0 <= idx < len(groupings): + self._current_grouping_idx = idx + self._update_nav_bar() + + def execute_command(self, n): + """Execute the Nth command (1-based) in the active grouping.""" + slot = get_workbench_slot(self._current_workbench_slot) + if slot is None: + return + cmd_id = get_command(slot["key"], self._current_grouping_idx, n) + if cmd_id: + try: + Gui.runCommand(cmd_id) + except Exception as e: + App.Console.PrintWarning(f"QuickNav: command {cmd_id} failed: {e}\n") + + # ------------------------------------------------------------------ + # Workbench re-activation guard + # ------------------------------------------------------------------ + + def handle_workbench_activated(self): + """Called from QuickNavWorkbench.Activated() to restore the + previous workbench without infinite recursion.""" + if self._reactivating: + return + if self.previous_workbench: + self._reactivating = True + try: + Gui.activateWorkbench(self.previous_workbench) + except Exception: + pass + finally: + self._reactivating = False + + # ------------------------------------------------------------------ + # Internal + # ------------------------------------------------------------------ + + def _update_nav_bar(self): + if not self._nav_bar: + return + slot = get_workbench_slot(self._current_workbench_slot) + if slot is None: + return + groupings = get_groupings(slot["key"]) + grouping = get_grouping(slot["key"], self._current_grouping_idx) + commands = grouping["commands"] if grouping else [] + self._nav_bar.update_display( + slot["display"], groupings, self._current_grouping_idx, commands + ) + + def _load_preference(self): + param = App.ParamGet(_PREF_PATH) + return param.GetBool("Enabled", True) + + def _save_preference(self): + param = App.ParamGet(_PREF_PATH) + param.SetBool("Enabled", self._active) diff --git a/quicknav/event_filter.py b/quicknav/event_filter.py new file mode 100644 index 0000000..7509bca --- /dev/null +++ b/quicknav/event_filter.py @@ -0,0 +1,84 @@ +"""Key event filter for QuickNav. + +Installed on FreeCAD's main window to intercept number key presses +when QuickNav is active. Passes through all events when inactive +(except Key_0 for re-enable toggle). +""" + +from PySide.QtCore import QEvent, QObject, Qt +from PySide.QtWidgets import ( + QAbstractSpinBox, + QApplication, + QLineEdit, + QPlainTextEdit, + QTextEdit, +) + +# Widget types that accept text input. QAbstractSpinBox covers +# QSpinBox, QDoubleSpinBox, and FreeCAD's custom QuantitySpinBox +# (used for Sketcher inline dimension entry). +_TEXT_INPUT_TYPES = (QLineEdit, QTextEdit, QPlainTextEdit, QAbstractSpinBox) + + +class QuickNavEventFilter(QObject): + """Intercepts key presses for QuickNav dispatch.""" + + def __init__(self, manager, parent=None): + super().__init__(parent) + self._manager = manager + + def eventFilter(self, obj, event): + if event.type() != QEvent.KeyPress: + return False + + key = event.key() + modifiers = event.modifiers() & ~Qt.KeypadModifier + + # Key_0 with no modifiers always toggles, even when inactive. + if key == Qt.Key_0 and modifiers == Qt.NoModifier: + if not self._focus_is_text_input(): + self._manager.toggle_active() + return True + return False + + if not self._manager.is_active(): + return False + + # Don't intercept when a text input has focus. + if self._focus_is_text_input(): + return False + + if Qt.Key_1 <= key <= Qt.Key_9: + n = key - Qt.Key_0 + if modifiers == Qt.ControlModifier: + self._manager.switch_workbench(n) + return True + elif modifiers == Qt.ShiftModifier: + self._manager.switch_grouping(n) + return True + elif modifiers == Qt.NoModifier: + self._manager.execute_command(n) + return True + + return False + + def _focus_is_text_input(self): + """Return True if the focused widget is a text/number input.""" + focused = QApplication.focusWidget() + if focused is None: + return False + if isinstance(focused, _TEXT_INPUT_TYPES): + return True + return self._is_in_task_panel(focused) + + @staticmethod + def _is_in_task_panel(widget): + """Return True if *widget* is inside FreeCAD's task panel.""" + parent = widget.parent() if widget else None + while parent is not None: + name = parent.objectName() or "" + cls = parent.__class__.__name__ + if "TaskView" in name or "TaskView" in cls: + return True + parent = parent.parent() if hasattr(parent, "parent") else None + return False diff --git a/quicknav/nav_bar.py b/quicknav/nav_bar.py new file mode 100644 index 0000000..e332672 --- /dev/null +++ b/quicknav/nav_bar.py @@ -0,0 +1,142 @@ +"""Navigation bar widget for QuickNav. + +A QToolBar that displays the current workbench, command groupings, +and numbered commands. Positioned at the bottom of the toolbar area. +""" + +from PySide.QtCore import Signal +from PySide.QtWidgets import QHBoxLayout, QLabel, QToolBar, QWidget + + +class _ClickableLabel(QLabel): + """QLabel that emits *clicked* on mouse press.""" + + clicked = Signal() + + def mousePressEvent(self, event): + self.clicked.emit() + super().mousePressEvent(event) + + +class QuickNavBar(QToolBar): + """Bottom toolbar showing QuickNav state.""" + + _MAX_SLOTS = 9 + + def __init__(self, manager, parent=None): + super().__init__("QuickNav", parent) + self._manager = manager + self.setObjectName("QuickNavBar") + self.setMovable(False) + self.setFloatable(False) + + container = QWidget() + layout = QHBoxLayout(container) + layout.setContentsMargins(4, 2, 4, 2) + layout.setSpacing(0) + + # Workbench label + self._wb_label = QLabel() + self._wb_label.setContentsMargins(4, 0, 8, 0) + layout.addWidget(self._wb_label) + + # Vertical separator + sep1 = QLabel("|") + sep1.setContentsMargins(4, 0, 4, 0) + layout.addWidget(sep1) + + # Grouping labels + self._grouping_labels = [] + for i in range(self._MAX_SLOTS): + label = _ClickableLabel() + label.setContentsMargins(4, 0, 4, 0) + label.clicked.connect(lambda n=i + 1: self._manager.switch_grouping(n)) + self._grouping_labels.append(label) + layout.addWidget(label) + + # Vertical separator + self._cmd_sep = QLabel("|") + self._cmd_sep.setContentsMargins(4, 0, 4, 0) + layout.addWidget(self._cmd_sep) + + # Command labels + self._command_labels = [] + for i in range(self._MAX_SLOTS): + label = _ClickableLabel() + label.setContentsMargins(4, 0, 4, 0) + label.clicked.connect(lambda n=i + 1: self._manager.execute_command(n)) + self._command_labels.append(label) + layout.addWidget(label) + + layout.addStretch() + self.addWidget(container) + + self._apply_theme() + + def update_display(self, wb_name, groupings, active_grouping_idx, commands): + """Refresh the bar with current state. + + Parameters + ---------- + wb_name : str + Workbench display name. + groupings : list[dict] + Grouping dicts with ``"name"`` keys. + active_grouping_idx : int + 0-based index of the active grouping. + commands : list[tuple[str, str]] + ``(command_id, display_name)`` for the active grouping. + """ + self._wb_label.setText(f"WB: {wb_name}") + + for i, label in enumerate(self._grouping_labels): + if i < len(groupings): + prefix = f"\u276{i + 1}" if i == active_grouping_idx else f"{i + 1}" + label.setText(f" {prefix}: {groupings[i]['name']} ") + if i == active_grouping_idx: + label.setStyleSheet(self._active_grouping_style) + else: + label.setStyleSheet(self._inactive_grouping_style) + label.show() + else: + label.hide() + + for i, label in enumerate(self._command_labels): + if i < len(commands): + label.setText(f" {i + 1}: {commands[i][1]} ") + label.setStyleSheet(self._command_style) + label.show() + else: + label.hide() + + def _apply_theme(self): + """Apply Catppuccin Mocha theme if SDK is available, else Qt defaults.""" + palette = None + try: + from kindred_sdk import load_palette + + palette = load_palette() + except ImportError: + pass + + if palette: + bg = palette.get("background.toolbar") or "#181825" + fg = palette.get("foreground") or "#cdd6f4" + accent = palette.get("accent.info") or "#89b4fa" + border = palette.get("border") or "#45475a" + muted = palette.get("foreground.muted") or "#a6adc8" + else: + # No theme — leave styles empty so Qt defaults apply. + self._active_grouping_style = "font-weight: bold;" + self._inactive_grouping_style = "" + self._command_style = "" + self._wb_label.setStyleSheet("font-weight: bold;") + return + + self.setStyleSheet( + f"QToolBar#QuickNavBar {{ background: {bg}; border-top: 1px solid {border}; }}" + ) + self._wb_label.setStyleSheet(f"color: {accent}; font-weight: bold;") + self._active_grouping_style = f"color: {accent}; font-weight: bold;" + self._inactive_grouping_style = f"color: {muted};" + self._command_style = f"color: {fg};" diff --git a/quicknav/workbench_map.py b/quicknav/workbench_map.py new file mode 100644 index 0000000..cf14151 --- /dev/null +++ b/quicknav/workbench_map.py @@ -0,0 +1,285 @@ +"""Static workbench slot assignments and command groupings. + +Phase 1 uses hardcoded data from the QuickNav spec (SPEC.md section 5). +Phase 2 replaces this with dynamic toolbar introspection. +""" + +# Fixed Ctrl+N workbench assignments +WORKBENCH_SLOTS = { + 1: { + "key": "sketcher", + "class_name": "SketcherWorkbench", + "display": "Sketcher", + }, + 2: { + "key": "partdesign", + "class_name": "PartDesignWorkbench", + "display": "Part Design", + }, + 3: { + "key": "assembly", + "class_name": "AssemblyWorkbench", + "display": "Assembly", + }, + 4: { + "key": "spreadsheet", + "class_name": "SpreadsheetWorkbench", + "display": "Spreadsheet", + }, + 5: { + "key": "techdraw", + "class_name": "TechDrawWorkbench", + "display": "TechDraw", + }, +} + +# Command groupings per workbench. Each grouping has a name and up to +# 9 commands as (FreeCAD_command_id, display_name) tuples. +WORKBENCH_GROUPINGS = { + "sketcher": [ + { + "name": "Primitives", + "commands": [ + ("Sketcher_CreateLine", "Line"), + ("Sketcher_CreateRectangle", "Rectangle"), + ("Sketcher_CreateCircle", "Circle"), + ("Sketcher_CreateArc", "Arc"), + ("Sketcher_CreatePoint", "Point"), + ("Sketcher_CreateSlot", "Slot"), + ("Sketcher_CreateBSpline", "B-Spline"), + ("Sketcher_CreatePolyline", "Polyline"), + ("Sketcher_CreateEllipseByCenter", "Ellipse"), + ], + }, + { + "name": "Constraints", + "commands": [ + ("Sketcher_ConstrainCoincidentUnified", "Coincident"), + ("Sketcher_ConstrainHorizontal", "Horizontal"), + ("Sketcher_ConstrainVertical", "Vertical"), + ("Sketcher_ConstrainParallel", "Parallel"), + ("Sketcher_ConstrainPerpendicular", "Perpendicular"), + ("Sketcher_ConstrainTangent", "Tangent"), + ("Sketcher_ConstrainEqual", "Equal"), + ("Sketcher_ConstrainSymmetric", "Symmetric"), + ("Sketcher_ConstrainBlock", "Block"), + ], + }, + { + "name": "Dimensions", + "commands": [ + ("Sketcher_ConstrainDistance", "Distance"), + ("Sketcher_ConstrainDistanceX", "Horiz. Distance"), + ("Sketcher_ConstrainDistanceY", "Vert. Distance"), + ("Sketcher_ConstrainRadius", "Radius"), + ("Sketcher_ConstrainDiameter", "Diameter"), + ("Sketcher_ConstrainAngle", "Angle"), + ("Sketcher_ConstrainLock", "Lock"), + ("Sketcher_ConstrainSnellsLaw", "Refraction"), + ], + }, + { + "name": "Construction", + "commands": [ + ("Sketcher_ToggleConstruction", "Toggle Constr."), + ("Sketcher_External", "External Geom."), + ("Sketcher_CarbonCopy", "Carbon Copy"), + ("Sketcher_Offset", "Offset"), + ("Sketcher_Trimming", "Trim"), + ("Sketcher_Extend", "Extend"), + ("Sketcher_Split", "Split"), + ], + }, + { + "name": "Tools", + "commands": [ + ("Sketcher_Symmetry", "Mirror"), + ("Sketcher_RectangularArray", "Linear Array"), + ("Sketcher_Move", "Move"), + ("Sketcher_Rotate", "Rotate"), + ("Sketcher_Scale", "Scale"), + ("Sketcher_CloseShape", "Close Shape"), + ("Sketcher_ConnectLines", "Connect Edges"), + ], + }, + ], + "partdesign": [ + { + "name": "Additive", + "commands": [ + ("PartDesign_Pad", "Pad"), + ("PartDesign_Revolution", "Revolution"), + ("PartDesign_AdditiveLoft", "Add. Loft"), + ("PartDesign_AdditivePipe", "Add. Pipe"), + ("PartDesign_AdditiveHelix", "Add. Helix"), + ("PartDesign_CompPrimitiveAdditive", "Add. Primitive"), + ], + }, + { + "name": "Subtractive", + "commands": [ + ("PartDesign_Pocket", "Pocket"), + ("PartDesign_Hole", "Hole"), + ("PartDesign_Groove", "Groove"), + ("PartDesign_SubtractiveLoft", "Sub. Loft"), + ("PartDesign_SubtractivePipe", "Sub. Pipe"), + ("PartDesign_SubtractiveHelix", "Sub. Helix"), + ("PartDesign_CompPrimitiveSubtractive", "Sub. Primitive"), + ], + }, + { + "name": "Datums", + "commands": [ + ("PartDesign_NewSketch", "New Sketch"), + ("PartDesign_Plane", "Datum Plane"), + ("PartDesign_Line", "Datum Line"), + ("PartDesign_Point", "Datum Point"), + ("PartDesign_ShapeBinder", "Shape Binder"), + ("PartDesign_SubShapeBinder", "Sub-Shape Binder"), + ("ZTools_DatumCreator", "ZT Datum Creator"), + ("ZTools_DatumManager", "ZT Datum Manager"), + ], + }, + { + "name": "Transformations", + "commands": [ + ("PartDesign_Mirrored", "Mirrored"), + ("PartDesign_LinearPattern", "Linear Pattern"), + ("PartDesign_PolarPattern", "Polar Pattern"), + ("PartDesign_MultiTransform", "MultiTransform"), + ("ZTools_RotatedLinearPattern", "ZT Rot. Linear"), + ], + }, + { + "name": "Modeling", + "commands": [ + ("PartDesign_Fillet", "Fillet"), + ("PartDesign_Chamfer", "Chamfer"), + ("PartDesign_Draft", "Draft"), + ("PartDesign_Thickness", "Thickness"), + ("PartDesign_Boolean", "Boolean"), + ("ZTools_EnhancedPocket", "ZT Enh. Pocket"), + ], + }, + ], + "assembly": [ + { + "name": "Components", + "commands": [ + ("Assembly_InsertLink", "Insert Component"), + ("Assembly_InsertNewPart", "Create Part"), + ("Assembly_CreateAssembly", "Create Assembly"), + ("Assembly_ToggleGrounded", "Ground"), + ("Assembly_CreateBom", "BOM"), + ], + }, + { + "name": "Joints", + "commands": [ + ("Assembly_CreateJointFixed", "Fixed"), + ("Assembly_CreateJointRevolute", "Revolute"), + ("Assembly_CreateJointCylindrical", "Cylindrical"), + ("Assembly_CreateJointSlider", "Slider"), + ("Assembly_CreateJointBall", "Ball"), + ("Assembly_CreateJointDistance", "Distance"), + ("Assembly_CreateJointAngle", "Angle"), + ("Assembly_CreateJointParallel", "Parallel"), + ("Assembly_CreateJointPerpendicular", "Perpendicular"), + ], + }, + { + "name": "Patterns", + "commands": [ + ("ZTools_AssemblyLinearPattern", "Linear Pattern"), + ("ZTools_AssemblyPolarPattern", "Polar Pattern"), + ], + }, + ], + "spreadsheet": [ + { + "name": "Editing", + "commands": [ + ("Spreadsheet_MergeCells", "Merge Cells"), + ("Spreadsheet_SplitCell", "Split Cell"), + ("Spreadsheet_SetAlias", "Alias"), + ("Spreadsheet_Import", "Import CSV"), + ("Spreadsheet_Export", "Export CSV"), + ], + }, + { + "name": "Formatting", + "commands": [ + ("ZTools_SpreadsheetStyleBold", "Bold"), + ("ZTools_SpreadsheetStyleItalic", "Italic"), + ("ZTools_SpreadsheetStyleUnderline", "Underline"), + ("ZTools_SpreadsheetAlignLeft", "Align Left"), + ("ZTools_SpreadsheetAlignCenter", "Align Center"), + ("ZTools_SpreadsheetAlignRight", "Align Right"), + ("ZTools_SpreadsheetBgColor", "BG Color"), + ("ZTools_SpreadsheetTextColor", "Text Color"), + ("ZTools_SpreadsheetQuickAlias", "Quick Alias"), + ], + }, + ], + "techdraw": [ + { + "name": "Views", + "commands": [ + ("TechDraw_PageDefault", "New Page"), + ("TechDraw_View", "Insert View"), + ("TechDraw_ProjectionGroup", "Projection Group"), + ("TechDraw_SectionView", "Section View"), + ("TechDraw_DetailView", "Detail View"), + ("TechDraw_ActiveView", "Active View"), + ], + }, + { + "name": "Dimensions", + "commands": [ + ("TechDraw_LengthDimension", "Length"), + ("TechDraw_HorizontalDimension", "Horizontal"), + ("TechDraw_VerticalDimension", "Vertical"), + ("TechDraw_RadiusDimension", "Radius"), + ("TechDraw_DiameterDimension", "Diameter"), + ("TechDraw_AngleDimension", "Angle"), + ], + }, + { + "name": "Annotations", + "commands": [ + ("TechDraw_Annotation", "Annotation"), + ("TechDraw_Balloon", "Balloon"), + ("TechDraw_LeaderLine", "Leader Line"), + ("TechDraw_CosmeticVertex", "Cosmetic Vertex"), + ("TechDraw_Midpoints", "Midpoints"), + ("TechDraw_CenterLine", "Center Line"), + ], + }, + ], +} + + +def get_workbench_slot(n): + """Return workbench info dict for Ctrl+N, or None if unassigned.""" + return WORKBENCH_SLOTS.get(n) + + +def get_groupings(workbench_key): + """Return the grouping list for a workbench key.""" + return WORKBENCH_GROUPINGS.get(workbench_key, []) + + +def get_grouping(workbench_key, grouping_idx): + """Return a specific grouping dict by 0-based index, or None.""" + groupings = get_groupings(workbench_key) + if 0 <= grouping_idx < len(groupings): + return groupings[grouping_idx] + return None + + +def get_command(workbench_key, grouping_idx, n): + """Return the FreeCAD command ID for the Nth command (1-based), or None.""" + grouping = get_grouping(workbench_key, grouping_idx) + if grouping and 1 <= n <= len(grouping["commands"]): + return grouping["commands"][n - 1][0] + return None