Source code for simvx.editor.panels.material_editor

"""Material Editor Panel -- Visual editor for Material properties.

Provides a dedicated panel for editing all aspects of a material:
colour, PBR properties (metallic, roughness), blend mode, boolean flags,
texture slot assignments, emissive settings, and a live colour preview.

All property edits go through the undo system via PropertyCommand.

Layout:
    +----------------------------------+
    | Material Editor    [New Material] |
    +----------------------------------+
    | Colour  [####]                     |
    |   [ColourPicker (inline toggle)]  |
    +----------------------------------+
    | v PBR Properties                 |
    |   Metallic   [====|------] 0.00  |
    |   Roughness  [========|--] 0.50  |
    +----------------------------------+
    | v Blend & Flags                  |
    |   Blend Mode [ opaque      v]    |
    |   Wireframe    [ ]               |
    |   Double Sided [ ]               |
    |   Unlit        [ ]               |
    +----------------------------------+
    | v Textures                       |
    |   Albedo      None  [Browse][X]  |
    |   Normal      None  [Browse][X]  |
    |   ...                            |
    +----------------------------------+
    | v Emissive                       |
    |   Colour  [####]                  |
    |   Intensity [====|------] 1.0    |
    +----------------------------------+
    | [Preview: colour sphere + RGB]    |
    +----------------------------------+
"""


from __future__ import annotations

from pathlib import Path

from simvx.core import (
    Button,
    CheckBox,
    ColourPicker,
    Control,
    DropDown,
    Label,
    MeshInstance3D,
    PropertyCommand,
    Signal,
    Slider,
    Vec2,
)
from simvx.core.graphics.material import Material
from simvx.core.ui.theme import em, get_theme

# ============================================================================
# Constants
# ============================================================================

_PANEL_BG = (0.14, 0.14, 0.14, 1.0)
_SECTION_BG = (0.18, 0.18, 0.18, 1.0)
_SECTION_HOVER_BG = (0.22, 0.22, 0.22, 1.0)
_HEADER_BG = (0.10, 0.10, 0.10, 1.0)
_LABEL_COLOUR = (0.7, 0.7, 0.7, 1.0)
_VALUE_COLOUR = (1.0, 1.0, 1.0, 1.0)
_ACCENT_COLOUR = (0.4, 0.7, 1.0, 1.0)
_SEPARATOR_COLOUR = (0.25, 0.25, 0.25, 1.0)
_MUTED_COLOUR = (0.5, 0.5, 0.5, 1.0)
_BUTTON_BG = (0.22, 0.22, 0.22, 1.0)
_BUTTON_HOVER = (0.30, 0.30, 0.30, 1.0)

_HEADER_HEIGHT = 28.0
_SECTION_HEADER_HEIGHT = 26.0
_SWATCH_SIZE = 20.0
_PREVIEW_HEIGHT = 120.0


def _row_h() -> float:
    return em(2.18)


def _font_size() -> float:
    return get_theme().font_size


def _label_w() -> float:
    return em(7.27)


def _padding() -> float:
    return em(0.55)


def _indent() -> float:
    return em(1.09)


# ============================================================================
# _SectionHeader -- Collapsible section header
# ============================================================================

class _SectionHeader(Control):
    """Clickable section header that toggles collapse state."""

    def __init__(self, title: str, collapsed: bool = False, **kwargs):
        super().__init__(**kwargs)
        self.title = title
        self.collapsed = collapsed
        self.toggled = Signal()
        self.size = Vec2(300, _SECTION_HEADER_HEIGHT)

    def _on_gui_input(self, event):
        if event.button == 1 and event.pressed:
            if self.is_point_inside(event.position):
                self.collapsed = not self.collapsed
                self.toggled.emit(self.collapsed)

    def draw(self, renderer):
        x, y, w, h = self.get_global_rect()
        bg = _SECTION_HOVER_BG if self.mouse_over else _SECTION_BG
        renderer.draw_filled_rect(x, y, w, h, bg)

        scale = _font_size() / 14.0
        arrow = ">" if self.collapsed else "v"
        renderer.draw_text_coloured(
            arrow, x + _padding(), y + (h - _font_size()) / 2,
            scale, _LABEL_COLOUR)
        renderer.draw_text_coloured(
            self.title, x + _padding() + 14, y + (h - _font_size()) / 2,
            scale, _LABEL_COLOUR)
        renderer.draw_filled_rect(x, y + h - 1, w, 1, _SEPARATOR_COLOUR)


# ============================================================================
# _Section -- Collapsible group of rows
# ============================================================================

class _Section:
    """Logical grouping of a header and its body rows."""

    __slots__ = ('header', 'rows', 'collapsed')

    def __init__(self, header: _SectionHeader, rows: list[Control]):
        self.header = header
        self.rows = rows
        self.collapsed = header.collapsed

    def toggle(self, collapsed: bool):
        self.collapsed = collapsed
        self.header.collapsed = collapsed
        for row in self.rows:
            row.visible = not collapsed


# ============================================================================
# MaterialEditorPanel -- Main panel
# ============================================================================

[docs] class MaterialEditorPanel(Control): """Visual editor panel for Material properties. Subscribes to ``editor_state.selection_changed`` and populates the panel when a MeshInstance3D with a material is selected. All edits go through PropertyCommand for undo/redo support. Args: editor_state: The central EditorState instance. """ def __init__(self, editor_state, **kwargs): super().__init__(**kwargs) self.state = editor_state self.bg_colour = _PANEL_BG self.size = Vec2(300, 700) # Signals self.browse_texture = Signal() # State self._material: Material | None = None self._target_node: MeshInstance3D | None = None self._sections: list[_Section] = [] self._colour_picker_visible = False self._emissive_picker_visible = False # Widget references (populated in _rebuild) self._colour_picker: ColourPicker | None = None self._emissive_picker: ColourPicker | None = None self._metallic_slider: Slider | None = None self._roughness_slider: Slider | None = None self._blend_dropdown: DropDown | None = None self._wireframe_cb: CheckBox | None = None self._double_sided_cb: CheckBox | None = None self._unlit_cb: CheckBox | None = None self._emissive_intensity_slider: Slider | None = None self._texture_labels: dict[str, Label] = {}
[docs] def ready(self): """Connect to editor state signals.""" self.state.selection_changed.connect(self._on_selection_changed) self.state.undo_stack.changed.connect(self._refresh_values)
# ==================================================================== # Selection handling # ==================================================================== def _on_selection_changed(self): """React to selection changes -- find a material to edit.""" node = self.state.selection.primary if isinstance(node, MeshInstance3D) and node.material is not None: self._target_node = node self.set_material(node.material) else: self._target_node = None self._material = None self._rebuild() # ==================================================================== # Public API # ====================================================================
[docs] def set_material(self, material: Material): """Populate all widgets from a material's properties.""" self._material = material self._rebuild()
# ==================================================================== # Rebuild -- tear down and recreate all widgets # ==================================================================== def _rebuild(self): """Recreate the entire panel layout for the current material.""" for child in list(self.children): self.remove_child(child) self._sections.clear() self._colour_picker_visible = False self._emissive_picker_visible = False self._texture_labels.clear() mat = self._material if mat is None: return self._add_header() self._add_colour_section(mat) self._add_pbr_section(mat) self._add_flags_section(mat) self._add_textures_section(mat) self._add_emissive_section(mat) # ==================================================================== # Header (28px) -- title + New Material button # ==================================================================== def _add_header(self): """Add the header bar with title and New Material button.""" header = Control() header.size = Vec2(self.size.x, _HEADER_HEIGHT) header.bg_colour = _HEADER_BG title = Label("Material Editor") title.text_colour = _ACCENT_COLOUR title.font_size = 13.0 title.position = Vec2(_padding(), 0) title.size = Vec2(150, _HEADER_HEIGHT) header.add_child(title) new_btn = Button("New Material") new_btn.font_size = 11.0 new_btn.size = Vec2(90, 22) new_btn.position = Vec2(self.size.x - 96, 3) new_btn.pressed.connect(self._on_new_material) header.add_child(new_btn) self.add_child(header) def _on_new_material(self): """Create a fresh material and assign it to the selected node.""" if self._target_node is None: return old_mat = self._target_node.material new_mat = Material() cmd = PropertyCommand( self._target_node, "material", old_mat, new_mat, description=f"New material on {self._target_node.name}", ) self.state.undo_stack.push(cmd) self._material = new_mat self._rebuild() self.state.scene_modified.emit() # ==================================================================== # Colour section -- swatch + inline ColourPicker toggle # ==================================================================== def _add_colour_section(self, mat: Material): """Add colour swatch row and toggleable inline ColourPicker.""" rows: list[Control] = [] # Swatch row swatch_row = _ColourSwatchRow( "Colour", mat.colour, self._on_colour_swatch_clicked) rows.append(swatch_row) # Inline ColourPicker (initially hidden) picker = ColourPicker() picker.size = Vec2(self.size.x - _padding() * 2 - _indent(), 200) picker.colour = mat.colour picker.colour_changed.connect(self._on_colour_changed) picker.visible = False self._colour_picker = picker rows.append(picker) self._add_section("Colour", rows, collapsed=False) def _on_colour_swatch_clicked(self): """Toggle the inline colour picker visibility.""" if self._colour_picker is not None: self._colour_picker_visible = not self._colour_picker_visible self._colour_picker.visible = self._colour_picker_visible def _on_colour_changed(self, new_colour: tuple): """Handle colour picker change with undo support.""" mat = self._material if mat is None: return old_colour = mat.colour if old_colour == new_colour: return cmd = PropertyCommand( mat, "colour", old_colour, new_colour, description="Change material colour", ) self.state.undo_stack.push(cmd) self.state.scene_modified.emit() # ==================================================================== # PBR Properties section -- Metallic + Roughness sliders # ==================================================================== def _add_pbr_section(self, mat: Material): """Add metallic and roughness sliders.""" rows: list[Control] = [] # Metallic metallic_row = _SliderRow("Metallic", 0.0, 1.0, mat.metallic, 0.01) metallic_row.slider.value_changed.connect( lambda val: self._on_slider_released( "metallic", val)) self._metallic_slider = metallic_row.slider rows.append(metallic_row) # Roughness roughness_row = _SliderRow("Roughness", 0.0, 1.0, mat.roughness, 0.01) roughness_row.slider.value_changed.connect( lambda val: self._on_slider_released( "roughness", val)) self._roughness_slider = roughness_row.slider rows.append(roughness_row) self._add_section("PBR Properties", rows) def _on_slider_released(self, prop: str, value: float): """Handle slider value change with undo (debounced on release).""" mat = self._material if mat is None: return old_val = getattr(mat, prop) if abs(old_val - value) < 1e-6: return cmd = PropertyCommand( mat, prop, old_val, value, description=f"Change material.{prop} to {value:.2f}", ) self.state.undo_stack.push(cmd) self.state.scene_modified.emit() # ==================================================================== # Blend & Flags section # ==================================================================== def _add_flags_section(self, mat: Material): """Add blend mode dropdown and boolean flag checkboxes.""" rows: list[Control] = [] # Blend Mode blend_modes = ["opaque", "alpha", "additive"] blend_idx = (blend_modes.index(mat.blend) if mat.blend in blend_modes else 0) blend_row = _DropDownRow("Blend Mode", blend_modes, blend_idx) blend_row.dropdown.item_selected.connect( lambda idx: self._on_blend_changed(blend_modes[idx])) self._blend_dropdown = blend_row.dropdown rows.append(blend_row) # Wireframe wire_row = _CheckBoxRow("Wireframe", mat.wireframe) wire_row.checkbox.toggled.connect( lambda checked: self._on_flag_changed("wireframe", checked)) self._wireframe_cb = wire_row.checkbox rows.append(wire_row) # Double Sided ds_row = _CheckBoxRow("Double Sided", mat.double_sided) ds_row.checkbox.toggled.connect( lambda checked: self._on_flag_changed("double_sided", checked)) self._double_sided_cb = ds_row.checkbox rows.append(ds_row) # Unlit unlit_row = _CheckBoxRow("Unlit", mat.unlit) unlit_row.checkbox.toggled.connect( lambda checked: self._on_flag_changed("unlit", checked)) self._unlit_cb = unlit_row.checkbox rows.append(unlit_row) self._add_section("Blend & Flags", rows) def _on_blend_changed(self, new_blend: str): """Handle blend mode change with undo.""" mat = self._material if mat is None: return old_blend = mat.blend if old_blend == new_blend: return cmd = PropertyCommand( mat, "blend", old_blend, new_blend, description=f"Change blend mode to {new_blend}", ) self.state.undo_stack.push(cmd) self.state.scene_modified.emit() def _on_flag_changed(self, prop: str, checked: bool): """Handle boolean flag change with undo.""" mat = self._material if mat is None: return old_val = getattr(mat, prop) if old_val == checked: return cmd = PropertyCommand( mat, prop, old_val, checked, description=f"Toggle material.{prop}", ) self.state.undo_stack.push(cmd) self.state.scene_modified.emit() # ==================================================================== # Textures section -- one slot per texture type # ==================================================================== _TEXTURE_SLOTS = [ ("Albedo", "albedo_uri"), ("Normal", "normal_uri"), ("Metal/Rough", "metallic_roughness_uri"), ("Emissive", "emissive_uri"), ("AO", "ao_uri"), ] def _add_textures_section(self, mat: Material): """Add texture slot rows with browse and clear buttons.""" rows: list[Control] = [] for display_name, attr_name in self._TEXTURE_SLOTS: uri = getattr(mat, attr_name) row = _TextureSlotRow( display_name, attr_name, uri, on_browse=lambda slot=attr_name: self._on_browse_texture(slot), on_clear=lambda slot=attr_name: self._on_clear_texture(slot), ) self._texture_labels[attr_name] = row.path_label rows.append(row) self._add_section("Textures", rows) def _on_browse_texture(self, slot_name: str): """Emit browse_texture signal for external file dialog handling.""" self.browse_texture.emit(slot_name) def _on_clear_texture(self, slot_name: str): """Clear a texture slot with undo support.""" mat = self._material if mat is None: return old_uri = getattr(mat, slot_name) if old_uri is None: return cmd = PropertyCommand( mat, slot_name, old_uri, None, description=f"Clear {slot_name} texture", ) self.state.undo_stack.push(cmd) if slot_name in self._texture_labels: self._texture_labels[slot_name].text = "None" self.state.scene_modified.emit() # ==================================================================== # Emissive section -- visible when emissive data exists # ==================================================================== def _add_emissive_section(self, mat: Material): """Add emissive colour swatch and intensity slider. Only visible if emissive_colour is set or emissive_map is assigned. """ has_emissive = (mat.emissive_colour is not None or mat.emissive_uri is not None) rows: list[Control] = [] # Emissive Colour swatch em_colour = mat.emissive_colour or (0.0, 0.0, 0.0, 1.0) swatch_row = _ColourSwatchRow( "Emissive Colour", em_colour, self._on_emissive_swatch_clicked) rows.append(swatch_row) # Inline emissive ColourPicker (initially hidden) em_picker = ColourPicker() em_picker.size = Vec2(self.size.x - _padding() * 2 - _indent(), 200) em_picker.colour = em_colour[:4] if len(em_colour) >= 4 else (*em_colour[:3], 1.0) em_picker.colour_changed.connect(self._on_emissive_colour_changed) em_picker.visible = False self._emissive_picker = em_picker rows.append(em_picker) # Emissive Intensity slider intensity = 1.0 if mat.emissive_colour is not None and len(mat.emissive_colour) >= 4: intensity = mat.emissive_colour[3] intensity_row = _SliderRow( "Intensity", 0.0, 10.0, intensity, 0.1) intensity_row.slider.value_changed.connect( self._on_emissive_intensity_changed) self._emissive_intensity_slider = intensity_row.slider rows.append(intensity_row) self._add_section("Emissive", rows, collapsed=not has_emissive) def _on_emissive_swatch_clicked(self): """Toggle the emissive colour picker visibility.""" if self._emissive_picker is not None: self._emissive_picker_visible = not self._emissive_picker_visible self._emissive_picker.visible = self._emissive_picker_visible def _on_emissive_colour_changed(self, new_colour: tuple): """Handle emissive colour change with undo.""" mat = self._material if mat is None: return old_ec = mat.emissive_colour # Preserve intensity from the 4th component if it exists intensity = 1.0 if old_ec is not None and len(old_ec) >= 4: intensity = old_ec[3] new_ec = (new_colour[0], new_colour[1], new_colour[2], intensity) cmd = PropertyCommand( mat, "emissive_colour", old_ec, new_ec, description="Change emissive colour", ) self.state.undo_stack.push(cmd) self.state.scene_modified.emit() def _on_emissive_intensity_changed(self, value: float): """Handle emissive intensity slider change with undo.""" mat = self._material if mat is None: return old_ec = mat.emissive_colour if old_ec is None: old_ec = (0.0, 0.0, 0.0, 1.0) new_ec = (old_ec[0], old_ec[1], old_ec[2], value) if old_ec == new_ec: return cmd = PropertyCommand( mat, "emissive_colour", mat.emissive_colour, new_ec, description=f"Change emissive intensity to {value:.1f}", ) self.state.undo_stack.push(cmd) self.state.scene_modified.emit() # ==================================================================== # Refresh -- sync widget values after undo/redo # ==================================================================== def _refresh_values(self): """Sync all widget values from the material after undo/redo.""" mat = self._material if mat is None: return # PBR sliders if self._metallic_slider is not None: self._metallic_slider.value = mat.metallic if self._roughness_slider is not None: self._roughness_slider.value = mat.roughness # Colour picker if self._colour_picker is not None: self._colour_picker.colour = mat.colour # Blend mode if self._blend_dropdown is not None: blend_modes = ["opaque", "alpha", "additive"] idx = blend_modes.index(mat.blend) if mat.blend in blend_modes else 0 self._blend_dropdown.selected = idx # Boolean flags if self._wireframe_cb is not None: self._wireframe_cb.checked = mat.wireframe if self._double_sided_cb is not None: self._double_sided_cb.checked = mat.double_sided if self._unlit_cb is not None: self._unlit_cb.checked = mat.unlit # Texture labels for _, attr_name in self._TEXTURE_SLOTS: if attr_name in self._texture_labels: uri = getattr(mat, attr_name) self._texture_labels[attr_name].text = ( Path(uri).name if uri else "None") # Emissive if self._emissive_picker is not None and mat.emissive_colour is not None: ec = mat.emissive_colour self._emissive_picker.colour = ec[:4] if len(ec) >= 4 else (*ec[:3], 1.0) if (self._emissive_intensity_slider is not None and mat.emissive_colour is not None and len(mat.emissive_colour) >= 4): self._emissive_intensity_slider.value = mat.emissive_colour[3] # ==================================================================== # Section management # ==================================================================== def _add_section(self, title: str, rows: list[Control], collapsed: bool = False): """Create a collapsible section with the given rows.""" header = _SectionHeader(title, collapsed=collapsed) header.size = Vec2(self.size.x, _SECTION_HEADER_HEIGHT) self.add_child(header) for row in rows: row.size = Vec2(self.size.x, row.size.y) if collapsed: row.visible = False self.add_child(row) section = _Section(header, rows) self._sections.append(section) header.toggled.connect( lambda c, sec=section: sec.toggle(c)) # ==================================================================== # Layout # ====================================================================
[docs] def process(self, dt: float): """Reflow vertical layout each frame.""" y = _padding() for child in self.children: if not isinstance(child, Control): continue if not child.visible: continue child.position = Vec2(_padding(), y) child.size = Vec2(self.size.x - _padding() * 2, child.size.y) y += child.size.y + 2
# ==================================================================== # Drawing # ====================================================================
[docs] def draw(self, renderer): x, y, w, h = self.get_global_rect() # Panel background renderer.draw_filled_rect(x, y, w, h, self.bg_colour) # Left border accent renderer.draw_filled_rect(x, y, 2, h, _ACCENT_COLOUR) renderer.push_clip(x, y, w, h) if self._material is None: # No material -- show placeholder message scale = _font_size() / 14.0 msg = "Select a MeshInstance3D node" tw = renderer.text_width(msg, scale) renderer.draw_text_coloured( msg, x + (w - tw) / 2, y + h / 2 - _font_size() / 2, scale, _MUTED_COLOUR) else: # Draw all child widgets for child in self.children: if isinstance(child, Control) and child.visible: child._draw_recursive(renderer) # Preview area at the bottom self._draw_preview(renderer) renderer.pop_clip()
def _draw_preview(self, renderer): """Draw a material preview sphere and RGB readout at the bottom.""" mat = self._material if mat is None: return x, y, w, h = self.get_global_rect() preview_y = y + h - _PREVIEW_HEIGHT preview_w = w preview_h = _PREVIEW_HEIGHT # Background renderer.draw_filled_rect( x, preview_y, preview_w, preview_h, (0.10, 0.10, 0.10, 1.0)) renderer.draw_filled_rect( x, preview_y, preview_w, 1, _SEPARATOR_COLOUR) # Draw a circle/sphere approximation using concentric rings cx = x + preview_w / 2 cy = preview_y + preview_h / 2 - 10 radius = 30.0 r, g, b, a = mat.colour # Sphere representation: draw filled circles from back to front # with lighting simulation (brighter at highlight, darker at edges) steps = 12 for i in range(steps, 0, -1): t = i / steps ring_r = radius * t # Simulate diffuse lighting: center is brighter shade = 0.4 + 0.6 * (1.0 - t * t) ring_colour = ( min(1.0, r * shade), min(1.0, g * shade), min(1.0, b * shade), a, ) # Approximate circle with a filled rect (square) half = ring_r renderer.draw_filled_rect( cx - half, cy - half, half * 2, half * 2, ring_colour) # Specular highlight (small bright spot) highlight_x = cx - radius * 0.25 highlight_y = cy - radius * 0.25 hl_size = radius * 0.3 hl_bright = min(1.0, 0.3 + (1.0 - mat.roughness) * 0.7) renderer.draw_filled_rect( highlight_x, highlight_y, hl_size, hl_size, (hl_bright, hl_bright, hl_bright, 0.5)) # RGB text readout scale = 10.0 / 14.0 rgb_text = f"R:{r:.2f} G:{g:.2f} B:{b:.2f} A:{a:.2f}" tw = renderer.text_width(rgb_text, scale) renderer.draw_text_coloured( rgb_text, x + (preview_w - tw) / 2, cy + radius + 12, scale, _LABEL_COLOUR) # Material info line info = f"{mat.blend}" if mat.wireframe: info += " | wire" if mat.unlit: info += " | unlit" info_tw = renderer.text_width(info, scale) renderer.draw_text_coloured( info, x + (preview_w - info_tw) / 2, cy + radius + 26, scale, _MUTED_COLOUR)
# ============================================================================ # Row widgets -- lightweight inline controls for the material editor # ============================================================================ class _ColourSwatchRow(Control): """A row with a label and a clickable colour swatch rectangle.""" def __init__(self, label_text: str, colour: tuple, on_click=None, **kwargs): super().__init__(**kwargs) self.label_text = label_text self.swatch_colour = colour self._on_click = on_click self.size = Vec2(300, _row_h()) def _on_gui_input(self, event): if event.button == 1 and event.pressed: if self.is_point_inside(event.position): if self._on_click: self._on_click() def draw(self, renderer): x, y, w, h = self.get_global_rect() scale = _font_size() / 14.0 # Label renderer.draw_text_coloured( self.label_text, x + _indent(), y + (h - _font_size()) / 2, scale, _LABEL_COLOUR) # Colour swatch swatch_x = x + _indent() + _label_w() swatch_y = y + (h - _SWATCH_SIZE) / 2 renderer.draw_filled_rect( swatch_x, swatch_y, _SWATCH_SIZE, _SWATCH_SIZE, self.swatch_colour) renderer.draw_rect_coloured( swatch_x, swatch_y, _SWATCH_SIZE, _SWATCH_SIZE, (0.4, 0.4, 0.4, 1.0)) # Hex readout next to swatch r, g, b = self.swatch_colour[:3] ri = max(0, min(255, int(round(r * 255)))) gi = max(0, min(255, int(round(g * 255)))) bi = max(0, min(255, int(round(b * 255)))) hex_str = f"#{ri:02X}{gi:02X}{bi:02X}" renderer.draw_text_coloured( hex_str, swatch_x + _SWATCH_SIZE + 6, y + (h - _font_size()) / 2, scale, _MUTED_COLOUR) class _SliderRow(Control): """A row with a label and a Slider widget.""" def __init__(self, label_text: str, min_val: float, max_val: float, value: float, step: float, **kwargs): super().__init__(**kwargs) self.label_text = label_text self.slider = Slider(min_val=min_val, max_val=max_val, value=value) self.slider.step = step self.size = Vec2(300, _row_h()) self.add_child(self.slider) def process(self, dt: float): _, _, w, h = self.get_rect() widget_x = _indent() + _label_w() widget_w = max(40, w - widget_x - _padding() - 40) self.slider.position = Vec2(widget_x, 2) self.slider.size = Vec2(widget_w, h - 4) def draw(self, renderer): x, y, w, h = self.get_global_rect() scale = _font_size() / 14.0 # Label renderer.draw_text_coloured( self.label_text, x + _indent(), y + (h - _font_size()) / 2, scale, _LABEL_COLOUR) # Value readout val_text = f"{self.slider.value:.2f}" val_tw = renderer.text_width(val_text, scale) renderer.draw_text_coloured( val_text, x + w - val_tw - _padding(), y + (h - _font_size()) / 2, scale, _VALUE_COLOUR) class _DropDownRow(Control): """A row with a label and a DropDown widget.""" def __init__(self, label_text: str, items: list[str], selected: int = 0, **kwargs): super().__init__(**kwargs) self.label_text = label_text self.dropdown = DropDown(items=items, selected=selected) self.dropdown.font_size = 11.0 self.size = Vec2(300, _row_h()) self.add_child(self.dropdown) def process(self, dt: float): _, _, w, h = self.get_rect() widget_x = _indent() + _label_w() widget_w = max(60, w - widget_x - _padding()) self.dropdown.position = Vec2(widget_x, 1) self.dropdown.size = Vec2(widget_w, h - 2) def draw(self, renderer): x, y, w, h = self.get_global_rect() scale = _font_size() / 14.0 renderer.draw_text_coloured( self.label_text, x + _indent(), y + (h - _font_size()) / 2, scale, _LABEL_COLOUR) class _CheckBoxRow(Control): """A row with a label and a CheckBox widget.""" def __init__(self, label_text: str, checked: bool = False, **kwargs): super().__init__(**kwargs) self.label_text = label_text self.checkbox = CheckBox("", checked=checked) self.size = Vec2(300, _row_h()) self.add_child(self.checkbox) def process(self, dt: float): _, _, w, h = self.get_rect() widget_x = _indent() + _label_w() self.checkbox.position = Vec2(widget_x, (h - 20) / 2) self.checkbox.size = Vec2(20, 20) def draw(self, renderer): x, y, w, h = self.get_global_rect() scale = _font_size() / 14.0 renderer.draw_text_coloured( self.label_text, x + _indent(), y + (h - _font_size()) / 2, scale, _LABEL_COLOUR) class _TextureSlotRow(Control): """A row showing a texture slot: label, path, Browse, Clear buttons.""" def __init__(self, display_name: str, attr_name: str, uri: str | None, on_browse=None, on_clear=None, **kwargs): super().__init__(**kwargs) self.display_name = display_name self.attr_name = attr_name self._on_browse = on_browse self._on_clear = on_clear self.size = Vec2(300, _row_h()) # Path label path_text = Path(uri).name if uri else "None" self.path_label = Label(path_text) self.path_label.text_colour = _MUTED_COLOUR if uri is None else _VALUE_COLOUR self.path_label.font_size = 11.0 self.add_child(self.path_label) # Browse button self.browse_btn = Button("Browse") self.browse_btn.font_size = 10.0 self.browse_btn.size = Vec2(48, 18) if on_browse: self.browse_btn.pressed.connect(on_browse) self.add_child(self.browse_btn) # Clear button self.clear_btn = Button("X") self.clear_btn.font_size = 10.0 self.clear_btn.size = Vec2(20, 18) if on_clear: self.clear_btn.pressed.connect(on_clear) self.add_child(self.clear_btn) def process(self, dt: float): _, _, w, h = self.get_rect() label_x = _indent() + _label_w() clear_x = w - _padding() - 20 browse_x = clear_x - 4 - 48 path_w = max(20, browse_x - label_x - 4) self.path_label.position = Vec2(label_x, (h - 14) / 2) self.path_label.size = Vec2(path_w, 14) self.browse_btn.position = Vec2(browse_x, (h - 18) / 2) self.clear_btn.position = Vec2(clear_x, (h - 18) / 2) def draw(self, renderer): x, y, w, h = self.get_global_rect() scale = _font_size() / 14.0 # Slot type label renderer.draw_text_coloured( self.display_name, x + _indent(), y + (h - _font_size()) / 2, scale, _LABEL_COLOUR)