Source code for simvx.editor.panels.inspector

"""Inspector Panel -- Property editor for the selected node.

Reads Property descriptors from the selected node's class hierarchy and
creates appropriate editor widgets (Slider, SpinBox, CheckBox, DropDown,
TextEdit, ColourPicker) for each property.  All property changes go
through the undo system via PropertyCommand.

Layout:
    +----------------------------------+
    | [Node Type Icon]  TypeName       |
    | Name: [ editable name field    ] |
    +----------------------------------+
    | v Node                           |
    |   Visible  [x]                   |
    +----------------------------------+
    | v Transform                      |
    |   Position X [  ] Y [  ] Z [  ] |
    |   Rotation X [  ] Y [  ] Z [  ] |
    |   Scale    X [  ] Y [  ] Z [  ] |
    +----------------------------------+
    | v Custom Propertys                |
    |   speed   [=====|-------] 5.0    |
    |   mode    [ walk       v]        |
    +----------------------------------+
    | v Material                       |
    |   Colour   [####]                 |
    |   Metallic  [===|-----] 0.0      |
    |   Roughness [======|--] 0.5      |
    +----------------------------------+
"""


from __future__ import annotations

import math
from typing import Any

from collections import OrderedDict

from simvx.core import (
    CheckBox,
    ColourPicker,
    Control,
    DropDown,
    HBoxContainer,
    Label,
    Node,
    Node2D,
    Node3D,
    Property,
    PropertyCommand,
    Quat,
    Signal,
    Slider,
    SpinBox,
    TextEdit,
    Vec2,
    Vec3,
    WorldEnvironment,
)
from simvx.core.ui.core import ThemeColour
from simvx.core.ui.theme import em, get_theme

from ..theme import TYPE_COLOUR
from .inspector_script import ScriptSectionMixin
from .section_widgets import (
    FONT_SIZE as _font_size_fn,
    INDENT,
    LABEL_WIDTH,
    PADDING,
    ROW_HEIGHT,
    PropertyRow,
    ResourcePicker,
    Section,
    SectionHeader,
    VectorRow,
)


# ============================================================================
# Backward-compat aliases for external consumers
# ============================================================================

_SectionHeader = SectionHeader
_Section = Section
_PropertyRow = PropertyRow
_VectorRow = VectorRow
_ResourcePicker = ResourcePicker


# ============================================================================
# Layout helpers
# ============================================================================

def _row_h() -> float:
    return ROW_HEIGHT()

def _section_h() -> float:
    return em(2.36)

def _label_w() -> float:
    return LABEL_WIDTH()

def _font_size() -> float:
    return _font_size_fn()

def _padding() -> float:
    return PADDING()

def _indent() -> float:
    return INDENT()


# ============================================================================
# InspectorPanel -- Main inspector control
# ============================================================================

[docs] class InspectorPanel(ScriptSectionMixin, Control): """Property editor panel for the currently selected node. Subscribes to ``state.selection_changed`` and rebuilds its contents whenever the selection changes. Each Property on the selected node is mapped to an appropriate editor widget. Property edits are pushed to the undo stack as ``PropertyCommand`` instances. Args: editor_state: The central EditorState instance. """ # Emitted as (node, prop_name, old_value, new_value) whenever an edit occurs property_changed = Signal() def __init__(self, editor_state=None, **kwargs): super().__init__(**kwargs) self.state = editor_state self.bg_colour = get_theme().panel_bg self.size = Vec2(300, 600) # Title header widgets self._type_label: Label | None = None self._name_edit: TextEdit | None = None # Section tracking self._sections: list[Section] = [] self._property_widgets: dict[str, Control] = {} self._scroll_offset = 0.0 # The node we are currently inspecting (cached for refresh) self._inspected_node: Node | None = None
[docs] def ready(self): """Connect to editor state signals.""" if self.state is not None: self.state.selection_changed.connect(self._rebuild_inspector) self.state.undo_stack.changed.connect(self._refresh_values)
[docs] def inspect(self, node: Node | None): """Public API: display properties for the given node (or clear if None).""" self._inspected_node = node self._rebuild()
# ==================================================================== # Rebuild -- called when selection changes # ==================================================================== def _rebuild_inspector(self): """Called by editor state when selection changes.""" if self.state is not None: self._inspected_node = self.state.selection.primary self._rebuild() def _rebuild(self): """Tear down all children and rebuild for the currently inspected node.""" # Clear existing children for child in list(self.children): self.remove_child(child) self._sections.clear() self._property_widgets.clear() self._type_label = None self._name_edit = None self._scroll_offset = 0.0 self._layout_dirty_ip = True node = self._inspected_node if not node: return # Build the header and property sections self._add_header(node) # Script section (from ScriptSectionMixin) self._add_script_section(node) # Node properties (name, visibility) self._add_node_section(node) # Transform section for spatial nodes if isinstance(node, Node3D): self._add_transform3d_section(node) elif isinstance(node, Node2D): self._add_transform2d_section(node) # Custom settings from Property descriptors self._add_settings_section(node) # Registry-based sections (mesh, material, audio, collision, camera, particles, etc.) self._add_registered_sections(node) # ==================================================================== # Refresh -- called after undo/redo to sync widget values # ==================================================================== def _refresh_values(self): """Sync widget values with the inspected node after undo/redo.""" node = self._inspected_node if node is None or node is not self.state.selection.primary: # Selection may have changed out from under us self._rebuild_inspector() return # Refresh name edit if self._name_edit is not None: if self._name_edit.text != node.name: self._name_edit.text = node.name self._name_edit.cursor_pos = len(node.name) # ==================================================================== # Header -- type name and editable node name # ==================================================================== def _add_header(self, node: Node): """Add the header showing node type and editable name.""" # Type label type_label = Label(type(node).__name__) type_label.text_colour = TYPE_COLOUR type_label.font_size = 14.0 type_label.size = Vec2(self.size.x, 22) self.add_child(type_label) self._type_label = type_label # Name edit row row = HBoxContainer() row.size = Vec2(self.size.x, _row_h()) row.separation = 4.0 name_label = Label("Name") name_label.text_colour = get_theme().text_label name_label.font_size = _font_size() name_label.size = Vec2(_label_w(), _row_h()) row.add_child(name_label) name_edit = TextEdit(text=node.name) name_edit.font_size = _font_size() name_edit.size = Vec2(self.size.x - _label_w() - _padding() * 2, _row_h()) name_edit.text_submitted.connect( lambda text: self._on_name_changed(node, text) ) row.add_child(name_edit) self._name_edit = name_edit self.add_child(row) def _on_name_changed(self, node: Node, new_name: str): """Handle node name edit with undo support.""" if not new_name or new_name == node.name: return old_name = node.name self._on_property_changed(node, "name", old_name, new_name) # ==================================================================== # Node section -- visibility # ==================================================================== def _add_node_section(self, node: Node): """Add section for base Node properties (visibility).""" rows: list[Control] = [] # Visible toggle cb = CheckBox("", checked=node.visible) cb.toggled.connect( lambda checked: self._on_property_changed( node, "visible", not checked, checked) ) row = PropertyRow("Visible", cb) rows.append(row) self._property_widgets["visible"] = cb self._add_section("Node", rows) # ==================================================================== # Transform sections # ==================================================================== def _add_transform3d_section(self, node: Node3D): """Add position, rotation (euler degrees), and scale for 3D nodes.""" rows: list[Control] = [] # Position pos = node.position pos_vals = ( pos.x if hasattr(pos, 'x') else float(pos[0]), pos.y if hasattr(pos, 'y') else float(pos[1]), pos.z if hasattr(pos, 'z') else float(pos[2]), ) pos_row = VectorRow("Position", 3, pos_vals, step=0.1) for i, spin in enumerate(pos_row._spinboxes): axis = i spin.value_changed.connect( lambda val, ax=axis: self._on_transform3d_pos(node, ax, val) ) rows.append(pos_row) self._property_widgets["position"] = pos_row # Rotation (displayed as euler degrees, stored as Quat in radians) euler_rad = node.rotation.euler_angles() rot_vals = ( math.degrees(euler_rad.x), math.degrees(euler_rad.y), math.degrees(euler_rad.z), ) rot_row = VectorRow("Rotation", 3, rot_vals, step=1.0, min_val=-360, max_val=360) for i, spin in enumerate(rot_row._spinboxes): axis = i spin.value_changed.connect( lambda val, ax=axis: self._on_transform3d_rot(node, ax, val) ) rows.append(rot_row) self._property_widgets["rotation"] = rot_row # Scale scl = node.scale scl_vals = ( scl.x if hasattr(scl, 'x') else float(scl[0]), scl.y if hasattr(scl, 'y') else float(scl[1]), scl.z if hasattr(scl, 'z') else float(scl[2]), ) scl_row = VectorRow("Scale", 3, scl_vals, step=0.1, min_val=-100, max_val=100) for i, spin in enumerate(scl_row._spinboxes): axis = i spin.value_changed.connect( lambda val, ax=axis: self._on_transform3d_scl(node, ax, val) ) rows.append(scl_row) self._property_widgets["scale"] = scl_row self._add_section("Transform", rows) def _on_transform3d_pos(self, node: Node3D, axis: int, value: float): """Handle position component change with undo.""" old_pos = Vec3(node.position) new_vals = [old_pos.x, old_pos.y, old_pos.z] new_vals[axis] = value new_pos = Vec3(new_vals[0], new_vals[1], new_vals[2]) self._on_property_changed(node, "position", old_pos, new_pos) def _on_transform3d_rot(self, node: Node3D, axis: int, value: float): """Handle rotation euler component change with undo.""" old_rot = Quat(node.rotation) old_euler_rad = node.rotation.euler_angles() new_euler_vals = [math.degrees(old_euler_rad.x), math.degrees(old_euler_rad.y), math.degrees(old_euler_rad.z)] new_euler_vals[axis] = value new_rot = Quat.from_euler( math.radians(new_euler_vals[0]), math.radians(new_euler_vals[1]), math.radians(new_euler_vals[2])) self._on_property_changed(node, "rotation", old_rot, new_rot) def _on_transform3d_scl(self, node: Node3D, axis: int, value: float): """Handle scale component change with undo.""" old_scl = Vec3(node.scale) new_vals = [old_scl.x, old_scl.y, old_scl.z] new_vals[axis] = value new_scl = Vec3(new_vals[0], new_vals[1], new_vals[2]) self._on_property_changed(node, "scale", old_scl, new_scl) def _add_transform2d_section(self, node: Node2D): """Add position, rotation (degrees), and scale for 2D nodes.""" rows: list[Control] = [] # Position (Vec2) pos = node.position pos_vals = ( pos.x if hasattr(pos, 'x') else float(pos[0]), pos.y if hasattr(pos, 'y') else float(pos[1]), ) pos_row = VectorRow("Position", 2, pos_vals, step=0.1) for i, spin in enumerate(pos_row._spinboxes): axis = i spin.value_changed.connect( lambda val, ax=axis: self._on_transform2d_pos(node, ax, val) ) rows.append(pos_row) self._property_widgets["position"] = pos_row # Rotation (display degrees, store radians) rot_spin = SpinBox(min_val=-360, max_val=360, value=math.degrees(node.rotation), step=1.0) rot_spin.font_size = 11.0 rot_spin.value_changed.connect( lambda val: self._on_property_changed( node, "rotation", node.rotation, math.radians(val)) ) rot_row = PropertyRow("Rotation", rot_spin) rows.append(rot_row) self._property_widgets["rotation"] = rot_spin # Scale (Vec2) scl = node.scale scl_vals = ( scl.x if hasattr(scl, 'x') else float(scl[0]), scl.y if hasattr(scl, 'y') else float(scl[1]), ) scl_row = VectorRow("Scale", 2, scl_vals, step=0.1, min_val=-100, max_val=100) for i, spin in enumerate(scl_row._spinboxes): axis = i spin.value_changed.connect( lambda val, ax=axis: self._on_transform2d_scl(node, ax, val) ) rows.append(scl_row) self._property_widgets["scale"] = scl_row self._add_section("Transform", rows) def _on_transform2d_pos(self, node: Node2D, axis: int, value: float): """Handle 2D position component change with undo.""" old_pos = Vec2(node.position) new_pos = Vec2(value, old_pos.y) if axis == 0 else Vec2(old_pos.x, value) self._on_property_changed(node, "position", old_pos, new_pos) def _on_transform2d_scl(self, node: Node2D, axis: int, value: float): """Handle 2D scale component change with undo.""" old_scl = Vec2(node.scale) new_scl = Vec2(value, old_scl.y) if axis == 0 else Vec2(old_scl.x, value) self._on_property_changed(node, "scale", old_scl, new_scl) # ==================================================================== # Custom properties section # ==================================================================== def _add_settings_section(self, node: Node): """Discover all Property and ThemeColour descriptors and create widgets for each. Groups properties by their ``Property.group`` attribute into separate collapsible sections. Properties with no group go to "Properties". For ``WorldEnvironment`` nodes all groups are merged into a single "Post Processing" section with ``pp_`` prefixed widget keys. """ settings = self._collect_properties(node) if not settings: return is_world_env = isinstance(node, WorldEnvironment) # Bucket settings by group name (preserving insertion order) grouped: OrderedDict[str, list[tuple[str, Property | ThemeColour]]] = OrderedDict() for name, setting in settings.items(): if name in ("visible", "gizmo_colour"): continue group = getattr(setting, "group", "") or "" if is_world_env: group = "Post Processing" elif not group: group = "Properties" grouped.setdefault(group, []).append((name, setting)) for section_name, entries in grouped.items(): rows: list[Control] = [] for name, setting in entries: value = getattr(node, name) if isinstance(setting, ThemeColour): widget = self._create_colour_picker(node, name, value) else: widget = self._create_widget_for_property(node, name, setting, value) if widget is not None: widget_key = f"pp_{name}" if is_world_env else name row = PropertyRow(name, widget) rows.append(row) self._property_widgets[widget_key] = widget if rows: self._add_section(section_name, rows) def _create_colour_picker(self, node: Node, name: str, value) -> Control | None: """Create a ColourPicker for a ThemeColour descriptor.""" if not (isinstance(value, tuple | list) and len(value) in (3, 4)): return None picker = ColourPicker() picker.size = Vec2(200, 180) if len(value) == 3: picker.colour = (value[0], value[1], value[2], 1.0) else: picker.colour = tuple(value[:4]) picker.colour_changed.connect( lambda colour, n=name: self._on_colour_changed(node, n, colour) ) return picker def _collect_properties(self, node: Node) -> dict[str, Property | ThemeColour]: """Walk the MRO to collect all Property and ThemeColour descriptors for the node.""" settings: dict[str, Property | ThemeColour] = {} # Walk MRO in reverse so subclass overrides appear last for cls in reversed(type(node).__mro__): for attr_name, attr_val in cls.__dict__.items(): if isinstance(attr_val, Property | ThemeColour): settings[attr_name] = attr_val return settings def _create_widget_for_property( self, node: Node, name: str, setting: Property, value: Any ) -> Control | None: """Map a Property's type and metadata to the appropriate widget. Returns: A configured widget Control, or None if unsupported. """ # --- Enum string -> DropDown --- if setting.enum is not None: idx = 0 if value in setting.enum: idx = setting.enum.index(value) dd = DropDown(items=list(setting.enum), selected=idx) dd.font_size = 11.0 dd.item_selected.connect( lambda new_idx, n=name, s=setting: self._on_enum_changed( node, n, s, new_idx) ) return dd # --- Bool -> CheckBox --- if isinstance(value, bool): cb = CheckBox("", checked=value) cb.toggled.connect( lambda checked, n=name: self._on_property_changed( node, n, not checked, checked) ) return cb # --- Colour tuple (3 or 4 floats in 0-1) -> ColourPicker --- if self._is_colour_value(name, value): picker = ColourPicker() picker.size = Vec2(200, 180) # Normalize to RGBA if len(value) == 3: picker.colour = (value[0], value[1], value[2], 1.0) else: picker.colour = tuple(value[:4]) picker.colour_changed.connect( lambda colour, n=name: self._on_colour_changed(node, n, colour) ) return picker # --- Vec3 -> 3x SpinBox --- if isinstance(value, Vec3): row = VectorRow("", 3, (value.x, value.y, value.z), step=0.1) for i, spin in enumerate(row._spinboxes): axis = i spin.value_changed.connect( lambda val, n=name, ax=axis: self._on_vec3_changed( node, n, ax, val) ) return row # --- Vec2 -> 2x SpinBox --- if isinstance(value, Vec2): row = VectorRow("", 2, (value.x, value.y), step=0.1) for i, spin in enumerate(row._spinboxes): axis = i spin.value_changed.connect( lambda val, n=name, ax=axis: self._on_vec2_changed( node, n, ax, val) ) return row # --- Float with range -> Slider --- if isinstance(value, float) and setting.range is not None: lo, hi = setting.range slider = Slider(min_val=lo, max_val=hi, value=value) slider.step = _guess_step(lo, hi) slider.value_changed.connect( lambda val, n=name: self._on_property_changed( node, n, getattr(node, n), val) ) return slider # --- Float / int without range -> SpinBox --- if isinstance(value, int | float): lo = setting.range[0] if setting.range else -10000 hi = setting.range[1] if setting.range else 10000 step = 1.0 if isinstance(value, int) else 0.1 spin = SpinBox(min_val=lo, max_val=hi, value=float(value), step=step) spin.font_size = 11.0 spin.value_changed.connect( lambda val, n=name, is_int=isinstance(value, int): # noqa: B008 self._on_property_changed( node, n, getattr(node, n), int(val) if is_int else val) ) return spin # --- String -> TextEdit --- if isinstance(value, str): edit = TextEdit(text=value, placeholder=setting.hint or name) edit.font_size = 11.0 edit.text_submitted.connect( lambda text, n=name: self._on_property_changed( node, n, getattr(node, n), text) ) return edit # --- Tuple that looks like a vector --- if isinstance(value, tuple) and len(value) in (2, 3) and all( isinstance(v, int | float) for v in value ): if not self._is_colour_value(name, value): comps = len(value) row = VectorRow("", comps, tuple(float(v) for v in value), step=0.1) for i, spin in enumerate(row._spinboxes): axis = i spin.value_changed.connect( lambda val, n=name, ax=axis, nc=comps: self._on_tuple_changed(node, n, ax, val, nc) ) return row return None # ==================================================================== # Material property change handlers (used by InspectorContext) # ==================================================================== def _on_material_colour_changed(self, node, new_colour: tuple): """Handle material colour change with undo.""" mat = node.material if mat is None: return old_colour = mat.colour if old_colour == new_colour: return if self.state is not None: cmd = PropertyCommand( mat, "colour", old_colour, new_colour, description=f"Change {node.name} material colour", ) self.state.undo_stack.push(cmd) self.state.modified = True else: mat.colour = new_colour self.property_changed.emit(node, "material.colour", old_colour, new_colour) def _on_material_prop_changed(self, node, prop: str, value: Any): """Handle material scalar property change with undo.""" mat = node.material if mat is None: return old_val = getattr(mat, prop) if old_val == value: return if self.state is not None: cmd = PropertyCommand( mat, prop, old_val, value, description=f"Change {node.name} material.{prop}", ) self.state.undo_stack.push(cmd) self.state.modified = True else: setattr(mat, prop, value) self.property_changed.emit(node, f"material.{prop}", old_val, value) def _on_material_texture_changed(self, node, attr: str, path: str | None): """Handle material texture URI change with undo.""" mat = node.material if mat is None: return old_val = getattr(mat, attr) if old_val == path: return if self.state is not None: cmd = PropertyCommand( mat, attr, old_val, path, description=f"Change {node.name} material.{attr}", ) self.state.undo_stack.push(cmd) self.state.modified = True else: setattr(mat, attr, path) self.property_changed.emit(node, f"material.{attr}", old_val, path) # ==================================================================== # Generic property change handlers # ==================================================================== def _on_property_changed(self, node: Node, prop: str, old_val: Any, new_val: Any): """Push a PropertyCommand for a simple scalar property change.""" if old_val == new_val: return if self.state is not None: cmd = PropertyCommand( node, prop, old_val, new_val, description=f"Set {node.name}.{prop}", ) self.state.undo_stack.push(cmd) self.state.modified = True else: setattr(node, prop, new_val) self.property_changed.emit(node, prop, old_val, new_val) def _on_enum_changed(self, node: Node, prop: str, setting: Property, new_idx: int): """Handle DropDown selection for enum settings.""" old_val = getattr(node, prop) new_val = setting.enum[new_idx] self._on_property_changed(node, prop, old_val, new_val) def _on_colour_changed(self, node: Node, prop: str, new_colour: tuple): """Handle colour property change with undo.""" old_colour = getattr(node, prop) if old_colour == new_colour: return self._on_property_changed(node, prop, old_colour, new_colour) def _on_vec3_changed(self, node: Node, prop: str, axis: int, value: float): """Handle a single axis change on a Vec3 property.""" old = getattr(node, prop) vals = [old.x, old.y, old.z] vals[axis] = value new = Vec3(vals[0], vals[1], vals[2]) ("X", "Y", "Z")[axis] self._on_property_changed(node, prop, old, new) def _on_vec2_changed(self, node: Node, prop: str, axis: int, value: float): """Handle a single axis change on a Vec2 property.""" old = getattr(node, prop) if axis == 0: new = Vec2(value, old.y) else: new = Vec2(old.x, value) ("X", "Y")[axis] self._on_property_changed(node, prop, old, new) def _on_tuple_changed(self, node: Node, prop: str, axis: int, value: float, num_components: int): """Handle a single axis change on a tuple property.""" old = getattr(node, prop) vals = list(old) vals[axis] = value new = tuple(vals) self._on_property_changed(node, prop, old, new) # ==================================================================== # Registry-based sections (from inspector_sections.py) # ==================================================================== def _add_registered_sections(self, node: Node): """Query the section registry and add matching sections for this node.""" from .inspector_sections import InspectorContext, get_sections_for_node # Skip registry sections whose title already exists (added by built-in code) existing_titles = {s.header.title for s in self._sections} ctx = InspectorContext(self) for section in get_sections_for_node(node): if section.section_title in existing_titles: continue rows = section.build_rows(node, ctx) if rows: self._add_section(section.section_title, rows) existing_titles.add(section.section_title) # ==================================================================== # Section management # ==================================================================== def _add_section(self, title: str, rows: list[Control]): """Create a collapsible section with the given rows.""" header = SectionHeader(title) header.size = Vec2(self.size.x, _section_h()) self.add_child(header) for row in rows: row.size = Vec2(self.size.x, row.size.y) self.add_child(row) section = Section(header, rows) self._sections.append(section) # Wire toggle header.toggled.connect( lambda collapsed, sec=section: self._on_section_toggled(sec, collapsed) ) def _on_section_toggled(self, section: Section, collapsed: bool): """Show or hide all rows in a section.""" section.toggle(collapsed) self._layout_dirty_ip = True # ==================================================================== # Layout -- vertically stack all children # ====================================================================
[docs] def process(self, dt: float): """Reflow vertical layout when size changes or content is dirty.""" current_size = (self.size.x, self.size.y) if getattr(self, "_layout_dirty_ip", True) or current_size != getattr(self, "_last_size_ip", None): self._last_size_ip = current_size self._layout_dirty_ip = False self._layout_children()
def _layout_children(self): """Stack all visible children vertically with padding.""" from simvx.core.ui.containers import Container _place = Container._place pad = _padding() content_w = self.size.x - pad * 2 y = pad for child in self.children: if not isinstance(child, Control): continue if not child.visible: continue _place(child, pad, y, content_w, child.size.y) y += child.size.y + 2 # ==================================================================== # Drawing # ====================================================================
[docs] def draw(self, renderer): t = get_theme() x, y, w, h = self.get_global_rect() # Panel background renderer.draw_filled_rect(x, y, w, h, t.panel_bg) # Left border accent renderer.draw_filled_rect(x, y, 2, h, t.border) # Title bar separator if self._inspected_node is not None: sep_y = y + _padding() + 22 + _row_h() + 2 renderer.draw_filled_rect(x + 4, sep_y, w - 8, 1, t.border)
def _draw_recursive(self, renderer): """Override to wrap child traversal in a clip region.""" if not self.visible: return self.draw(renderer) x, y, w, h = self.get_global_rect() renderer.push_clip(x, y, w, h) for child in list(self.children): child._draw_recursive(renderer) renderer.pop_clip() # ==================================================================== # Utility helpers # ==================================================================== @staticmethod def _is_colour_value(name: str, value: Any) -> bool: """Heuristic: is this value a colour tuple? Returns True if the value is a tuple of 3 or 4 floats in [0, 1] and the property name contains a colour-related keyword. """ if not isinstance(value, tuple): return False if len(value) not in (3, 4): return False if not all(isinstance(v, int | float) for v in value): return False # Check name hints colour_hints = ("colour", "colour", "tint", "albedo", "emissive") name_lower = name.lower() if any(hint in name_lower for hint in colour_hints): return True # Check if all values are in 0-1 range (likely a colour) if all(0.0 <= float(v) <= 1.0 for v in value): # 4-element tuples in 0-1 range are almost certainly colours if len(value) == 4: return True return False
# ============================================================================ # Module-level helpers # ============================================================================ def _guess_step(lo: float, hi: float) -> float: """Pick a reasonable step value given a range.""" span = abs(hi - lo) if span <= 1: return 0.01 if span <= 10: return 0.1 if span <= 100: return 1.0 return 10.0