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)
This commit is contained in:
2026-02-23 14:11:06 -06:00
commit 658a427132
10 changed files with 810 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
__pycache__/

3
Init.py Normal file
View File

@@ -0,0 +1,3 @@
import FreeCAD
FreeCAD.Console.PrintLog("quicknav addon loaded\n")

37
InitGui.py Normal file
View File

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

25
package.xml Normal file
View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<package format="1" xmlns="https://wiki.freecad.org/Package_Metadata">
<name>QuickNav</name>
<description>Keyboard-driven command navigation</description>
<version>0.1.0</version>
<maintainer email="dev@kindred-systems.com">Kindred Systems</maintainer>
<license>LGPL-2.1</license>
<url type="repository">https://git.kindred-systems.com/kindred/quicknav</url>
<content>
<workbench>
<classname>QuickNavWorkbench</classname>
<subdirectory>./</subdirectory>
</workbench>
</content>
<kindred>
<min_create_version>0.1.0</min_create_version>
<load_priority>20</load_priority>
<pure_python>true</pure_python>
<dependencies>
<dependency>sdk</dependency>
</dependencies>
</kindred>
</package>

1
quicknav/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""QuickNav -- keyboard-driven command navigation for FreeCAD and Kindred Create."""

24
quicknav/commands.py Normal file
View File

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

208
quicknav/core.py Normal file
View File

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

84
quicknav/event_filter.py Normal file
View File

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

142
quicknav/nav_bar.py Normal file
View File

@@ -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};"

285
quicknav/workbench_map.py Normal file
View File

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