From b154cadf3b95bf2ee2f443eb92024f77f1c0a116 Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Tue, 2 Jun 2026 08:38:01 +0200 Subject: [PATCH] Add IconSlot placeholders + stair xN tread label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a clickable "xN" badge to GizmoStairEdition's edit row, mirroring the array's popup-input UX: click opens a number dialog (no more shift+click-into-modal). Text-only — no 2x2 grid glyph. Structural changes that enable this cleanly: * IconSlot.placeholder=True: slots reserve an X position in the row without auto-creating a gizmo. Subclasses resolve the reserved X via _slot_x_positions()[name] to place their own dynamic gizmos. Drops the brittle "remember to add extra_gap_before" workaround that would silently rot on slot reorders. * Array bug fix: the count badge collided with the "-" icon because the slot manager placed count_minus at the cycle position (X=0.87) where ICON_NUMBER_X also lives. Migrating the badge to a placeholder slot lets the manager allocate the X naturally and the "-" no longer overlaps. ICON_NUMBER_X constant removed. * IntegerInputDialogMixin in parametric_lifecycle.py: extracts the popup-dialog plumbing shared between InputArrayCount and the new InputStairTreads. Subclasses declare an IntProperty + attr_name + props_getter; the mixin owns invoke/execute. _resolve_props helper factors the common obj/props/requires_editing prologue. Tests: BIM_GT_count_label registration; IconSlot placeholder contract (no gizmo_idname required; gizmo_attrs() returns empty); the stair edit-row slot layout reserves the label position between tread_lock and plus at one ICON_ARRAY_GAP each; visibility propagates from props.is_editing. Partly generated with the assistance of an AI coding tool. --- .../bonsai/bim/module/drawing/__init__.py | 1 + .../bonsai/bim/module/drawing/gizmos.py | 70 ++++++++++++++++-- .../bonsai/bim/module/model/__init__.py | 1 + src/bonsai/bonsai/bim/module/model/array.py | 64 +++++++---------- src/bonsai/bonsai/bim/module/model/stair.py | 51 +++++++++++-- src/bonsai/bonsai/bim/parametric_lifecycle.py | 45 ++++++++++++ .../bim/module/model/test_stair_gizmos.py | 72 +++++++++++++++++++ 7 files changed, 253 insertions(+), 51 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/drawing/__init__.py b/src/bonsai/bonsai/bim/module/drawing/__init__.py index 83be6dcd01..d4245cb1db 100644 --- a/src/bonsai/bonsai/bim/module/drawing/__init__.py +++ b/src/bonsai/bonsai/bim/module/drawing/__init__.py @@ -154,6 +154,7 @@ classes = ( gizmos.GizmoArrayParent, gizmos.GizmoArrayAll, gizmos.GizmoArrayLayerIndicator, + gizmos.GizmoCountLabel, gizmos.GizmoMerge, gizmos.GizmoSplit, gizmos.GizmoUnjoin, diff --git a/src/bonsai/bonsai/bim/module/drawing/gizmos.py b/src/bonsai/bonsai/bim/module/drawing/gizmos.py index 2c34ebe759..6cfd321114 100644 --- a/src/bonsai/bonsai/bim/module/drawing/gizmos.py +++ b/src/bonsai/bonsai/bim/module/drawing/gizmos.py @@ -3871,6 +3871,44 @@ class GizmoArrayLayerIndicator(bpy.types.Gizmo): draw_array_layer_children_bbox(context, parent_element, self._layer_index) +class GizmoCountLabel(bpy.types.Gizmo): + """``xN`` text label rendered from 7-segment digit triangles. + + Mirrors a caller-supplied integer (set via :meth:`set_count`) into a + live count badge. No icon glyph; the gizmo is the number alone.""" + + bl_idname = "BIM_GT_count_label" + + __slots__ = ("custom_shape", "_count", "_built_count", "_outlined_batch") + + def setup(self) -> None: + self._count = 0 + self._built_count = -1 + tris = _count_label_tris(self._count, 0.0, 0.0) + self.custom_shape = self.new_custom_shape("TRIS", tris) + self._outlined_batch = batch_for_shader(_get_static_tris_shader(), "TRIS", {"pos": tris}) + self._built_count = 0 + + def set_count(self, count: int) -> None: + self._count = int(count) + + def _ensure_shape(self) -> None: + if self._built_count != self._count: + tris = _count_label_tris(self._count, 0.0, 0.0) + self.custom_shape = self.new_custom_shape("TRIS", tris) + self._outlined_batch = batch_for_shader(_get_static_tris_shader(), "TRIS", {"pos": tris}) + self._built_count = self._count + + def draw(self, context: bpy.types.Context) -> None: + self._ensure_shape() + color = (*self.color_highlight, 1.0) if self.is_highlight else (*self.color, 1.0) + draw_tris_with_outline(self._outlined_batch, self.matrix_basis @ self.matrix_offset, color) + + def draw_select(self, context: bpy.types.Context, select_id: int) -> None: + self._ensure_shape() + self.draw_custom_shape(self.custom_shape, select_id=select_id) + + class GizmoMerge(StaticTrisGizmoMixin, bpy.types.Gizmo): """Two arrows pointing inward toward each other — conveys joining/merging elements.""" @@ -4986,11 +5024,16 @@ class IconSlot: action (trash) from the routine edit controls. - ``operator_props`` — tuple of (key, value) pairs forwarded to ``target_set_operator``'s return value (e.g. ``increment=1`` for a - +/- adjuster, ``property_name="..."`` for a generic toggle).""" + +/- adjuster, ``property_name="..."`` for a generic toggle). + - ``placeholder`` — when ``True``, the slot reserves an X position in + the row but no auto-managed gizmo is created. Subclasses look the X + up via ``_slot_x_positions()[name]`` to place their own dynamically- + built gizmos (e.g. a live count label). ``gizmo_idname`` / ``operator`` + are unused for placeholders.""" name: str - gizmo_idname: str | tuple[str, ...] - operator: str + gizmo_idname: str | tuple[str, ...] = "" + operator: str = "" # Matches DEFAULT_BILLBOARD_SCALE — the scale validate/cancel render at, # so slots that don't override land at the same visual size by default. # Helper icons (+/- count adjusters, lock pairs, delete) override with @@ -5000,10 +5043,13 @@ class IconSlot: variants: tuple[str, ...] = () extra_gap_before: float = 0.0 operator_props: tuple[tuple[str, Any], ...] = () + placeholder: bool = False def __post_init__(self) -> None: # Validate shape at class-definition time so a typo doesn't surface # as a runtime error in the gizmo group's setup() three layers deep. + if self.placeholder: + return if self.variants: if isinstance(self.gizmo_idname, str): pass # prefix form — idname auto-suffixed per variant @@ -5015,10 +5061,11 @@ class IconSlot: f"to be either a string prefix (auto-suffixed as _) or a " f"tuple of {len(self.variants)} explicit idnames, got {self.gizmo_idname!r}" ) - elif not isinstance(self.gizmo_idname, str): + elif not isinstance(self.gizmo_idname, str) or not self.gizmo_idname: raise TypeError( f"IconSlot({self.name!r}): single-icon slot requires gizmo_idname str, " - f"got {self.gizmo_idname!r} (set variants=(...) if you want a multi-variant slot)" + f"got {self.gizmo_idname!r} (set variants=(...) if you want a multi-variant " + f"slot, or placeholder=True for a reserved-position slot)" ) def variant_idnames(self) -> tuple[str, ...]: @@ -5034,7 +5081,10 @@ class IconSlot: def gizmo_attrs(self) -> tuple[str, ...]: """Names of every ``self.*`` attribute this slot writes during setup. - Returns one for a single slot, N for an N-variant slot.""" + Returns one for a single slot, N for an N-variant slot, and an empty + tuple for placeholder slots (which reserve X without an auto-gizmo).""" + if self.placeholder: + return () if self.variants: return tuple(f"{self.name}_{variant}_gizmo" for variant in self.variants) return (f"{self.name}_gizmo",) @@ -5879,8 +5929,12 @@ class BaseParametricGizmoGroup: # Feature-specific edit-row icons. Subclasses declare them via # ``feature_slots``; multi-variant slots create one gizmo per # variant at the same X (e.g. a lock pair, a baseline triplet) and - # the subclass picks which is visible per frame. + # the subclass picks which is visible per frame. Placeholder slots + # only reserve an X position — the subclass creates its own gizmo + # there in ``setup_element_specific_gizmos``. for slot in self.feature_slots: + if slot.placeholder: + continue slot_color = slot.color if slot.color is not None else default_color kwargs = dict(slot.operator_props) for attr, idname in zip(slot.gizmo_attrs(), slot.variant_idnames()): @@ -6155,6 +6209,8 @@ class BaseParametricGizmoGroup: # whenever the gizmo group polls visible. slot_positions = self._slot_x_positions() for slot in self.feature_slots: + if slot.placeholder: + continue slot_x = self.ICON_VALIDATE_X + slot_positions[slot.name] attrs = slot.gizmo_attrs() if slot.variants: diff --git a/src/bonsai/bonsai/bim/module/model/__init__.py b/src/bonsai/bonsai/bim/module/model/__init__.py index 9085e72ae7..ecaa4c9d5f 100644 --- a/src/bonsai/bonsai/bim/module/model/__init__.py +++ b/src/bonsai/bonsai/bim/module/model/__init__.py @@ -210,6 +210,7 @@ classes = ( stair.ToggleStairProperty, stair.AdjustStairTreads, stair.SetStairTreads, + stair.InputStairTreads, stair.CycleStairType, stair.GizmoStairEdition, sverchok_modifier.CreateNewSverchokGraph, diff --git a/src/bonsai/bonsai/bim/module/model/array.py b/src/bonsai/bonsai/bim/module/model/array.py index 68bfb9ac4c..85cc765ea2 100644 --- a/src/bonsai/bonsai/bim/module/model/array.py +++ b/src/bonsai/bonsai/bim/module/model/array.py @@ -34,7 +34,10 @@ from bonsai.bim.module.drawing.gizmos import ( DimensionGizmoConfig, IconSlot, ) -from bonsai.bim.parametric_lifecycle import ParametricEditMixinBase +from bonsai.bim.parametric_lifecycle import ( + IntegerInputDialogMixin, + ParametricEditMixinBase, +) def _wipe_array_children(layers: list) -> None: @@ -1052,11 +1055,10 @@ class RemoveArrayLayerFromEdit(bpy.types.Operator, tool.Ifc.Operator): return {"FINISHED"} -class InputArrayCount(bpy.types.Operator): - """Open a number-input dialog so the user can type a new ``count`` during - an active edit lifecycle. Bound to the world-space count gizmo in the edit - row (between cancel and minus) — for users who'd rather type a value than - repeatedly click +/-.""" +class InputArrayCount(IntegerInputDialogMixin, bpy.types.Operator): + """Popup-dialog entry point for typing a new draft ``count`` during an + active array edit lifecycle. Bound to the world-space count gizmo in + the edit row.""" bl_idname = "bim.input_array_count" bl_label = "Set Array Count" @@ -1064,26 +1066,9 @@ class InputArrayCount(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} count: bpy.props.IntProperty(name="Count", default=1, min=1) - - def invoke(self, context, event): - obj = context.active_object - if not obj: - return {"CANCELLED"} - props = tool.Model.get_array_props(obj) - if not props.is_editing: - return {"CANCELLED"} - self.count = max(1, props.count) - return context.window_manager.invoke_props_dialog(self) - - def execute(self, context): - obj = context.active_object - if not obj: - return {"CANCELLED"} - props = tool.Model.get_array_props(obj) - if not props.is_editing: - return {"CANCELLED"} - props.count = max(1, self.count) - return {"FINISHED"} + attr_name = "count" + props_getter = staticmethod(tool.Model.get_array_props) + requires_editing = True class AdjustArrayCount(bpy.types.Operator): @@ -1138,18 +1123,18 @@ class GizmoArrayEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): hide_pen_button = True # Editing row layout: validate | cancel | xN | - | + | method | trash. - # The count-label (xN) gizmo sits at the cycle slot (X = 0.87), positioned - # manually in ``_refresh_element_specific`` because it's a label that - # replaces the cycle icon rather than a row slot. The +/-/method/trash - # icons live in ``feature_slots`` below — the base class assigns X - # positions from tuple order; the trash carries ``extra_gap_before`` to - # visually separate the destructive action from the routine controls. - ICON_NUMBER_X = 0.87 + # The ``count_label`` placeholder reserves the position for the + # dynamically-built ``xN`` gizmo; the +/-/method/trash icons live in + # ``feature_slots`` below and the base class assigns X positions from + # tuple order. The trash carries ``extra_gap_before`` to visually + # separate the destructive action from the routine controls. + # Render scale for the +/- and method-toggle icons — ~70% of the standard # 0.5 used for validate/cancel. Makes the helpers look secondary. ICON_HELPER_SCALE = 0.35 feature_slots: ClassVar[tuple[IconSlot, ...]] = ( + IconSlot(name="count_label", placeholder=True), IconSlot( name="count_minus", gizmo_idname="VIEW3D_GT_minus", @@ -1290,12 +1275,11 @@ class GizmoArrayEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): ``feature_slots`` and are auto-created by the base class.""" default_color, highlight_color = self.get_decoration_colors() # World-space count display for the edit row. Click opens a numeric - # input dialog (``bim.input_array_count``) so the user can type a - # value directly instead of clicking +/- repeatedly. Renders the same - # ``xN`` glyph as the idle-state per-layer icons for visual consistency. - # Sits at the cycle slot — it's a label that replaces the cycle icon, - # not a row slot, so it's positioned manually below rather than via - # ``feature_slots``. + # input dialog so the user can type a value directly instead of + # clicking +/- repeatedly. Renders the same ``xN`` glyph as the + # idle-state per-layer icons for visual consistency. Position is + # reserved by the ``count_label`` placeholder slot in + # ``feature_slots``; the matrix is set per-frame below. self.count_label_gizmo = self.gizmos.new("BIM_GT_array_layer_indicator") self.count_label_gizmo.use_draw_scale = False self.count_label_gizmo.color = default_color @@ -1431,7 +1415,7 @@ class GizmoArrayEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): self.count_label_gizmo.set_count(int(props.count)) world_pos = mw @ Vector( ( - self.ICON_VALIDATE_X + self.ICON_NUMBER_X, + self.ICON_VALIDATE_X + self._slot_x_positions()["count_label"], icon_y, icon_z, ) diff --git a/src/bonsai/bonsai/bim/module/model/stair.py b/src/bonsai/bonsai/bim/module/model/stair.py index f1588e06ea..3785da87fc 100644 --- a/src/bonsai/bonsai/bim/module/model/stair.py +++ b/src/bonsai/bonsai/bim/module/model/stair.py @@ -37,6 +37,7 @@ from bonsai.bim.module.drawing.gizmos import ( DimensionGizmoConfig, IconSlot, ) +from bonsai.bim.parametric_lifecycle import IntegerInputDialogMixin from bonsai.tool.numeric_input import ( IntegerInputState, run_integer_input_modal, @@ -383,6 +384,20 @@ class AdjustStairTreads(bpy.types.Operator): return {"FINISHED"} +class InputStairTreads(IntegerInputDialogMixin, bpy.types.Operator): + """Popup-dialog entry point for typing a new ``number_of_treads`` value. + Bound to the world-space ``xN`` count label in the stair edit row.""" + + bl_idname = "bim.input_stair_treads" + bl_label = "Set Number of Treads" + bl_description = "Type the number of treads for this stair" + bl_options = {"REGISTER", "UNDO"} + + number_of_treads: IntProperty(name="Number of Treads", default=1, min=1) + attr_name = "number_of_treads" + props_getter = staticmethod(tool.Model.get_stair_props) + + class SetStairTreads(bpy.types.Operator): """Set the number of treads to a specific value.""" @@ -468,11 +483,12 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): bl_options = {"3D", "PERSISTENT"} # === Stair-Specific Icon Layout === - # Row order: [Validate] [Cancel] [Cycle] [TreadLock] [Plus] [Minus] + # Row order: [Validate] [Cancel] [Cycle] [TreadLock] [xN] [Plus] [Minus] # The base class assigns X positions from ``feature_slots`` tuple order — # adding an icon is a one-line append, no hardcoded X constant. ICON_PLUS_MINUS_SCALE = 0.24 # Scale for plus/minus icons (slightly larger) ICON_CYCLE_SCALE = 0.3 # Scale for cycle type icon + ICON_COUNT_LABEL_SCALE = 0.36 # Scale for the xN tread-count label ICON_Z_OFFSET = 0.5 # Z offset above geometry for editing icons feature_slots: ClassVar[tuple[IconSlot, ...]] = ( @@ -484,6 +500,7 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): color=(1.0, 1.0, 1.0), operator_props=(("property_name", "custom_tread_lock"),), ), + IconSlot(name="tread_count_label", placeholder=True), IconSlot( name="plus", gizmo_idname="VIEW3D_GT_plus", @@ -618,16 +635,28 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): return tool.Blender.Modifier.is_stair(element) def setup_element_specific_gizmos(self, context: bpy.types.Context) -> None: - """Create the total-length lock as an open/closed pair. Click toggles + """Create the total-length lock as an open/closed pair plus the + ``xN`` tread-count label. Lock click toggles ``props.total_length_lock``; the per-frame update hook picks which member is visible. Anchored to the stair's far X end (not the edit row) so it's positioned by ``_update_lock_gizmo_position`` rather - than the toolbar slot system.""" + than the toolbar slot system. + + The count label binds to ``bim.input_stair_treads`` (popup dialog) + for click-to-type input and sits at the X reserved by the + ``tread_count_label`` placeholder slot in ``feature_slots``.""" self.total_length_lock_open_gizmo, self.total_length_lock_closed_gizmo = self.create_icon_gizmo_lock_pair( "bim.toggle_stair_property", self.COLOR_BLUE, property_name="total_length_lock", ) + default_color, highlight_color = self.get_decoration_colors() + self.tread_count_label_gizmo = self.gizmos.new("BIM_GT_count_label") + self.tread_count_label_gizmo.use_draw_scale = False + self.tread_count_label_gizmo.color = default_color + self.tread_count_label_gizmo.color_highlight = highlight_color + self.tread_count_label_gizmo.alpha = 0.8 + self.tread_count_label_gizmo.target_set_operator("bim.input_stair_treads") def _refresh_element_specific( self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties" # noqa: ARG002 @@ -667,12 +696,15 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): self.tread_lock_closed_gizmo.hide = not props.custom_tread_lock def update_tread_count_gizmos(self, props: "BIMStairProperties") -> None: - """Update visibility of +/- tread count gizmos. Positioning is handled in _update_editing_icon_positions.""" + """Update visibility of the +/- tread count gizmos and the ``xN`` + label. Positioning is handled in ``_update_editing_icon_positions``.""" if not hasattr(self, "plus_gizmo") or not hasattr(self, "minus_gizmo"): return self.update_gizmo_visibility(self.plus_gizmo, props.is_editing) # Minus has additional condition: number_of_treads > 1 self.update_gizmo_visibility(self.minus_gizmo, props.is_editing and props.number_of_treads > 1) + if hasattr(self, "tread_count_label_gizmo"): + self.update_gizmo_visibility(self.tread_count_label_gizmo, props.is_editing) def _update_dimension_gizmo_positions( self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties" # noqa: ARG002 @@ -800,3 +832,14 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): self.set_icon_gizmo_position( "minus_gizmo", mw, slot_x["minus"], y_pos, icon_z, billboard_rot, scale=self.ICON_PLUS_MINUS_SCALE ) + if hasattr(self, "tread_count_label_gizmo"): + self.tread_count_label_gizmo.set_count(int(props.number_of_treads)) + self.set_icon_gizmo_position( + "tread_count_label_gizmo", + mw, + slot_x["tread_count_label"], + y_pos, + icon_z, + billboard_rot, + scale=self.ICON_COUNT_LABEL_SCALE, + ) diff --git a/src/bonsai/bonsai/bim/parametric_lifecycle.py b/src/bonsai/bonsai/bim/parametric_lifecycle.py index b247e3e4bc..59e98ac61a 100644 --- a/src/bonsai/bonsai/bim/parametric_lifecycle.py +++ b/src/bonsai/bonsai/bim/parametric_lifecycle.py @@ -528,6 +528,51 @@ class PickTypeMixin(TypeAccessorBase): return {"FINISHED"} +class IntegerInputDialogMixin: + """Operator mixin that mirrors a per-feature ``IntProperty`` on the + operator into a draft attribute on the active object's parametric props, + via Blender's ``invoke_props_dialog`` popup. + + Subclasses declare: + + - ``attr_name`` — name of the IntProperty on the subclass AND of the + attribute on the resolved props (same name on both sides). + - ``props_getter`` — ``staticmethod(tool.Model.get__props)``. + - ``requires_editing`` — True iff the operator must no-op outside an + active edit lifecycle. Default False. + - ``value_min`` — minimum value to clamp to. Default 1.""" + + attr_name: ClassVar[str] = "" + props_getter: ClassVar[Callable[[bpy.types.Object], bpy.types.PropertyGroup]] + requires_editing: ClassVar[bool] = False + value_min: ClassVar[int] = 1 + + def _resolve_props(self, context: bpy.types.Context) -> bpy.types.PropertyGroup | None: + """Return the active object's parametric props if the operator is + allowed to fire, ``None`` otherwise (caller bails with ``CANCELLED``).""" + obj = context.active_object + if not obj: + return None + props = self.props_getter(obj) + if self.requires_editing and not props.is_editing: + return None + return props + + def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]: # noqa: ARG002 + props = self._resolve_props(context) + if props is None: + return {"CANCELLED"} + setattr(self, self.attr_name, max(self.value_min, getattr(props, self.attr_name))) + return context.window_manager.invoke_props_dialog(self) + + def execute(self, context: bpy.types.Context) -> set[str]: + props = self._resolve_props(context) + if props is None: + return {"CANCELLED"} + setattr(props, self.attr_name, max(self.value_min, getattr(self, self.attr_name))) + return {"FINISHED"} + + # --- Undo-resync registry ---------------------------------------------------- # # Per-type regenerators called from ``resync_parametric_drafts_after_undo`` diff --git a/src/bonsai/test/bim/module/model/test_stair_gizmos.py b/src/bonsai/test/bim/module/model/test_stair_gizmos.py index 060fdbcbe5..e406bc8cb8 100644 --- a/src/bonsai/test/bim/module/model/test_stair_gizmos.py +++ b/src/bonsai/test/bim/module/model/test_stair_gizmos.py @@ -133,3 +133,75 @@ def test_set_icon_gizmo_position_does_not_apply_object_rotation(): for row_a, row_b in zip(stub.matrix_basis, expected): for va, vb in zip(row_a, row_b): assert abs(va - vb) < 1e-6 + + +def test_icon_slot_placeholder_skips_validation_and_returns_no_attrs(): + """Placeholder slots reserve an X position without an auto-created gizmo: + construction must not require ``gizmo_idname`` / ``operator``, and + ``gizmo_attrs()`` must return an empty tuple so the base class's + setup/positioning loops naturally skip the slot.""" + from bonsai.bim.module.drawing.gizmos import IconSlot + + slot = IconSlot(name="my_label", placeholder=True) + assert slot.placeholder is True + assert slot.gizmo_attrs() == () + + with pytest.raises(TypeError, match="gizmo_idname"): + IconSlot(name="broken") + + +def test_count_label_gizmo_is_registered(): + """The shared text-only ``xN`` gizmo must register so the stair group's + ``gizmos.new("BIM_GT_count_label")`` resolves.""" + from bonsai.bim.module.drawing.gizmos import GizmoCountLabel + + assert GizmoCountLabel.bl_idname == "BIM_GT_count_label" + assert bpy.types.Gizmo.bl_rna_get_subclass_py("BIM_GT_count_label") is GizmoCountLabel + + +def test_stair_edit_row_reserves_label_slot_between_tread_lock_and_plus(): + """The ``tread_count_label`` placeholder slot must sit one + ``ICON_ARRAY_GAP`` past the tread-lock and one gap before the plus + icon, so the layout naturally allocates the count label's X without + any subclass-side gap math.""" + from bonsai.bim.module.drawing.gizmos import BaseParametricGizmoGroup + from bonsai.bim.module.model.stair import GizmoStairEdition + + slot_x = GizmoStairEdition._slot_x_positions() + gap = BaseParametricGizmoGroup.ICON_ARRAY_GAP + + assert "tread_count_label" in slot_x + assert slot_x["tread_count_label"] - slot_x["tread_lock"] == pytest.approx(gap) + assert slot_x["plus"] - slot_x["tread_count_label"] == pytest.approx(gap) + assert slot_x["minus"] - slot_x["plus"] == pytest.approx(gap) + + +def test_update_tread_count_gizmos_toggles_label_with_editing(): + """``update_tread_count_gizmos`` must propagate ``props.is_editing`` + to the label's hide state so the badge appears only inside edit mode.""" + from bonsai.bim.module.drawing.gizmos import BaseParametricGizmoGroup + from bonsai.bim.module.model.stair import GizmoStairEdition + + class _GizmoStub: + def __init__(self): + self.hide = False + + plus_gz = _GizmoStub() + minus_gz = _GizmoStub() + label_gz = _GizmoStub() + + fake_self = types.SimpleNamespace( + plus_gizmo=plus_gz, + minus_gizmo=minus_gz, + tread_count_label_gizmo=label_gz, + update_gizmo_visibility=lambda g, v: BaseParametricGizmoGroup.update_gizmo_visibility(fake_self, g, v), + is_gizmo_hidden_by_modal=lambda g: False, + ) + + props_editing = types.SimpleNamespace(is_editing=True, number_of_treads=5) + GizmoStairEdition.update_tread_count_gizmos(fake_self, props_editing) + assert label_gz.hide is False + + props_idle = types.SimpleNamespace(is_editing=False, number_of_treads=5) + GizmoStairEdition.update_tread_count_gizmos(fake_self, props_idle) + assert label_gz.hide is True