git.lucas.co / hou-control
SideFX Houdini customization package
git clone https://git.lucas.co/hou-control.git

commit7e74eed0a608c8f714bdf95f4ef9589e35d2b6fb
parentfe666aa631
authorLucas Galante <[email protected]>
date2026-09-14 10:20
hc: draw checkboxes and dropdowns on HC Panel entries

A command can now declare its state on the decorator. `state="isGridVisible"`
gives the row a checkbox showing the getter's value; adding `choices=` gives
it a dropdown and the method receives the chosen value. Working the control
applies in place and keeps the panel open; Enter still runs a toggle and
closes, and opens a dropdown. Plain commands stay text items so the Replace
Node picker is unaffected.

Toggles drop their "Toggle" prefix. Update Mode collapses three entries into
one dropdown (Auto, Manual, On Mouse Up); Grid Mode becomes a dropdown with
Houdini's own labels; Draw Mode and Viewport Layout are new dropdowns on the
scene viewer. "Toggle All Paths" is renamed All Pane Tabs, which is what it
did. Toggle Light Geo is removed: it called a method that never existed.

tools/check.py verifies every state getter resolves and every choice method
takes a value.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

 CLAUDE.md                            |   2 +
 python3.13libs/hc/hccommands.py      | 160 ++++++++++++++++++++++--
 python3.13libs/hc/hcnetworkeditor.py |  24 +++-
 python3.13libs/hc/hcpane.py          |   4 +-
 python3.13libs/hc/hcsceneviewer.py   | 232 +++++++++++++++++------------------
 python3.13libs/hc/hcsession.py       | 121 ++++++++++++------
 python3.13libs/hc/hctab.py           |   7 +-
 python3.13libs/hc/hcwidgets.py       | 164 +++++++++++++++++++++++--
 tools/check.py                       |  66 +++++++++-
 9 files changed, 596 insertions(+), 184 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 274f010..e88e502 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -52,6 +52,8 @@ Key hierarchy:
 
 When adding a new command: implement it on the appropriate wrapper (`HCSession`/`HCPane`/`HC*Tab`) and decorate it with `@command("Label")`. That is the whole registration — there is no separate map to update. If it should be hotkey-bound, add a Houdini symbol entry to `hc_hotkeys.json`; menus still need their own `scriptItem` in the XML.
 
+A command that has a state gets a control in the panel. `@command("Grid", state="isGridVisible")` draws a checkbox showing the getter's value (the getter is a method *name* on the same class, so `tools/check.py` can verify it exists; `'0'`/`'1'` pref strings are coerced correctly). `@command("Grid Mode", state="gridMode", choices=(("No Grid", "0"), ...))` draws a dropdown and the method receives the chosen value. Label toggles by what they control (`"Grid"`, not `"Toggle Grid"`) — the checkbox already says it toggles. In the panel, Enter on a toggle runs it and closes as before; Enter on a dropdown opens it; working the control with the mouse applies in place and keeps the panel open. Plain commands stay plain text items — a widget per row would make the Replace Node picker slow.
+
 When adding a new setting: add a `Setting` to `hcschema.SCHEMA`. Defaults, the settings panel control, and the widget type all follow from it.
 
 HC settings cannot live in Houdini's own **Edit > Preferences** window. That window is a compiled-in pane (`h.pane.preferences`) with a fixed page list; no `HOUDINI_PATH` directory contributes pages to it, and HOM exposes only value access (`hou.getPreference` / `setPreference` / `removePreference` / `savePreferences`), no page registration. The Python Panel above is the closest native equivalent.
diff --git a/python3.13libs/hc/hccommands.py b/python3.13libs/hc/hccommands.py
index 98fa000..23be6b0 100644
--- a/python3.13libs/hc/hccommands.py
+++ b/python3.13libs/hc/hccommands.py
@@ -20,33 +20,83 @@ verify they point at commands that exist.
 are always available; commands on a tab class appear whenever the current tab
 is an instance of it, so a method on HCTab shows everywhere and one on
 HCNetworkEditor only in network editors.
+
+A command can also describe the control the panel should draw for it. There
+are three kinds:
+
+    action  -- a plain row. Enter runs the method and closes the panel.
+
+    toggle  -- a row with a checkbox showing the current state. Declare the
+               getter that reads it:
+
+                   @command("Grid", state="isGridVisible")
+                   def toggleGrid(self): ...
+
+               Enter still runs the method and closes; clicking the checkbox
+               runs it and leaves the panel open.
+
+    choice  -- a row with a dropdown. Declare the choices and the getter; the
+               method takes the chosen value:
+
+                   @command("Grid Mode", state="gridMode",
+                            choices=(("No Grid", "0"), ("Grid Points", "1")))
+                   def setGridMode(self, mode): ...
+
+               Enter opens the dropdown; picking an entry applies it and
+               leaves the panel open.
+
+The getter is named, not passed, for the same reason the label is a
+decorator: it has to resolve on the same instance the method is bound to, and
+naming it lets tools/check.py verify it exists.
 """
 
+import inspect
+
 ATTR = "_hc_command"
 
+KINDS = ("action", "toggle", "choice")
+
 
 class Command:
-    __slots__ = ("label", "tabs", "help")
+    __slots__ = ("label", "tabs", "help", "state", "choices", "kind")
 
-    def __init__(self, label, tabs=None, help=None):
+    def __init__(self, label, tabs=None, help=None, state=None, choices=None):
         self.label = label
         # Tab .type() strings this command applies to, or None for all. Needed
         # because a few commands live on HCTab (every tab has the method) but
         # only make sense on some tab types.
         self.tabs = tuple(tabs) if tabs else None
         self.help = help
+        # Name of the method that reads the current value, on the same
+        # instance the command is bound to. Its return is a bool for toggles
+        # and one of the choice values for choices.
+        self.state = state
+        # ((label, value), ...) for choice commands.
+        self.choices = tuple(tuple(c) for c in choices) if choices else None
+        if self.choices is not None:
+            if self.state is None:
+                raise ValueError(f"{label!r}: choices need a state getter")
+            for choice in self.choices:
+                if len(choice) != 2:
+                    raise ValueError(f"{label!r}: choices are (label, value) pairs")
+            self.kind = "choice"
+        elif self.state is not None:
+            self.kind = "toggle"
+        else:
+            self.kind = "action"
 
     def appliesTo(self, tab_type):
         return self.tabs is None or tab_type in self.tabs
 
     def __repr__(self):
-        return f"<Command {self.label!r}>"
+        return f"<Command {self.label!r} {self.kind}>"
 
 
-def command(label, tabs=None, help=None):
+def command(label, tabs=None, help=None, state=None, choices=None):
     """Expose the decorated method in the HC Panel under `label`."""
     def decorate(fn):
-        setattr(fn, ATTR, Command(label, tabs=tabs, help=help))
+        setattr(fn, ATTR, Command(label, tabs=tabs, help=help, state=state,
+                                  choices=choices))
         return fn
     return decorate
 
@@ -67,8 +117,70 @@ def declared(cls):
     return found
 
 
+def asBool(value):
+    """Coerce a state getter's return to a bool.
+
+    Houdini prefs come back as the strings '0' and '1', and bool('0') is
+    True -- which is how a checkbox would end up ticked for every toggle
+    that reads a pref. Only the text of the string counts.
+    """
+    if isinstance(value, str):
+        return value.strip().lower() not in ("", "0", "false", "off", "no")
+    return bool(value)
+
+
+class Bound:
+    """A command bound to an instance.
+
+    Calling it runs the method, exactly like the bound method it replaces --
+    HCMaps used to hand out bare bound methods and the panel just called
+    them. On top of that it can read the current state for the panel's
+    control, and apply a chosen value.
+    """
+
+    __slots__ = ("label", "spec", "instance", "method")
+
+    def __init__(self, label, spec, instance, method):
+        self.label = label
+        self.spec = spec
+        self.instance = instance
+        self.method = method
+
+    @property
+    def kind(self):
+        return self.spec.kind
+
+    @property
+    def choices(self):
+        return self.spec.choices
+
+    def __call__(self, *args, **kwargs):
+        return self.method(*args, **kwargs)
+
+    def state(self):
+        """The current value, or None for an action.
+
+        A bool for a toggle, one of the choice values for a choice. A getter
+        that raises is left to raise: the panel disables the control and
+        reports it, which beats a checkbox quietly showing the wrong state.
+        """
+        if self.spec.state is None:
+            return None
+        value = getattr(self.instance, self.spec.state)()
+        if self.kind == "toggle":
+            return asBool(value)
+        return value
+
+    def set(self, value):
+        """Apply a choice value."""
+        return self.method(value)
+
+    def __repr__(self):
+        return f"<Bound {self.label!r} {self.kind} on {type(self.instance).__name__}>"
+
+
 def bind(instance, tab_type=None):
-    """{label: bound method} for the commands on `instance` that apply.
+    """{label: Bound} for the commands on `instance` that apply.
 
     `tab_type` is the current tab's .type() string; commands scoped to other
     tab types are left out.
@@ -81,9 +193,43 @@ def bind(instance, tab_type=None):
             continue
         method = getattr(instance, name, None)
         if method is not None:
-            bound[label] = method
+            bound[label] = Bound(label, spec, instance, method)
     return bound
 
 
 def labels(cls):
     return sorted(declared(cls))
+
+
+def verify(cls):
+    """Problems with the controls declared on `cls`, as a list of strings.
+
+    Empty when every state getter names a callable on the class and every
+    choice command's method accepts the chosen value. tools/check.py runs
+    this over every wrapper class.
+    """
+    problems = []
+    for label, (name, spec) in declared(cls).items():
+        if spec.state is not None:
+            getter = getattr(cls, spec.state, None)
+            if not callable(getter):
+                problems.append(f"{cls.__name__}.{name} ({label!r}): "
+                                f"state getter {spec.state!r} does not exist")
+        method = getattr(cls, name)
+        try:
+            params = [p for p in inspect.signature(method).parameters.values()
+                      if p.name != "self"]
+        except (TypeError, ValueError):
+            continue
+        required = [p for p in params if p.default is p.empty
+                    and p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)]
+        if spec.kind == "choice":
+            positional = [p for p in params
+                          if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)]
+            if len(required) > 1 or not positional:
+                problems.append(f"{cls.__name__}.{name} ({label!r}): "
+                                f"a choice command takes exactly one value")
+        elif required:
+            problems.append(f"{cls.__name__}.{name} ({label!r}): "
+                            f"the panel calls it with no arguments")
+    return problems
diff --git a/python3.13libs/hc/hcnetworkeditor.py b/python3.13libs/hc/hcnetworkeditor.py
index 2718c64..bec7c90 100644
--- a/python3.13libs/hc/hcnetworkeditor.py
+++ b/python3.13libs/hc/hcnetworkeditor.py
@@ -13,6 +13,14 @@ _grid_prefs = hcstate.Store("grid_pref_sync", hcstate.PANE)
 # Not per-tab at all: hcnetcursor_NxN.png presence, resolved once per path.
 _cursor_asset_exists = {}
 
+# The network editor's `gridmode` pref, labelled as Houdini's own Show Grid
+# radio in NetworkViewMenu.xml labels it.
+GRID_MODES = (
+    ("No Grid", "0"),
+    ("Grid Points", "1"),
+    ("Grid Lines", "2"),
+)
+
 
 class HCNetworkEditor(HCPathTab):
     def __init__(self, hou_tab):
@@ -1062,15 +1070,23 @@ class HCNetworkEditor(HCPathTab):
         mode = self.hou_tab.getPref('dimunusednodes')
         self.hou_tab.setPref('dimunusednodes', map[mode])
 
-    @command("ToggleGrid Mode")
+    def gridMode(self):
+        """The `gridmode` pref as Houdini stores it: '0', '1' or '2'."""
+        return self.hou_tab.getPref('gridmode')
+
+    @command("Grid Mode", state="gridMode", choices=GRID_MODES)
+    def setGridMode(self, mode):
+        self.hou_tab.setPref('gridmode', str(mode))
+
+    # Cycles through the three modes; NetworkViewMenu.xml's Toggle Grid entry
+    # still calls it. The panel lists the dropdown instead.
     def toggleGridMode(self):
         map = {
             '0': '1',
             '1': '2',
             '2': '0'
         }
-        mode = self.hou_tab.getPref('gridmode')
-        self.hou_tab.setPref('gridmode', map[mode])
+        self.setGridMode(map[self.gridMode()])
 
     def isChromeVisible(self):
         return bool(super().isChromeVisible() or self.isMenuOpen())
@@ -1081,7 +1097,7 @@ class HCNetworkEditor(HCPathTab):
         # restored -- matching the original toggleMenus behaviour.
         self.setMenuOpen(0)
 
-    @command("Toggle Network Menu")
+    @command("Network Menu", state="isMenuOpen")
     def toggleMenu(self):
         map = {
             '0': '1',
diff --git a/python3.13libs/hc/hcpane.py b/python3.13libs/hc/hcpane.py
index 28cce26..5545133 100644
--- a/python3.13libs/hc/hcpane.py
+++ b/python3.13libs/hc/hcpane.py
@@ -119,7 +119,7 @@ class HCPane:
     def toggleSplitMaximized(self):
         self.setIsSplitMaximized(not self.isSplitMaximized())
 
-    @command("Maximize Pane")
+    @command("Maximize Pane", state="isMaximized")
     def toggleMaximize(self):
         self.hou_pane.setIsMaximized(not self.isMaximized())
 
@@ -208,6 +208,6 @@ class HCPane:
             tab_names.append(tab.name())
         return tab_names
 
-    @command("Toggle Tabs")
+    @command("Pane Tabs", state="isShowingTabs")
     def toggleTabs(self):
         self.showTabs(not self.isShowingTabs())
diff --git a/python3.13libs/hc/hcsceneviewer.py b/python3.13libs/hc/hcsceneviewer.py
index 94b9d2b..5f4c158 100644
--- a/python3.13libs/hc/hcsceneviewer.py
+++ b/python3.13libs/hc/hcsceneviewer.py
@@ -1,5 +1,5 @@
-import hou, types
-from .hcdrawmode import get_draw_mode_label, next_draw_mode
+import hou, re, types
+from .hcdrawmode import DRAW_MODE_LABELS, DRAW_MODE_SEQUENCE, get_draw_mode_label, next_draw_mode
 from .hcpathtab import HCPathTab
 from .hcviewport import HCViewport
 from .hccommands import command
@@ -47,6 +47,29 @@ setViewportLayout(layout, single=-1)
 3: bottom-right quad viewport (default: Right)
 """
 
+# hou.geometryViewportLayout names, in the order nextLayout() cycles them.
+LAYOUT_NAMES = (
+    "DoubleSide",
+    "DoubleStack",
+    "Quad",
+    "QuadBottomSplit",
+    "QuadLeftSplit",
+    "TripleBottomSplit",
+    "TripleLeftSplit",
+    "Single",
+)
+
+
+def _spaced(name):
+    """'QuadBottomSplit' -> 'Quad Bottom Split'."""
+    return re.sub(r"(?<=[a-z])(?=[A-Z])", " ", name)
+
+
+# (panel label, value) pairs for the two scene viewer dropdowns.
+LAYOUTS = tuple((_spaced(name), name) for name in LAYOUT_NAMES)
+DRAW_MODES = tuple((DRAW_MODE_LABELS[mode], mode) for mode in DRAW_MODE_SEQUENCE)
+
+
 class HCSceneViewer(HCPathTab):
     def __init__(self, hou_tab):
         self.hou_tab = hou_tab
@@ -62,97 +85,80 @@ class HCSceneViewer(HCPathTab):
             displaySets.append(displaySet)
         return displaySets
 
-    @command("Toggle Light Geo")
-    def toggleLightGeo(self):
-        self.setShowLights(not self.showLights())
+    # The display-set toggles all follow one rule: if any viewport shows the
+    # thing, hide it everywhere; otherwise show it everywhere. Each used to
+    # spell that out by hand, so the panel's checkbox now reads the same
+    # "any viewport" answer the toggle acts on.
+
+    def _anyDisplaySetShowing(self, getter):
+        return any(getattr(ds, getter)() for ds in self.allDisplaySets())
+
+    def _showOnAllDisplaySets(self, setter, value):
+        for display_set in self.allDisplaySets():
+            getattr(display_set, setter)(value)
+
+    def _toggleDisplaySets(self, getter, setter):
+        self._showOnAllDisplaySets(setter, not self._anyDisplaySetShowing(getter))
+
+    def isShowingBackfaces(self):
+        return self._anyDisplaySetShowing("isShowingPrimBackfaces")
 
-    @command("Toggle Backface")
+    @command("Backfaces", state="isShowingBackfaces")
     def toggleBackface(self):
-        visible = 0
-        displaySets = self.allDisplaySets()
-        for displaySet in displaySets:
-            if displaySet.isShowingPrimBackfaces():
-                visible = 1
-        for displaySet in displaySets:
-            displaySet.showPrimBackfaces(not visible)
-
-    @command("Toggle Point Markers")
+        self._toggleDisplaySets("isShowingPrimBackfaces", "showPrimBackfaces")
+
+    def isShowingPointMarkers(self):
+        return self._anyDisplaySetShowing("isShowingPointMarkers")
+
+    @command("Point Markers", state="isShowingPointMarkers")
     def togglePointMarkers(self):
-        visible = 0
-        displaySets = self.allDisplaySets()
-        for displaySet in displaySets:
-            if displaySet.isShowingPointMarkers():
-                visible = 1
-        for displaySet in displaySets:
-            displaySet.showPointMarkers(not visible)
-
-    @command("Toggle Point Normals")
+        self._toggleDisplaySets("isShowingPointMarkers", "showPointMarkers")
+
+    def isShowingPointNormals(self):
+        return self._anyDisplaySetShowing("isShowingPointNormals")
+
+    @command("Point Normals", state="isShowingPointNormals")
     def togglePointNormals(self):
-        visible = 0
-        displaySets = self.allDisplaySets()
-        for displaySet in displaySets:
-            if displaySet.isShowingPointNormals():
-                visible = 1
-        for displaySet in displaySets:
-            displaySet.showPointNormals(not visible)
-
-    @command("Toggle Point Numbers")
+        self._toggleDisplaySets("isShowingPointNormals", "showPointNormals")
+
+    def isShowingPointNumbers(self):
+        return self._anyDisplaySetShowing("isShowingPointNumbers")
+
+    @command("Point Numbers", state="isShowingPointNumbers")
     def togglePointNumbers(self):
-        visible = 0
-        displaySets = self.allDisplaySets()
-        for displaySet in displaySets:
-            if displaySet.isShowingPointNumbers():
-                visible = 1
-        for displaySet in displaySets:
-            displaySet.showPointNumbers(not visible)
-
-    @command("Toggle Prim Normals")
+        self._toggleDisplaySets("isShowingPointNumbers", "showPointNumbers")
+
+    def isShowingPrimNormals(self):
+        return self._anyDisplaySetShowing("isShowingPrimNormals")
+
+    @command("Prim Normals", state="isShowingPrimNormals")
     def togglePrimNormals(self):
-        visible = 0
-        displaySets = self.allDisplaySets()
-        for displaySet in displaySets:
-            if displaySet.isShowingPrimNormals():
-                visible = 1
-        for displaySet in displaySets:
-            displaySet.showPrimNormals(not visible)
-
-    @command("Toggle Prim Numbers")
+        self._toggleDisplaySets("isShowingPrimNormals", "showPrimNormals")
+
+    def isShowingPrimNumbers(self):
+        return self._anyDisplaySetShowing("isShowingPrimNumbers")
+
+    @command("Prim Numbers", state="isShowingPrimNumbers")
     def togglePrimNumbers(self):
-        visible = 0
-        displaySets = self.allDisplaySets()
-        for displaySet in displaySets:
-            if displaySet.isShowingPrimNumbers():
-                visible = 1
-        for displaySet in displaySets:
-            displaySet.showPrimNumbers(not visible)
-
-    @command("Toggle Vectors")
-    def toggleVectors(self):
-        for viewport in self.allViewports():
-            settings = viewport.settings()
-            scale = settings.vectorScale()
-            if scale == 1:
-                settings.setVectorScale(0)
-            elif scale == 0:
-                settings.setVectorScale(1)
-            else:
-                settings.setVectorScale(1)
+        self._toggleDisplaySets("isShowingPrimNumbers", "showPrimNumbers")
 
-    def cycleDrawMode(self):
-        next_mode_name = None
-        display_sets = self.allDisplaySets()
+    def isShowingVectors(self):
+        return any(v.settings().vectorScale() != 0 for v in self.allViewports())
 
-        for display_set in display_sets:
-            current_mode = display_set.shadedMode()
-            next_mode_name = next_draw_mode(current_mode.name())
-            break
+    @command("Vectors", state="isShowingVectors")
+    def toggleVectors(self):
+        scale = 0 if self.isShowingVectors() else 1
+        for viewport in self.allViewports():
+            viewport.settings().setVectorScale(scale)
 
-        if next_mode_name is None:
-            next_mode_name = next_draw_mode(None)
+    @command("Draw Mode", state="drawMode", choices=DRAW_MODES)
+    def setDrawMode(self, mode_name):
+        mode = getattr(hou.glShadingType, mode_name)
+        for display_set in self.allDisplaySets():
+            display_set.setShadedMode(mode)
 
-        next_mode = getattr(hou.glShadingType, next_mode_name)
-        for display_set in display_sets:
-            display_set.setShadedMode(next_mode)
+    def cycleDrawMode(self):
+        self.setDrawMode(next_draw_mode(self.drawMode()))
 
     def drawMode(self):
         for display_set in self.allDisplaySets():
@@ -168,10 +174,12 @@ class HCSceneViewer(HCPathTab):
     def referencePlane(self):
         return self.hou_tab.referencePlane()
 
-    @command("Toggle Grid")
+    def isGridVisible(self):
+        return self.referencePlane().isVisible()
+
+    @command("Grid", state="isGridVisible")
     def toggleGrid(self):
-        reference_plane = self.referencePlane()
-        reference_plane.setIsVisible(not reference_plane.isVisible())
+        self.referencePlane().setIsVisible(not self.isGridVisible())
 
 
     """ Layout """
@@ -179,17 +187,11 @@ class HCSceneViewer(HCPathTab):
     def layout(self):
         return self.hou_tab.viewportLayout()
 
+    def layoutName(self):
+        return self.layout().name()
+
     def layouts(self):
-        return (
-            hou.geometryViewportLayout.DoubleSide,
-            hou.geometryViewportLayout.DoubleStack,
-            hou.geometryViewportLayout.Quad,
-            hou.geometryViewportLayout.QuadBottomSplit,
-            hou.geometryViewportLayout.QuadLeftSplit,
-            hou.geometryViewportLayout.TripleBottomSplit,
-            hou.geometryViewportLayout.TripleLeftSplit,
-            hou.geometryViewportLayout.Single,
-        )
+        return tuple(getattr(hou.geometryViewportLayout, name) for name in LAYOUT_NAMES)
 
     def layoutIndices(self):
         return (
@@ -214,6 +216,7 @@ class HCSceneViewer(HCPathTab):
         self.setLayout(layouts[index])
         return
 
+    @command("Viewport Layout", state="layoutName", choices=LAYOUTS)
     def setLayout(self, layout):
         if isinstance(layout, str):
             layout = getattr(hou.geometryViewportLayout, layout, layout)
@@ -234,6 +237,13 @@ class HCSceneViewer(HCPathTab):
     def isVisibleSelectionBar(self):
         return self.hou_tab.isShowingSelectionBar()
 
+    def isAnyBarVisible(self):
+        return bool(
+            self.isVisibleOperationBar()
+            or self.isVisibleDisplayBar()
+            or self.isVisibleSelectionBar()
+        )
+
     def showDisplayBar(self, value):
         self.hou_tab.showDisplayOptionsBar(value)
 
@@ -246,29 +256,24 @@ class HCSceneViewer(HCPathTab):
     def showSelectionBar(self, value):
         self.hou_tab.showSelectionBar(value)
 
-    @command("Toggle Display Bar")
+    @command("Display Bar", state="isVisibleDisplayBar")
     def toggleDisplayBar(self):
         self.showDisplayBar(not self.isVisibleDisplayBar())
 
-    @command("Toggle Group List")
+    @command("Group List", state="isVisibleGroupList")
     def toggleGroupList(self):
         self.showGroupList(not self.isVisibleGroupList())
 
-    @command("Toggle Operation Bar")
+    @command("Operation Bar", state="isVisibleOperationBar")
     def toggleOperationBar(self):
         self.showOperationBar(not self.isVisibleOperationBar())
 
-    @command("Toggle Selection Bar")
+    @command("Selection Bar", state="isVisibleSelectionBar")
     def toggleSelectionBar(self):
         self.showSelectionBar(not self.isVisibleSelectionBar())
 
     def isChromeVisible(self):
-        return bool(
-            super().isChromeVisible()
-            or self.isVisibleOperationBar()
-            or self.isVisibleDisplayBar()
-            or self.isVisibleSelectionBar()
-        )
+        return bool(super().isChromeVisible() or self.isAnyBarVisible())
 
     def showChrome(self, visible):
         super().showChrome(visible)
@@ -276,17 +281,12 @@ class HCSceneViewer(HCPathTab):
         self.showDisplayBar(visible)
         self.showSelectionBar(visible)
 
-    @command("Toggle Bars")
+    @command("Bars", state="isAnyBarVisible")
     def toggleBars(self):
-        state = self.hou_tab.isShowingOperationBar() + self.hou_tab.isShowingDisplayOptionsBar() + self.hou_tab.isShowingSelectionBar()
-        if state > 0:
-            self.showOperationBar(0)
-            self.showDisplayBar(0)
-            self.showSelectionBar(0)
-        else:
-            self.showOperationBar(1)
-            self.showDisplayBar(1)
-            self.showSelectionBar(1)
+        show = not self.isAnyBarVisible()
+        self.showOperationBar(show)
+        self.showDisplayBar(show)
+        self.showSelectionBar(show)
 
     @command("Close Scene Viewer Toolbars")
     def closeToolbars(self):
@@ -332,7 +332,7 @@ class HCSceneViewer(HCPathTab):
     def isListening(self):
         return _hcPrintSceneViewerEvent in self.hou_tab.eventCallbacks()
 
-    @command("Toggle Event Listener")
+    @command("Event Listener", state="isListening")
     def toggleEventListener(self):
         if self.isListening():
             self.removeEventListener()
diff --git a/python3.13libs/hc/hcsession.py b/python3.13libs/hc/hcsession.py
index 3b3ecbc..6377756 100644
--- a/python3.13libs/hc/hcsession.py
+++ b/python3.13libs/hc/hcsession.py
@@ -14,6 +14,13 @@ from .hccommands import command
 SETTINGS_PANEL_SIZE = (720, 600)
 SPREADSHEET_PANEL_SIZE = (1400, 900)
 
+# (panel label, hou.updateMode attribute name) for the Update Mode dropdown.
+UPDATE_MODES = (
+    ("Auto", "AutoUpdate"),
+    ("Manual", "Manual"),
+    ("On Mouse Up", "OnMouseUp"),
+)
+
 
 def floatWindow(window, size=None):
     """Make a Houdini floating panel's toplevel read as a dialog to the WM.
@@ -233,10 +240,19 @@ class HCSession:
     def openPreferences(self):
         hou.ui.openPreferences('ui', '')
 
-    @command("Toggle HC Status")
+    def statusDialog(self):
+        """The HC Status dialog if one has been created, else None."""
+        from .hcstatus import HCStatus
+        return hou.qt.mainWindow().findChild(HCStatus, HCStatus.OBJECT_NAME)
+
+    def isStatusVisible(self):
+        dialog = self.statusDialog()
+        return dialog is not None and dialog.isVisible()
+
+    @command("HC Status", state="isStatusVisible")
     def toggleStatus(self):
         from .hcstatus import HCStatus
-        existing = hou.qt.mainWindow().findChild(HCStatus, HCStatus.OBJECT_NAME)
+        existing = self.statusDialog()
         if existing is None:
             existing = HCStatus()
         if existing.isVisible():
@@ -321,20 +337,35 @@ class HCSession:
             hou.session._hc_split_handles = HCSplitHandles()
         return hou.session._hc_split_handles
 
-    @command("Toggle Split Handles")
+    def isSplitHandlesShowing(self):
+        # Read without creating: the panel asks on every open, and a state
+        # query should not be what first builds the handle manager.
+        handles = getattr(hou.session, "_hc_split_handles", None)
+        return handles is not None and handles.isShowing()
+
+    @command("Split Handles", state="isSplitHandlesShowing")
     def toggleSplitHandles(self):
         self.splitHandles().toggle()
 
-    @command("Toggle Spreadsheet")
-    def toggleSpreadsheet(self):
-        # Search for any floating panel containing a DetailsView tab
+    def spreadsheetPanel(self):
+        """The floating panel holding a DetailsView tab, or None."""
         for fp in hou.ui.floatingPanels():
             for pane in fp.panes():
                 for tab in pane.tabs():
                     if tab.type() == hou.paneTabType.DetailsView:
-                        fp.close()
-                        hou.ui.setStatusMessage("Closed Spreadsheet")
-                        return
+                        return fp
+        return None
+
+    def isSpreadsheetOpen(self):
+        return self.spreadsheetPanel() is not None
+
+    @command("Spreadsheet", state="isSpreadsheetOpen")
+    def toggleSpreadsheet(self):
+        panel = self.spreadsheetPanel()
+        if panel is not None:
+            panel.close()
+            hou.ui.setStatusMessage("Closed Spreadsheet")
+            return
 
         # If not found, open a new floating one with DetailsView. It asks to
         # be floated: like HC Settings, a tiling WM would otherwise give the
@@ -362,7 +393,11 @@ class HCSession:
             hou.session._hc_window_watcher = HCWindowWatcher()
         return hou.session._hc_window_watcher
 
-    @command("Toggle Window Watcher")
+    def isWindowWatcherRunning(self):
+        watcher = getattr(hou.session, "_hc_window_watcher", None)
+        return watcher is not None and watcher.isRunning()
+
+    @command("Window Watcher", state="isWindowWatcherRunning")
     def toggleWindowWatcher(self):
         self.windowWatcher().toggle()
 
@@ -595,35 +630,45 @@ class HCSession:
     def reloadKeycam(self):
         hou.ui.reloadViewerState('keycam')
 
-    @command("Update Mode: Auto")
+    def updateMode(self):
+        """The current update mode's name: 'AutoUpdate', 'Manual', 'OnMouseUp'."""
+        return hou.updateModeSetting().name()
+
+    @command("Update Mode", state="updateMode", choices=UPDATE_MODES)
+    def setUpdateMode(self, mode):
+        """Set the update mode, by hou.updateMode value or by its name."""
+        if isinstance(mode, str):
+            mode = getattr(hou.updateMode, mode)
+        hou.setUpdateMode(mode)
+        hou.ui.setStatusMessage(f"Update mode: {mode.name()}")
+
+    # The three below used to be separate panel entries. They are one
+    # dropdown now; the methods stay for hotkeys and menus.
+
     def setUpdateModeAuto(self):
-        hou.setUpdateMode(hou.updateMode.AutoUpdate)
+        self.setUpdateMode(hou.updateMode.AutoUpdate)
 
-    @command("Update Mode: Manual")
     def setUpdateModeManual(self):
-        hou.setUpdateMode(hou.updateMode.Manual)
+        self.setUpdateMode(hou.updateMode.Manual)
+
+    def toggleUpdateMode(self):
+        # hou.updateModeSetting is a function; the old code stringified the
+        # function object and then called the lookup dict, so this never ran.
+        if hou.updateModeSetting() == hou.updateMode.Manual:
+            self.setUpdateMode(hou.updateMode.AutoUpdate)
+        else:
+            self.setUpdateMode(hou.updateMode.Manual)
 
     def triggerUpdate(self):
         hou.ui.triggerUpdate()
 
-    @command("Toggle Autosave")
+    @command("Autosave", state="isAutoSave")
     def toggleAutoSave(self):
         map = {'0': '1', '1': '0'}
         hou.setPreference('autoSave', map[hou.getPreference('autoSave')])
         from .hcstatusbar import HCStatusBar
         HCStatusBar().updateAutosave()
 
-    @command("Toggle Update Mode")
-    def toggleUpdateMode(self):
-        # hou.updateModeSetting is a function; the old code stringified the
-        # function object and then called the lookup dict, so this never ran.
-        if hou.updateModeSetting() == hou.updateMode.Manual:
-            mode = hou.updateMode.AutoUpdate
-        else:
-            mode = hou.updateMode.Manual
-        hou.setUpdateMode(mode)
-        hou.ui.setStatusMessage(f"Update mode: {mode.name()}")
-
     def updateMainMenuBar(self):
         hou.ui.updateMainMenuBar()
 
@@ -793,7 +838,7 @@ class HCSession:
     def showShelf(self):
         self.desktop().shelfDock().show(1)
 
-    @command("Toggle Main Menu")
+    @command("Main Menu", state="isVisibleMainMenu")
     def toggleMainMenu(self):
         value = (self.isVisibleMainMenu()+1) % 2
         self.showMainMenu(value)
@@ -801,7 +846,7 @@ class HCSession:
         if tab is not None and tab.type() == 'HCNetworkEditor':
             tab.toggleMenu()
 
-    @command("Toggle All Menus")
+    @command("All Menus", state="isVisibleMenus")
     def toggleMenus(self):
         visible = self.isVisibleMenus()
         show = not visible
@@ -823,7 +868,10 @@ class HCSession:
         for tab in tabs:
             tab.showNetworkControls(not visible)
 
-    @command("Toggle Stowbars")
+    def isStowbarsVisible(self):
+        return not hou.ui.hideAllMinimizedStowbars()
+
+    @command("Stowbars", state="isStowbarsVisible")
     def toggleStowbars(self):
         if hou.ui.hideAllMinimizedStowbars():
             hou.ui.setHideAllMinimizedStowbars(False)
@@ -888,12 +936,13 @@ class HCSession:
         anchor_geometry = pane.qtScreenGeometry() if pane is not None else None
         return self._paneTabTypeDialog("New Floating Pane", self.newFloatingPane, anchor_geometry=anchor_geometry)
 
-    @command("Toggle All Paths")
+    def isAnyPaneShowingTabs(self):
+        return any(pane.isShowingTabs() for pane in self.allPanes())
+
+    # This was listed as "Toggle All Paths", but it has only ever switched the
+    # pane tab strips; the paths are toggleAllNetworkControls above.
+    @command("All Pane Tabs", state="isAnyPaneShowingTabs")
     def toggleAllTabs(self):
-        visible = 0
-        panes = self.allPanes()
-        for pane in panes:
-            if pane.isShowingTabs():
-                visible = 1
-        for pane in panes:
+        visible = self.isAnyPaneShowingTabs()
+        for pane in self.allPanes():
             pane.showTabs(not visible)
diff --git a/python3.13libs/hc/hctab.py b/python3.13libs/hc/hctab.py
index 0fc1e55..b85bc60 100644
--- a/python3.13libs/hc/hctab.py
+++ b/python3.13libs/hc/hctab.py
@@ -148,7 +148,10 @@ class HCTab():
     def showNetworkControls(self, value):
         self.hou_tab.showNetworkControls(value)
 
-    @command("Toggle Path", tabs=("HCPathTab", "HCParameterTab"))
+    def isShowingPath(self):
+        return bool(self.hasNetworkControls() and self.isShowingNetworkControls())
+
+    @command("Path", tabs=("HCPathTab", "HCParameterTab"), state="isShowingPath")
     def toggleNetworkControls(self):
         if self.hasNetworkControls():
             self.showNetworkControls(not self.isShowingNetworkControls())
@@ -168,6 +171,6 @@ class HCTab():
         if self.hasNetworkControls():
             self.showNetworkControls(visible)
 
-    @command("Toggle Pin", tabs=("HCPathTab", "HCParameterTab"))
+    @command("Pin", tabs=("HCPathTab", "HCParameterTab"), state="isPin")
     def togglePin(self):
         self.setPin(not self.isPin())
diff --git a/python3.13libs/hc/hcwidgets.py b/python3.13libs/hc/hcwidgets.py
index 9fc6296..77c44e5 100644
--- a/python3.13libs/hc/hcwidgets.py
+++ b/python3.13libs/hc/hcwidgets.py
@@ -1,8 +1,9 @@
 import hou
+import traceback
 from fuzzyfinder import fuzzyfinder
 from importlib import reload
 from PySide6 import QtWidgets
-from PySide6.QtCore import QEvent, Qt
+from PySide6.QtCore import QEvent, QSize, Qt
 
 class HCWidgets:
 
@@ -21,7 +22,107 @@ class HCWidgets:
             self.setFlat(1)
 
 
+    class ControlRow(QtWidgets.QWidget):
+        """A panel row for a toggle or choice command: label left, control right.
+
+        The control shows the command's current state and can be worked in
+        place -- ticking the checkbox or picking from the dropdown runs the
+        command and leaves the panel open. Clicks on the label fall through to
+        the list, so the row still selects and runs like a plain entry.
+
+        `on_change(bound, fn)` is the dialog's hook: it runs `fn` with error
+        reporting and then refreshes every row, since one command can change
+        another's state (Bars flips every bar).
+        """
+
+        OBJECT_NAME = "hc_panel_row"
+
+        def __init__(self, bound, on_change, parent=None):
+            super().__init__(parent)
+            self.bound = bound
+            self._on_change = on_change
+            self._extra = None
+            # Transparent, so the list's selection highlight shows through.
+            self.setObjectName(self.OBJECT_NAME)
+            self.setStyleSheet(f"#{self.OBJECT_NAME}, #{self.OBJECT_NAME} QLabel"
+                               " { background: transparent; }")
+
+            layout = QtWidgets.QHBoxLayout(self)
+            layout.setContentsMargins(4, 0, 4, 0)
+            self.label = QtWidgets.QLabel(bound.label)
+            layout.addWidget(self.label, 1)
+
+            if bound.kind == "toggle":
+                self.control = QtWidgets.QCheckBox()
+                # clicked, not toggled: refresh() sets the box programmatically
+                # and must not run the command again.
+                self.control.clicked.connect(self._clicked)
+            else:
+                self.control = QtWidgets.QComboBox()
+                for text, value in bound.choices:
+                    self.control.addItem(text, value)
+                self.control.activated.connect(self._activated)
+            # Keyboard focus stays in the filter line; the controls are worked
+            # with the mouse or via Enter on the row.
+            self.control.setFocusPolicy(Qt.NoFocus)
+            layout.addWidget(self.control, 0)
+
+        def _clicked(self, checked):
+            self._on_change(self.bound, self.bound)
+
+        def _activated(self, index):
+            value = self.control.itemData(index)
+            self._on_change(self.bound, lambda: self.bound.set(value))
+
+        def open(self):
+            """What Enter does on this row: pop the dropdown, or run the toggle."""
+            if isinstance(self.control, QtWidgets.QComboBox):
+                self.control.showPopup()
+                return False
+            return True
+
+        def refresh(self):
+            """Re-read the command's state into the control.
+
+            A getter that raises disables the control and shows the error as
+            the tooltip, instead of leaving a checkbox that lies.
+            """
+            try:
+                value = self.bound.state()
+            except Exception as e:
+                traceback.print_exc()
+                self.control.setEnabled(False)
+                self.setToolTip(f"{type(e).__name__}: {e}")
+                return
+            self.control.setEnabled(True)
+            self.setToolTip("")
+            self.control.blockSignals(True)
+            try:
+                if isinstance(self.control, QtWidgets.QCheckBox):
+                    self.control.setChecked(bool(value))
+                else:
+                    self._select(value)
+            finally:
+                self.control.blockSignals(False)
+
+        def _select(self, value):
+            combo = self.control
+            if self._extra is not None:
+                combo.removeItem(self._extra)
+                self._extra = None
+            index = combo.findData(value)
+            if index < 0 and value is not None:
+                # A value outside the declared choices -- a shading mode
+                # picked from Houdini's own menu, say. Show it rather than
+                # a blank box; picking it again is a no-op.
+                combo.addItem(str(value), value)
+                index = self._extra = combo.count() - 1
+            combo.setCurrentIndex(index)
+
+
     class SelectionDialog(QtWidgets.QDialog):
+        ROW_HEIGHT = 24
+
         def __init__(self, window_title, list_dict, anchor_geometry=None):
             super().__init__(hou.qt.mainWindow())
             self.resize(900, 600)
@@ -32,6 +133,7 @@ class HCWidgets:
             self.list = self.List()
             self.list_dict = list_dict
             self.list_items = []
+            self.rows = {}
             for item in list_dict:
                 self.list_items.append(item)
             self.populate(self.list_items)
@@ -113,33 +215,72 @@ class HCWidgets:
             item = self.list.currentItem()
             if item is None:
                 return
-            label = item.text()
+            label = self.list.labelOf(item)
             action = self.list_dict.get(label)
             if action is None:
                 return
+            row = self.rows.get(label)
+            if row is not None and not row.open():
+                # A dropdown row: Enter opened the popup, the pick applies in
+                # place and the panel stays up.
+                return
             self.accept()
+            self._run(label, action)
+
+        def _run(self, label, action):
             # Without this, any exception raised by the command is swallowed by
             # Qt's slot dispatch and the user just sees "nothing happens" -- the
             # single worst thing to debug in this codebase. Surface it instead.
             try:
                 action()
             except Exception as e:
-                import traceback
                 traceback.print_exc()
                 hou.ui.setStatusMessage(
                     f"{label}: {type(e).__name__}: {e}",
                     hou.severityType.Error,
                 )
 
+        def _runInPlace(self, bound, action):
+            """A control was worked: run, then show every row its new state."""
+            self._run(bound.label, action)
+            self.refreshStates()
+            self.input_line.setFocus()
+
+        def refreshStates(self):
+            for row in self.rows.values():
+                row.refresh()
+
         def populate(self, item_list):
-            items = []
-            for key in item_list:
-                items.append(key)
-            self.list.addItems(items)
+            for label in item_list:
+                item = QtWidgets.QListWidgetItem()
+                item.setData(Qt.UserRole, label)
+                entry = self.list_dict.get(label)
+                kind = getattr(entry, "kind", "action")
+                if kind == "action":
+                    # Plain entries stay plain text: a widget per row would
+                    # make the Replace Node picker, with every node type in
+                    # it, take seconds to open.
+                    item.setText(label)
+                    item.setSizeHint(QSize(0, self.ROW_HEIGHT))
+                    self.list.addItem(item)
+                    continue
+                row = HCWidgets.ControlRow(entry, self._runInPlace)
+                item.setSizeHint(QSize(row.sizeHint().width(), self.ROW_HEIGHT))
+                self.list.addItem(item)
+                self.list.setItemWidget(item, row)
+                self.rows[label] = row
+                row.refresh()
 
 
         class List(QtWidgets.QListWidget):
 
+            @staticmethod
+            def labelOf(item):
+                """The entry label. Rows with a control keep their text in
+                UserRole so the item's own text does not paint under the widget."""
+                label = item.data(Qt.UserRole)
+                return label if label is not None else item.text()
+
             def allItems(self):
                 items = []
                 for i in range(self.count()):
@@ -160,13 +301,10 @@ class HCWidgets:
 
             def filter(self, query):
                 all_items = self.allItems()
-                all_item_names = [item.text() for item in all_items]
-                matches = list(fuzzyfinder(query, all_item_names))
+                all_item_names = [self.labelOf(item) for item in all_items]
+                matches = set(fuzzyfinder(query, all_item_names))
                 for item in all_items:
-                    if item.text() in matches:
-                        item.setHidden(0)
-                    else:
-                        item.setHidden(1)
+                    item.setHidden(self.labelOf(item) not in matches)
                 self.setIndex(0)
 
             def selectNext(self):
diff --git a/tools/check.py b/tools/check.py
index f47ea23..f8a7716 100644
--- a/tools/check.py
+++ b/tools/check.py
@@ -156,22 +156,80 @@ def check_commands():
             assert commands, "no commands bound"
             for name, method in commands.items():
                 assert callable(method), f"{name} is not callable"
-            return f"{len(commands)} commands"
+                assert method.kind in hccommands.KINDS, f"{name}: kind {method.kind!r}"
+            kinds = {}
+            for bound in commands.values():
+                kinds[bound.kind] = kinds.get(bound.kind, 0) + 1
+            return f"{len(commands)} commands ({kinds})"
 
         check(f"{label} panel binds", binds)
 
     def scoping_holds():
-        """Toggle Path/Pin live on HCTab but only apply to path-like tabs."""
+        """Path/Pin live on HCTab but only apply to path-like tabs."""
         network = maps.commands(session, pane, blank(HCNetworkEditor))
         parm = maps.commands(session, pane, blank(HCParameterTab))
-        assert "Toggle Pin" in parm, "path tabs lost Toggle Pin"
-        assert "Toggle Pin" not in network, "Toggle Pin leaked into network editors"
+        assert "Pin" in parm, "path tabs lost Pin"
+        assert "Pin" not in network, "Pin leaked into network editors"
         assert "Replace Node" in network, "network editors lost Replace Node"
         assert "Replace Node" not in parm, "Replace Node leaked into parameter tabs"
         return "tab-scoped commands stay scoped"
 
     check("command scoping", scoping_holds)
 
+    def controls_resolve():
+        """Every state getter exists and every choice method takes a value.
+
+        A getter named in a decorator is a string; nothing else checks it
+        until the panel opens and the row's checkbox greys out.
+        """
+        problems = []
+        for cls in (HCSession, HCPane, HCTab, HCPathTab, HCParameterTab,
+                    HCNetworkEditor, HCSceneViewer):
+            problems.extend(hccommands.verify(cls))
+        assert not problems, "; ".join(problems)
+        return "state getters and choice setters resolve"
+
+    check("panel controls resolve", controls_resolve)
+
+    def choices_have_values():
+        """Choice commands list at least two (label, value) pairs and the
+        value that reads back from the getter is one of them, or a toggle's
+        getter is one the panel can coerce."""
+        counted = 0
+        for cls in (HCSession, HCPane, HCTab, HCNetworkEditor, HCSceneViewer):
+            for label, (name, spec) in hccommands.declared(cls).items():
+                if spec.kind != "choice":
+                    continue
+                assert len(spec.choices) >= 2, f"{label}: fewer than two choices"
+                values = [value for _, value in spec.choices]
+                assert len(set(values)) == len(values), f"{label}: duplicate values"
+                counted += 1
+        assert counted, "no choice commands declared"
+        return f"{counted} dropdowns"
+
+    check("dropdown choices", choices_have_values)
+
+    def state_coercion():
+        """Houdini prefs are '0'/'1' strings; bool('0') is True."""
+        assert hccommands.asBool("0") is False
+        assert hccommands.asBool("1") is True
+        assert hccommands.asBool(0) is False
+        assert hccommands.asBool(2) is True
+        assert hccommands.asBool(None) is False
+        return "'0' reads as off"
+
+    check("toggle state coercion", state_coercion)
+
+    def update_mode_reads_back():
+        """The Update Mode dropdown's getter returns one of its own values."""
+        bound = maps.commands(session, pane, None)["Update Mode"]
+        value = bound.state()
+        values = [v for _, v in bound.choices]
+        assert value in values, f"{value!r} not in {values}"
+        return f"currently {value}"
+
+    check("Update Mode state", update_mode_reads_back)
+
     def labels_are_unique():
         seen = {}
         for cls in (HCSession, HCPane, HCTab, HCPathTab, HCParameterTab,