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