Test suite modernization
This commit is contained in:
parent
0969f7c2f6
commit
52fdfd0347
141 changed files with 9947 additions and 4665 deletions
18
tests/procgen_interactive/core/__init__.py
Normal file
18
tests/procgen_interactive/core/__init__.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
"""Core framework components for interactive procedural generation demos."""
|
||||
|
||||
from .demo_base import ProcgenDemoBase, StepDef, LayerDef, StateSnapshot
|
||||
from .parameter import Parameter
|
||||
from .widgets import Stepper, Slider, LayerToggle
|
||||
from .viewport import ViewportController
|
||||
|
||||
__all__ = [
|
||||
'ProcgenDemoBase',
|
||||
'StepDef',
|
||||
'LayerDef',
|
||||
'StateSnapshot',
|
||||
'Parameter',
|
||||
'Stepper',
|
||||
'Slider',
|
||||
'LayerToggle',
|
||||
'ViewportController',
|
||||
]
|
||||
614
tests/procgen_interactive/core/demo_base.py
Normal file
614
tests/procgen_interactive/core/demo_base.py
Normal file
|
|
@ -0,0 +1,614 @@
|
|||
"""Base class for interactive procedural generation demos.
|
||||
|
||||
Provides the core framework for:
|
||||
- Step-by-step generation with forward/backward navigation
|
||||
- State snapshots for true backward navigation
|
||||
- Parameter management with regeneration on change
|
||||
- Layer visibility management
|
||||
- UI layout with control panel
|
||||
"""
|
||||
|
||||
import mcrfpy
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Dict, Any, Callable, Optional, Tuple
|
||||
from abc import ABC, abstractmethod
|
||||
from .parameter import Parameter
|
||||
from .widgets import Stepper, Slider, LayerToggle
|
||||
from .viewport import ViewportController
|
||||
|
||||
|
||||
@dataclass
|
||||
class StepDef:
|
||||
"""Definition of a generation step.
|
||||
|
||||
Attributes:
|
||||
name: Display name for the step
|
||||
function: Callable that executes the step
|
||||
description: Optional longer description/tooltip
|
||||
"""
|
||||
name: str
|
||||
function: Callable
|
||||
description: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class LayerDef:
|
||||
"""Definition of a visualization layer.
|
||||
|
||||
Attributes:
|
||||
name: Internal name (for grid.layers access)
|
||||
display: Display name in UI
|
||||
type: 'color' or 'tile'
|
||||
z_index: Render order (-1 = below entities, 1 = above)
|
||||
visible: Initial visibility
|
||||
description: Optional tooltip
|
||||
"""
|
||||
name: str
|
||||
display: str
|
||||
type: str = "color"
|
||||
z_index: int = -1
|
||||
visible: bool = True
|
||||
description: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class StateSnapshot:
|
||||
"""Captured state at a specific step for backward navigation.
|
||||
|
||||
Stores HeightMap data as lists for restoration.
|
||||
"""
|
||||
step_index: int
|
||||
heightmaps: Dict[str, List[float]] = field(default_factory=dict)
|
||||
layer_colors: Dict[str, List[Tuple[int, int, int, int]]] = field(default_factory=dict)
|
||||
extra_data: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class ProcgenDemoBase(ABC):
|
||||
"""Abstract base class for procedural generation demos.
|
||||
|
||||
Subclasses must implement:
|
||||
- name: Demo display name
|
||||
- description: Demo description
|
||||
- define_steps(): Return list of StepDef
|
||||
- define_parameters(): Return list of Parameter
|
||||
- define_layers(): Return list of LayerDef
|
||||
|
||||
The framework provides:
|
||||
- Step navigation (forward/backward)
|
||||
- State snapshot capture and restoration
|
||||
- Parameter UI widgets
|
||||
- Layer visibility toggles
|
||||
- Viewport pan/zoom
|
||||
"""
|
||||
|
||||
# Subclass must set these
|
||||
name: str = "Unnamed Demo"
|
||||
description: str = ""
|
||||
|
||||
# Default map size - subclasses can override
|
||||
MAP_SIZE: Tuple[int, int] = (256, 256)
|
||||
|
||||
# Layout constants
|
||||
GRID_WIDTH = 700
|
||||
GRID_HEIGHT = 525
|
||||
PANEL_WIDTH = 300
|
||||
PANEL_X = 720
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the demo framework."""
|
||||
# Get definitions from subclass
|
||||
self.steps = self.define_steps()
|
||||
self.parameters = {p.name: p for p in self.define_parameters()}
|
||||
self.layer_defs = self.define_layers()
|
||||
|
||||
# State tracking
|
||||
self.current_step = 0
|
||||
self.state_history: List[StateSnapshot] = []
|
||||
self.heightmaps: Dict[str, mcrfpy.HeightMap] = {}
|
||||
|
||||
# UI elements
|
||||
self.scene = None
|
||||
self.grid = None
|
||||
self.layers: Dict[str, Any] = {}
|
||||
self.viewport = None
|
||||
self.widgets: Dict[str, Any] = {}
|
||||
|
||||
# Build the scene
|
||||
self._build_scene()
|
||||
|
||||
# Wire up parameter change handlers
|
||||
for param in self.parameters.values():
|
||||
param._on_change = self._on_parameter_change
|
||||
|
||||
@abstractmethod
|
||||
def define_steps(self) -> List[StepDef]:
|
||||
"""Define the generation steps. Subclass must implement."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def define_parameters(self) -> List[Parameter]:
|
||||
"""Define configurable parameters. Subclass must implement."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def define_layers(self) -> List[LayerDef]:
|
||||
"""Define visualization layers. Subclass must implement."""
|
||||
pass
|
||||
|
||||
def _build_scene(self):
|
||||
"""Build the scene with grid, layers, and control panel."""
|
||||
self.scene = mcrfpy.Scene(f"procgen_{self.name.lower().replace(' ', '_')}")
|
||||
ui = self.scene.children
|
||||
|
||||
# Background
|
||||
bg = mcrfpy.Frame(
|
||||
pos=(0, 0),
|
||||
size=(1024, 768),
|
||||
fill_color=mcrfpy.Color(25, 25, 30)
|
||||
)
|
||||
ui.append(bg)
|
||||
|
||||
# Grid for visualization
|
||||
self.grid = mcrfpy.Grid(
|
||||
grid_size=self.MAP_SIZE,
|
||||
pos=(10, 10),
|
||||
size=(self.GRID_WIDTH, self.GRID_HEIGHT)
|
||||
)
|
||||
ui.append(self.grid)
|
||||
|
||||
# Add layers from definitions
|
||||
for layer_def in self.layer_defs:
|
||||
if layer_def.type == "color":
|
||||
layer = mcrfpy.ColorLayer(z_index=layer_def.z_index, grid_size=self.MAP_SIZE)
|
||||
else:
|
||||
layer = mcrfpy.TileLayer(z_index=layer_def.z_index, grid_size=self.MAP_SIZE)
|
||||
self.grid.add_layer(layer)
|
||||
layer.visible = layer_def.visible
|
||||
self.layers[layer_def.name] = layer
|
||||
|
||||
# Keyboard handler - set BEFORE viewport so viewport can chain to it
|
||||
self.scene.on_key = self._on_key
|
||||
|
||||
# Set up viewport controller (handles scroll wheel via on_click, chains keyboard to us)
|
||||
self.viewport = ViewportController(
|
||||
self.grid, self.scene,
|
||||
on_zoom_change=self._on_zoom_change
|
||||
)
|
||||
|
||||
# Build control panel
|
||||
self._build_control_panel(ui)
|
||||
|
||||
def _build_control_panel(self, ui):
|
||||
"""Build the right-side control panel."""
|
||||
panel_y = 10
|
||||
|
||||
# Title
|
||||
title = mcrfpy.Caption(
|
||||
text=f"Demo: {self.name}",
|
||||
pos=(self.PANEL_X, panel_y),
|
||||
font_size=20,
|
||||
fill_color=mcrfpy.Color(220, 220, 230)
|
||||
)
|
||||
ui.append(title)
|
||||
panel_y += 35
|
||||
|
||||
# Separator
|
||||
sep1 = mcrfpy.Frame(
|
||||
pos=(self.PANEL_X, panel_y),
|
||||
size=(self.PANEL_WIDTH, 2),
|
||||
fill_color=mcrfpy.Color(60, 60, 70)
|
||||
)
|
||||
ui.append(sep1)
|
||||
panel_y += 10
|
||||
|
||||
# Step navigation
|
||||
step_label = mcrfpy.Caption(
|
||||
text="Step:",
|
||||
pos=(self.PANEL_X, panel_y),
|
||||
font_size=14,
|
||||
fill_color=mcrfpy.Color(150, 150, 160)
|
||||
)
|
||||
ui.append(step_label)
|
||||
panel_y += 20
|
||||
|
||||
# Step display and navigation
|
||||
self._step_display = mcrfpy.Caption(
|
||||
text=self._format_step_display(),
|
||||
pos=(self.PANEL_X, panel_y),
|
||||
font_size=16,
|
||||
fill_color=mcrfpy.Color(200, 200, 210)
|
||||
)
|
||||
ui.append(self._step_display)
|
||||
|
||||
# Step nav buttons
|
||||
btn_prev = mcrfpy.Frame(
|
||||
pos=(self.PANEL_X + 200, panel_y - 5),
|
||||
size=(30, 25),
|
||||
fill_color=mcrfpy.Color(60, 60, 70),
|
||||
outline=1,
|
||||
outline_color=mcrfpy.Color(100, 100, 110)
|
||||
)
|
||||
prev_label = mcrfpy.Caption(text="<", pos=(10, 3), font_size=14,
|
||||
fill_color=mcrfpy.Color(200, 200, 210))
|
||||
btn_prev.children.append(prev_label)
|
||||
btn_prev.on_click = lambda p, b, a: self._on_step_prev() if b == mcrfpy.MouseButton.LEFT and a == mcrfpy.InputState.RELEASED else None
|
||||
ui.append(btn_prev)
|
||||
|
||||
btn_next = mcrfpy.Frame(
|
||||
pos=(self.PANEL_X + 235, panel_y - 5),
|
||||
size=(30, 25),
|
||||
fill_color=mcrfpy.Color(60, 60, 70),
|
||||
outline=1,
|
||||
outline_color=mcrfpy.Color(100, 100, 110)
|
||||
)
|
||||
next_label = mcrfpy.Caption(text=">", pos=(10, 3), font_size=14,
|
||||
fill_color=mcrfpy.Color(200, 200, 210))
|
||||
btn_next.children.append(next_label)
|
||||
btn_next.on_click = lambda p, b, a: self._on_step_next() if b == mcrfpy.MouseButton.LEFT and a == mcrfpy.InputState.RELEASED else None
|
||||
ui.append(btn_next)
|
||||
|
||||
panel_y += 30
|
||||
|
||||
# Current step name
|
||||
self._step_name = mcrfpy.Caption(
|
||||
text="",
|
||||
pos=(self.PANEL_X, panel_y),
|
||||
font_size=12,
|
||||
fill_color=mcrfpy.Color(120, 150, 180)
|
||||
)
|
||||
ui.append(self._step_name)
|
||||
panel_y += 30
|
||||
|
||||
# Separator
|
||||
sep2 = mcrfpy.Frame(
|
||||
pos=(self.PANEL_X, panel_y),
|
||||
size=(self.PANEL_WIDTH, 2),
|
||||
fill_color=mcrfpy.Color(60, 60, 70)
|
||||
)
|
||||
ui.append(sep2)
|
||||
panel_y += 15
|
||||
|
||||
# Parameters section
|
||||
if self.parameters:
|
||||
param_header = mcrfpy.Caption(
|
||||
text="Parameters",
|
||||
pos=(self.PANEL_X, panel_y),
|
||||
font_size=14,
|
||||
fill_color=mcrfpy.Color(150, 150, 160)
|
||||
)
|
||||
ui.append(param_header)
|
||||
panel_y += 25
|
||||
|
||||
for param in self.parameters.values():
|
||||
# Parameter label
|
||||
param_label = mcrfpy.Caption(
|
||||
text=param.display + ":",
|
||||
pos=(self.PANEL_X, panel_y),
|
||||
font_size=12,
|
||||
fill_color=mcrfpy.Color(180, 180, 190)
|
||||
)
|
||||
ui.append(param_label)
|
||||
panel_y += 20
|
||||
|
||||
# Widget based on type
|
||||
if param.type == 'int':
|
||||
widget = Stepper(param, pos=(self.PANEL_X, panel_y),
|
||||
width=180, on_change=self._on_widget_change)
|
||||
else: # float
|
||||
widget = Slider(param, pos=(self.PANEL_X, panel_y),
|
||||
width=200, on_change=self._on_widget_change)
|
||||
ui.append(widget.frame)
|
||||
self.widgets[param.name] = widget
|
||||
panel_y += 35
|
||||
|
||||
panel_y += 10
|
||||
|
||||
# Separator
|
||||
sep3 = mcrfpy.Frame(
|
||||
pos=(self.PANEL_X, panel_y),
|
||||
size=(self.PANEL_WIDTH, 2),
|
||||
fill_color=mcrfpy.Color(60, 60, 70)
|
||||
)
|
||||
ui.append(sep3)
|
||||
panel_y += 15
|
||||
|
||||
# Layers section
|
||||
if self.layer_defs:
|
||||
layer_header = mcrfpy.Caption(
|
||||
text="Layers",
|
||||
pos=(self.PANEL_X, panel_y),
|
||||
font_size=14,
|
||||
fill_color=mcrfpy.Color(150, 150, 160)
|
||||
)
|
||||
ui.append(layer_header)
|
||||
panel_y += 25
|
||||
|
||||
for layer_def in self.layer_defs:
|
||||
layer = self.layers.get(layer_def.name)
|
||||
toggle = LayerToggle(
|
||||
layer_def.display, layer,
|
||||
pos=(self.PANEL_X, panel_y),
|
||||
width=180,
|
||||
initial=layer_def.visible,
|
||||
on_change=self._on_layer_toggle
|
||||
)
|
||||
ui.append(toggle.frame)
|
||||
self.widgets[f"layer_{layer_def.name}"] = toggle
|
||||
panel_y += 30
|
||||
|
||||
panel_y += 15
|
||||
|
||||
# View section
|
||||
sep4 = mcrfpy.Frame(
|
||||
pos=(self.PANEL_X, panel_y),
|
||||
size=(self.PANEL_WIDTH, 2),
|
||||
fill_color=mcrfpy.Color(60, 60, 70)
|
||||
)
|
||||
ui.append(sep4)
|
||||
panel_y += 15
|
||||
|
||||
view_header = mcrfpy.Caption(
|
||||
text="View",
|
||||
pos=(self.PANEL_X, panel_y),
|
||||
font_size=14,
|
||||
fill_color=mcrfpy.Color(150, 150, 160)
|
||||
)
|
||||
ui.append(view_header)
|
||||
panel_y += 25
|
||||
|
||||
self._zoom_display = mcrfpy.Caption(
|
||||
text="Zoom: 1.00x",
|
||||
pos=(self.PANEL_X, panel_y),
|
||||
font_size=12,
|
||||
fill_color=mcrfpy.Color(180, 180, 190)
|
||||
)
|
||||
ui.append(self._zoom_display)
|
||||
panel_y += 25
|
||||
|
||||
# Reset view button
|
||||
btn_reset = mcrfpy.Frame(
|
||||
pos=(self.PANEL_X, panel_y),
|
||||
size=(100, 25),
|
||||
fill_color=mcrfpy.Color(60, 60, 70),
|
||||
outline=1,
|
||||
outline_color=mcrfpy.Color(100, 100, 110)
|
||||
)
|
||||
reset_label = mcrfpy.Caption(text="Reset View", pos=(15, 5), font_size=12,
|
||||
fill_color=mcrfpy.Color(200, 200, 210))
|
||||
btn_reset.children.append(reset_label)
|
||||
btn_reset.on_click = lambda p, b, a: self.viewport.reset_view() if b == mcrfpy.MouseButton.LEFT and a == mcrfpy.InputState.RELEASED else None
|
||||
ui.append(btn_reset)
|
||||
panel_y += 40
|
||||
|
||||
# Instructions at bottom
|
||||
instructions = [
|
||||
"Left/Right: Step nav",
|
||||
"Middle-drag: Pan",
|
||||
"Scroll: Zoom",
|
||||
"1-9: Toggle layers",
|
||||
"R: Reset view",
|
||||
"Esc: Menu"
|
||||
]
|
||||
for instr in instructions:
|
||||
instr_caption = mcrfpy.Caption(
|
||||
text=instr,
|
||||
pos=(self.PANEL_X, panel_y),
|
||||
font_size=10,
|
||||
fill_color=mcrfpy.Color(100, 100, 110)
|
||||
)
|
||||
ui.append(instr_caption)
|
||||
panel_y += 15
|
||||
|
||||
def _format_step_display(self) -> str:
|
||||
"""Format step counter display."""
|
||||
return f"{self.current_step}/{len(self.steps)}"
|
||||
|
||||
def _update_step_display(self):
|
||||
"""Update step navigation display."""
|
||||
self._step_display.text = self._format_step_display()
|
||||
if 0 < self.current_step <= len(self.steps):
|
||||
self._step_name.text = self.steps[self.current_step - 1].name
|
||||
else:
|
||||
self._step_name.text = "(not started)"
|
||||
|
||||
def _on_zoom_change(self, zoom: float):
|
||||
"""Handle zoom level change."""
|
||||
self._zoom_display.text = f"Zoom: {zoom:.2f}x"
|
||||
|
||||
def _on_step_prev(self):
|
||||
"""Go to previous step."""
|
||||
self.reverse_step()
|
||||
|
||||
def _on_step_next(self):
|
||||
"""Go to next step."""
|
||||
self.advance_step()
|
||||
|
||||
def _on_widget_change(self, param: Parameter):
|
||||
"""Handle parameter widget change."""
|
||||
# Parameter already updated, trigger regeneration
|
||||
self.regenerate_from(param.affects_step)
|
||||
|
||||
def _on_parameter_change(self, param: Parameter):
|
||||
"""Handle direct parameter value change."""
|
||||
# Update widget display if exists
|
||||
widget = self.widgets.get(param.name)
|
||||
if widget:
|
||||
widget.update_display()
|
||||
|
||||
def _on_layer_toggle(self, name: str, visible: bool):
|
||||
"""Handle layer visibility toggle."""
|
||||
# Layer visibility already updated by widget
|
||||
pass
|
||||
|
||||
def _on_key(self, key, action):
|
||||
"""Handle keyboard input."""
|
||||
# Only process on key press
|
||||
if action != mcrfpy.InputState.PRESSED:
|
||||
return
|
||||
|
||||
# Check specific keys using enums
|
||||
if key == mcrfpy.Key.LEFT:
|
||||
self.reverse_step()
|
||||
elif key == mcrfpy.Key.RIGHT:
|
||||
self.advance_step()
|
||||
elif key == mcrfpy.Key.R:
|
||||
self.viewport.reset_view()
|
||||
elif key == mcrfpy.Key.ESCAPE:
|
||||
self._return_to_menu()
|
||||
else:
|
||||
# Number keys for layer toggles - convert to string for parsing
|
||||
key_str = str(key) if not isinstance(key, str) else key
|
||||
if key_str.startswith("Key.NUM") or (len(key_str) == 1 and key_str.isdigit()):
|
||||
try:
|
||||
num = int(key_str[-1])
|
||||
if 1 <= num <= len(self.layer_defs):
|
||||
layer_def = self.layer_defs[num - 1]
|
||||
toggle = self.widgets.get(f"layer_{layer_def.name}")
|
||||
if toggle:
|
||||
toggle.toggle()
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
def _return_to_menu(self):
|
||||
"""Return to demo menu."""
|
||||
try:
|
||||
from ..main import show_menu
|
||||
show_menu()
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# === State Management ===
|
||||
|
||||
def capture_state(self) -> StateSnapshot:
|
||||
"""Capture current state for later restoration."""
|
||||
snapshot = StateSnapshot(step_index=self.current_step)
|
||||
|
||||
# Capture HeightMap data
|
||||
for name, hmap in self.heightmaps.items():
|
||||
data = []
|
||||
w, h = hmap.size
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
data.append(hmap[x, y])
|
||||
snapshot.heightmaps[name] = data
|
||||
|
||||
# Capture layer colors
|
||||
for name, layer in self.layers.items():
|
||||
if hasattr(layer, 'at'): # ColorLayer
|
||||
colors = []
|
||||
w, h = layer.grid_size
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
c = layer.at(x, y)
|
||||
colors.append((c.r, c.g, c.b, c.a))
|
||||
snapshot.layer_colors[name] = colors
|
||||
|
||||
return snapshot
|
||||
|
||||
def restore_state(self, snapshot: StateSnapshot):
|
||||
"""Restore state from snapshot."""
|
||||
# Restore HeightMap data
|
||||
for name, data in snapshot.heightmaps.items():
|
||||
if name in self.heightmaps:
|
||||
hmap = self.heightmaps[name]
|
||||
w, h = hmap.size
|
||||
idx = 0
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
hmap[x, y] = data[idx]
|
||||
idx += 1
|
||||
|
||||
# Restore layer colors
|
||||
for name, colors in snapshot.layer_colors.items():
|
||||
if name in self.layers:
|
||||
layer = self.layers[name]
|
||||
if hasattr(layer, 'set'): # ColorLayer
|
||||
w, h = layer.grid_size
|
||||
idx = 0
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
r, g, b, a = colors[idx]
|
||||
layer.set((x, y), mcrfpy.Color(r, g, b, a))
|
||||
idx += 1
|
||||
|
||||
self.current_step = snapshot.step_index
|
||||
self._update_step_display()
|
||||
|
||||
def advance_step(self):
|
||||
"""Execute the next generation step."""
|
||||
if self.current_step >= len(self.steps):
|
||||
return # Already at end
|
||||
|
||||
# Capture state before this step
|
||||
snapshot = self.capture_state()
|
||||
self.state_history.append(snapshot)
|
||||
|
||||
# Execute the step
|
||||
step = self.steps[self.current_step]
|
||||
step.function()
|
||||
self.current_step += 1
|
||||
self._update_step_display()
|
||||
|
||||
def reverse_step(self):
|
||||
"""Restore to previous step's state."""
|
||||
if not self.state_history:
|
||||
return # No history to restore
|
||||
|
||||
snapshot = self.state_history.pop()
|
||||
self.restore_state(snapshot)
|
||||
|
||||
def regenerate_from(self, step: int):
|
||||
"""Re-run generation from a specific step after parameter change."""
|
||||
# Find the snapshot for the step before target
|
||||
while self.state_history and self.state_history[-1].step_index >= step:
|
||||
self.state_history.pop()
|
||||
|
||||
# Restore to just before target step
|
||||
if self.state_history:
|
||||
snapshot = self.state_history[-1]
|
||||
self.restore_state(snapshot)
|
||||
else:
|
||||
# No history - reset to beginning
|
||||
self.current_step = 0
|
||||
self._reset_state()
|
||||
|
||||
# Re-run steps up to where we were
|
||||
target = min(step + 1, len(self.steps))
|
||||
while self.current_step < target:
|
||||
self.advance_step()
|
||||
|
||||
def _reset_state(self):
|
||||
"""Reset all state to initial. Override in subclass if needed."""
|
||||
for hmap in self.heightmaps.values():
|
||||
hmap.fill(0.0)
|
||||
for layer in self.layers.values():
|
||||
if hasattr(layer, 'fill'):
|
||||
layer.fill(mcrfpy.Color(0, 0, 0, 0))
|
||||
|
||||
# === Activation ===
|
||||
|
||||
def activate(self):
|
||||
"""Activate this demo's scene."""
|
||||
mcrfpy.current_scene = self.scene
|
||||
self._update_step_display()
|
||||
|
||||
def run(self):
|
||||
"""Activate and run through first step."""
|
||||
self.activate()
|
||||
|
||||
# === Utility Methods for Subclasses ===
|
||||
|
||||
def get_param(self, name: str) -> Any:
|
||||
"""Get current value of a parameter."""
|
||||
param = self.parameters.get(name)
|
||||
return param.value if param else None
|
||||
|
||||
def create_heightmap(self, name: str, fill: float = 0.0) -> mcrfpy.HeightMap:
|
||||
"""Create and register a HeightMap."""
|
||||
hmap = mcrfpy.HeightMap(self.MAP_SIZE, fill=fill)
|
||||
self.heightmaps[name] = hmap
|
||||
return hmap
|
||||
|
||||
def get_layer(self, name: str):
|
||||
"""Get a layer by name."""
|
||||
return self.layers.get(name)
|
||||
125
tests/procgen_interactive/core/parameter.py
Normal file
125
tests/procgen_interactive/core/parameter.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
"""Parameter definitions and validation for procedural generation demos.
|
||||
|
||||
Parameters define configurable values that affect generation steps.
|
||||
When a parameter changes, the framework re-runs from the affected step.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Literal, Optional, List, Callable
|
||||
|
||||
|
||||
@dataclass
|
||||
class Parameter:
|
||||
"""Definition for a configurable generation parameter.
|
||||
|
||||
Attributes:
|
||||
name: Internal identifier used in code
|
||||
display: Human-readable label for UI
|
||||
type: Parameter type - 'int', 'float', or 'choice'
|
||||
default: Default value
|
||||
min_val: Minimum value (for numeric types)
|
||||
max_val: Maximum value (for numeric types)
|
||||
step: Increment for +/- buttons (for numeric types)
|
||||
choices: List of valid values (for choice type)
|
||||
affects_step: Which step index to re-run when this parameter changes
|
||||
description: Optional tooltip/help text
|
||||
"""
|
||||
name: str
|
||||
display: str
|
||||
type: Literal['int', 'float', 'choice']
|
||||
default: Any
|
||||
min_val: Optional[float] = None
|
||||
max_val: Optional[float] = None
|
||||
step: float = 1
|
||||
choices: Optional[List[Any]] = None
|
||||
affects_step: int = 0
|
||||
description: str = ""
|
||||
|
||||
# Runtime state
|
||||
_value: Any = field(default=None, repr=False)
|
||||
_on_change: Optional[Callable] = field(default=None, repr=False)
|
||||
|
||||
def __post_init__(self):
|
||||
"""Initialize runtime value to default."""
|
||||
if self._value is None:
|
||||
self._value = self.default
|
||||
|
||||
@property
|
||||
def value(self) -> Any:
|
||||
"""Get current parameter value."""
|
||||
return self._value
|
||||
|
||||
@value.setter
|
||||
def value(self, new_value: Any):
|
||||
"""Set parameter value with validation and change notification."""
|
||||
validated = self._validate(new_value)
|
||||
if validated != self._value:
|
||||
self._value = validated
|
||||
if self._on_change:
|
||||
self._on_change(self)
|
||||
|
||||
def _validate(self, value: Any) -> Any:
|
||||
"""Validate and coerce value to correct type/range."""
|
||||
if self.type == 'int':
|
||||
value = int(value)
|
||||
if self.min_val is not None:
|
||||
value = max(int(self.min_val), value)
|
||||
if self.max_val is not None:
|
||||
value = min(int(self.max_val), value)
|
||||
elif self.type == 'float':
|
||||
value = float(value)
|
||||
if self.min_val is not None:
|
||||
value = max(self.min_val, value)
|
||||
if self.max_val is not None:
|
||||
value = min(self.max_val, value)
|
||||
elif self.type == 'choice':
|
||||
if self.choices and value not in self.choices:
|
||||
value = self.choices[0] if self.choices else self.default
|
||||
return value
|
||||
|
||||
def increment(self):
|
||||
"""Increase value by step amount."""
|
||||
if self.type in ('int', 'float'):
|
||||
self.value = self._value + self.step
|
||||
elif self.type == 'choice' and self.choices:
|
||||
idx = self.choices.index(self._value)
|
||||
if idx < len(self.choices) - 1:
|
||||
self.value = self.choices[idx + 1]
|
||||
|
||||
def decrement(self):
|
||||
"""Decrease value by step amount."""
|
||||
if self.type in ('int', 'float'):
|
||||
self.value = self._value - self.step
|
||||
elif self.type == 'choice' and self.choices:
|
||||
idx = self.choices.index(self._value)
|
||||
if idx > 0:
|
||||
self.value = self.choices[idx - 1]
|
||||
|
||||
def reset(self):
|
||||
"""Reset to default value."""
|
||||
self.value = self.default
|
||||
|
||||
def format_value(self) -> str:
|
||||
"""Format value for display."""
|
||||
if self.type == 'int':
|
||||
return str(int(self._value))
|
||||
elif self.type == 'float':
|
||||
# Show 2 decimal places for floats
|
||||
return f"{self._value:.2f}"
|
||||
else:
|
||||
return str(self._value)
|
||||
|
||||
def get_normalized(self) -> float:
|
||||
"""Get value as 0-1 normalized float (for sliders)."""
|
||||
if self.type in ('int', 'float') and self.min_val is not None and self.max_val is not None:
|
||||
if self.max_val == self.min_val:
|
||||
return 0.5
|
||||
return (self._value - self.min_val) / (self.max_val - self.min_val)
|
||||
return 0.5
|
||||
|
||||
def set_from_normalized(self, normalized: float):
|
||||
"""Set value from 0-1 normalized float (from sliders)."""
|
||||
if self.type in ('int', 'float') and self.min_val is not None and self.max_val is not None:
|
||||
normalized = max(0.0, min(1.0, normalized))
|
||||
raw_value = self.min_val + normalized * (self.max_val - self.min_val)
|
||||
self.value = raw_value
|
||||
159
tests/procgen_interactive/core/viewport.py
Normal file
159
tests/procgen_interactive/core/viewport.py
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
"""Viewport controller for pan and zoom on large maps.
|
||||
|
||||
Provides click-drag pan (middle mouse button) and scroll-wheel zoom
|
||||
for navigating 256x256 or larger maps within a smaller viewport.
|
||||
"""
|
||||
|
||||
import mcrfpy
|
||||
from typing import Optional, Callable
|
||||
|
||||
|
||||
class ViewportController:
|
||||
"""Click-drag pan and scroll-wheel zoom for Grid.
|
||||
|
||||
Features:
|
||||
- Middle-click drag to pan the viewport
|
||||
- Scroll wheel to zoom in/out (0.25x to 4.0x range)
|
||||
- Optional zoom level display callback
|
||||
|
||||
Args:
|
||||
grid: The mcrfpy.Grid to control
|
||||
scene: The scene for keyboard event chaining
|
||||
min_zoom: Minimum zoom level (default 0.25)
|
||||
max_zoom: Maximum zoom level (default 4.0)
|
||||
zoom_factor: Multiplier per scroll tick (default 1.15)
|
||||
on_zoom_change: Optional callback(zoom_level) when zoom changes
|
||||
|
||||
Note:
|
||||
Scroll wheel events are delivered via on_click with MouseButton.SCROLL_UP
|
||||
and MouseButton.SCROLL_DOWN (#231, #232).
|
||||
"""
|
||||
|
||||
def __init__(self, grid, scene,
|
||||
min_zoom: float = 0.25,
|
||||
max_zoom: float = 4.0,
|
||||
zoom_factor: float = 1.15,
|
||||
on_zoom_change: Optional[Callable] = None):
|
||||
self.grid = grid
|
||||
self.scene = scene
|
||||
self.min_zoom = min_zoom
|
||||
self.max_zoom = max_zoom
|
||||
self.zoom_factor = zoom_factor
|
||||
self.on_zoom_change = on_zoom_change
|
||||
|
||||
# Drag state
|
||||
self.dragging = False
|
||||
self.drag_start_center = (0, 0)
|
||||
self.drag_start_mouse = (0, 0)
|
||||
|
||||
# Store original handlers to chain
|
||||
self._original_on_click = getattr(grid, 'on_click', None)
|
||||
self._original_on_move = getattr(grid, 'on_move', None)
|
||||
self._original_on_key = getattr(scene, 'on_key', None)
|
||||
|
||||
# Bind our handlers
|
||||
grid.on_click = self._on_click
|
||||
grid.on_move = self._on_move
|
||||
scene.on_key = self._on_key
|
||||
|
||||
def _on_click(self, pos, button, action):
|
||||
"""Handle drag start/end with middle mouse button, and scroll wheel zoom."""
|
||||
# Middle-click for panning
|
||||
if button == mcrfpy.MouseButton.MIDDLE:
|
||||
if action == mcrfpy.InputState.PRESSED:
|
||||
self.dragging = True
|
||||
self.drag_start_center = (self.grid.center.x, self.grid.center.y)
|
||||
self.drag_start_mouse = (pos.x, pos.y)
|
||||
elif action == mcrfpy.InputState.RELEASED:
|
||||
self.dragging = False
|
||||
return # Don't chain middle-click to other handlers
|
||||
|
||||
# Scroll wheel for zooming (#231, #232 - scroll is now a click event)
|
||||
if button == mcrfpy.MouseButton.SCROLL_UP:
|
||||
self._zoom_in()
|
||||
return
|
||||
elif button == mcrfpy.MouseButton.SCROLL_DOWN:
|
||||
self._zoom_out()
|
||||
return
|
||||
|
||||
# Chain to original handler for other buttons
|
||||
if self._original_on_click:
|
||||
self._original_on_click(pos, button, action)
|
||||
|
||||
def _on_move(self, pos):
|
||||
"""Update center during drag."""
|
||||
if self.dragging:
|
||||
# Calculate mouse movement delta
|
||||
dx = pos.x - self.drag_start_mouse[0]
|
||||
dy = pos.y - self.drag_start_mouse[1]
|
||||
|
||||
# Move center opposite to mouse movement, scaled by zoom
|
||||
# When zoomed in (zoom > 1), movement should be smaller
|
||||
# When zoomed out (zoom < 1), movement should be larger
|
||||
zoom = self.grid.zoom if hasattr(self.grid, 'zoom') else 1.0
|
||||
self.grid.center = (
|
||||
self.drag_start_center[0] - dx / zoom,
|
||||
self.drag_start_center[1] - dy / zoom
|
||||
)
|
||||
else:
|
||||
# Chain to original handler when not dragging
|
||||
if self._original_on_move:
|
||||
self._original_on_move(pos)
|
||||
|
||||
def _on_key(self, key, action):
|
||||
"""Handle keyboard input - chain to original handler.
|
||||
|
||||
Note: Scroll wheel zoom is now handled in _on_click via
|
||||
MouseButton.SCROLL_UP/SCROLL_DOWN (#231, #232).
|
||||
"""
|
||||
# Chain to original handler
|
||||
if self._original_on_key:
|
||||
self._original_on_key(key, action)
|
||||
|
||||
def _zoom_in(self):
|
||||
"""Increase zoom level."""
|
||||
current = self.grid.zoom if hasattr(self.grid, 'zoom') else 1.0
|
||||
new_zoom = min(self.max_zoom, current * self.zoom_factor)
|
||||
self.grid.zoom = new_zoom
|
||||
if self.on_zoom_change:
|
||||
self.on_zoom_change(new_zoom)
|
||||
|
||||
def _zoom_out(self):
|
||||
"""Decrease zoom level."""
|
||||
current = self.grid.zoom if hasattr(self.grid, 'zoom') else 1.0
|
||||
new_zoom = max(self.min_zoom, current / self.zoom_factor)
|
||||
self.grid.zoom = new_zoom
|
||||
if self.on_zoom_change:
|
||||
self.on_zoom_change(new_zoom)
|
||||
|
||||
def reset_view(self):
|
||||
"""Reset to default view (zoom=1, centered)."""
|
||||
self.grid.zoom = 1.0
|
||||
# Center on map center
|
||||
grid_size = self.grid.grid_size
|
||||
cell_w = self.grid.texture.sprite_size[0] if self.grid.texture else 16
|
||||
cell_h = self.grid.texture.sprite_size[1] if self.grid.texture else 16
|
||||
self.grid.center = (grid_size[0] * cell_w / 2, grid_size[1] * cell_h / 2)
|
||||
if self.on_zoom_change:
|
||||
self.on_zoom_change(1.0)
|
||||
|
||||
def center_on_cell(self, cell_x: int, cell_y: int):
|
||||
"""Center the viewport on a specific cell."""
|
||||
cell_w = self.grid.texture.sprite_size[0] if self.grid.texture else 16
|
||||
cell_h = self.grid.texture.sprite_size[1] if self.grid.texture else 16
|
||||
self.grid.center = (
|
||||
(cell_x + 0.5) * cell_w,
|
||||
(cell_y + 0.5) * cell_h
|
||||
)
|
||||
|
||||
@property
|
||||
def zoom(self) -> float:
|
||||
"""Get current zoom level."""
|
||||
return self.grid.zoom if hasattr(self.grid, 'zoom') else 1.0
|
||||
|
||||
@zoom.setter
|
||||
def zoom(self, value: float):
|
||||
"""Set zoom level within bounds."""
|
||||
self.grid.zoom = max(self.min_zoom, min(self.max_zoom, value))
|
||||
if self.on_zoom_change:
|
||||
self.on_zoom_change(self.grid.zoom)
|
||||
353
tests/procgen_interactive/core/widgets.py
Normal file
353
tests/procgen_interactive/core/widgets.py
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
"""UI widgets for interactive parameter controls.
|
||||
|
||||
Provides reusable widget classes for:
|
||||
- Stepper: +/- buttons with value display for integers/seeds
|
||||
- Slider: Draggable track for float ranges
|
||||
- LayerToggle: Checkbox for layer visibility
|
||||
"""
|
||||
|
||||
import mcrfpy
|
||||
from typing import Callable, Optional
|
||||
from .parameter import Parameter
|
||||
|
||||
|
||||
class Stepper:
|
||||
"""Integer/seed stepper with +/- buttons and value display.
|
||||
|
||||
Layout: [-] [ value ] [+]
|
||||
|
||||
Args:
|
||||
parameter: Parameter to control
|
||||
pos: (x, y) position tuple
|
||||
width: Total widget width (default 150)
|
||||
height: Widget height (default 30)
|
||||
on_change: Optional callback when value changes
|
||||
"""
|
||||
|
||||
def __init__(self, parameter: Parameter, pos: tuple,
|
||||
width: int = 150, height: int = 30,
|
||||
on_change: Optional[Callable] = None):
|
||||
self.parameter = parameter
|
||||
self.pos = pos
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.on_change = on_change
|
||||
|
||||
button_width = height # Square buttons
|
||||
value_width = width - 2 * button_width - 4
|
||||
|
||||
# Container frame
|
||||
self.frame = mcrfpy.Frame(
|
||||
pos=pos,
|
||||
size=(width, height),
|
||||
fill_color=mcrfpy.Color(40, 40, 45),
|
||||
outline=1,
|
||||
outline_color=mcrfpy.Color(80, 80, 90)
|
||||
)
|
||||
|
||||
# Minus button
|
||||
self.btn_minus = mcrfpy.Frame(
|
||||
pos=(0, 0),
|
||||
size=(button_width, height),
|
||||
fill_color=mcrfpy.Color(60, 60, 70),
|
||||
outline=1,
|
||||
outline_color=mcrfpy.Color(100, 100, 110)
|
||||
)
|
||||
minus_label = mcrfpy.Caption(
|
||||
text="-",
|
||||
pos=(button_width // 2 - 4, height // 2 - 10),
|
||||
font_size=18,
|
||||
fill_color=mcrfpy.Color(200, 200, 210)
|
||||
)
|
||||
self.btn_minus.children.append(minus_label)
|
||||
self.btn_minus.on_click = self._on_minus_click
|
||||
self.btn_minus.on_enter = lambda pos: self._on_btn_hover(self.btn_minus, True)
|
||||
self.btn_minus.on_exit = lambda pos: self._on_btn_hover(self.btn_minus, False)
|
||||
self.frame.children.append(self.btn_minus)
|
||||
|
||||
# Value display
|
||||
self.value_caption = mcrfpy.Caption(
|
||||
text=parameter.format_value(),
|
||||
pos=(button_width + value_width // 2, height // 2 - 8),
|
||||
font_size=14,
|
||||
fill_color=mcrfpy.Color(220, 220, 230)
|
||||
)
|
||||
self.frame.children.append(self.value_caption)
|
||||
|
||||
# Plus button
|
||||
self.btn_plus = mcrfpy.Frame(
|
||||
pos=(width - button_width, 0),
|
||||
size=(button_width, height),
|
||||
fill_color=mcrfpy.Color(60, 60, 70),
|
||||
outline=1,
|
||||
outline_color=mcrfpy.Color(100, 100, 110)
|
||||
)
|
||||
plus_label = mcrfpy.Caption(
|
||||
text="+",
|
||||
pos=(button_width // 2 - 4, height // 2 - 10),
|
||||
font_size=18,
|
||||
fill_color=mcrfpy.Color(200, 200, 210)
|
||||
)
|
||||
self.btn_plus.children.append(plus_label)
|
||||
self.btn_plus.on_click = self._on_plus_click
|
||||
self.btn_plus.on_enter = lambda pos: self._on_btn_hover(self.btn_plus, True)
|
||||
self.btn_plus.on_exit = lambda pos: self._on_btn_hover(self.btn_plus, False)
|
||||
self.frame.children.append(self.btn_plus)
|
||||
|
||||
# Wire up parameter change notification
|
||||
self.parameter._on_change = self._on_param_change
|
||||
|
||||
def _on_btn_hover(self, btn, entered: bool):
|
||||
"""Handle button hover state."""
|
||||
if entered:
|
||||
btn.fill_color = mcrfpy.Color(80, 80, 95)
|
||||
else:
|
||||
btn.fill_color = mcrfpy.Color(60, 60, 70)
|
||||
|
||||
def _on_minus_click(self, pos, button, action):
|
||||
"""Handle minus button click."""
|
||||
if button == mcrfpy.MouseButton.LEFT and action == mcrfpy.InputState.RELEASED:
|
||||
self.parameter.decrement()
|
||||
|
||||
def _on_plus_click(self, pos, button, action):
|
||||
"""Handle plus button click."""
|
||||
if button == mcrfpy.MouseButton.LEFT and action == mcrfpy.InputState.RELEASED:
|
||||
self.parameter.increment()
|
||||
|
||||
def _on_param_change(self, param):
|
||||
"""Handle parameter value change."""
|
||||
self.value_caption.text = param.format_value()
|
||||
if self.on_change:
|
||||
self.on_change(param)
|
||||
|
||||
def update_display(self):
|
||||
"""Force update of displayed value."""
|
||||
self.value_caption.text = self.parameter.format_value()
|
||||
|
||||
|
||||
class Slider:
|
||||
"""Draggable slider for float parameter ranges.
|
||||
|
||||
Layout: [======o========] value
|
||||
|
||||
Args:
|
||||
parameter: Parameter to control
|
||||
pos: (x, y) position tuple
|
||||
width: Total widget width (default 200)
|
||||
height: Widget height (default 25)
|
||||
on_change: Optional callback when value changes
|
||||
"""
|
||||
|
||||
def __init__(self, parameter: Parameter, pos: tuple,
|
||||
width: int = 200, height: int = 25,
|
||||
on_change: Optional[Callable] = None):
|
||||
self.parameter = parameter
|
||||
self.pos = pos
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.on_change = on_change
|
||||
self.dragging = False
|
||||
|
||||
value_display_width = 50
|
||||
track_width = width - value_display_width - 5
|
||||
|
||||
# Container frame
|
||||
self.frame = mcrfpy.Frame(
|
||||
pos=pos,
|
||||
size=(width, height),
|
||||
fill_color=mcrfpy.Color(40, 40, 45)
|
||||
)
|
||||
|
||||
# Track background
|
||||
track_height = 8
|
||||
track_y = (height - track_height) // 2
|
||||
self.track = mcrfpy.Frame(
|
||||
pos=(0, track_y),
|
||||
size=(track_width, track_height),
|
||||
fill_color=mcrfpy.Color(50, 50, 55),
|
||||
outline=1,
|
||||
outline_color=mcrfpy.Color(80, 80, 90)
|
||||
)
|
||||
self.track.on_click = self._on_track_click
|
||||
self.track.on_move = self._on_track_move
|
||||
self.frame.children.append(self.track)
|
||||
|
||||
# Filled portion (left of handle)
|
||||
self.fill = mcrfpy.Frame(
|
||||
pos=(0, 0),
|
||||
size=(int(track_width * parameter.get_normalized()), track_height),
|
||||
fill_color=mcrfpy.Color(100, 150, 200)
|
||||
)
|
||||
self.track.children.append(self.fill)
|
||||
|
||||
# Handle
|
||||
handle_width = 12
|
||||
handle_pos = int((track_width - handle_width) * parameter.get_normalized())
|
||||
self.handle = mcrfpy.Frame(
|
||||
pos=(handle_pos, -3),
|
||||
size=(handle_width, track_height + 6),
|
||||
fill_color=mcrfpy.Color(180, 180, 200),
|
||||
outline=1,
|
||||
outline_color=mcrfpy.Color(220, 220, 230)
|
||||
)
|
||||
self.track.children.append(self.handle)
|
||||
|
||||
# Value display
|
||||
self.value_caption = mcrfpy.Caption(
|
||||
text=parameter.format_value(),
|
||||
pos=(track_width + 8, height // 2 - 8),
|
||||
font_size=12,
|
||||
fill_color=mcrfpy.Color(180, 180, 190)
|
||||
)
|
||||
self.frame.children.append(self.value_caption)
|
||||
|
||||
# Wire up parameter change notification
|
||||
self.parameter._on_change = self._on_param_change
|
||||
self.track_width = track_width
|
||||
|
||||
def _on_track_click(self, pos, button, action):
|
||||
"""Handle click on track for direct positioning and drag start/end."""
|
||||
if button == mcrfpy.MouseButton.LEFT:
|
||||
if action == mcrfpy.InputState.PRESSED:
|
||||
self.dragging = True
|
||||
self._update_from_position(pos.x)
|
||||
elif action == mcrfpy.InputState.RELEASED:
|
||||
self.dragging = False
|
||||
|
||||
def _on_track_move(self, pos):
|
||||
"""Handle mouse movement for dragging."""
|
||||
if self.dragging:
|
||||
self._update_from_position(pos.x)
|
||||
|
||||
def _update_from_position(self, x: float):
|
||||
"""Update parameter value from mouse x position on track."""
|
||||
normalized = max(0.0, min(1.0, x / self.track_width))
|
||||
self.parameter.set_from_normalized(normalized)
|
||||
|
||||
def _on_param_change(self, param):
|
||||
"""Handle parameter value change - update visual elements."""
|
||||
normalized = param.get_normalized()
|
||||
handle_width = 12
|
||||
handle_pos = int((self.track_width - handle_width) * normalized)
|
||||
self.handle.x = handle_pos
|
||||
self.fill.w = int(self.track_width * normalized)
|
||||
self.value_caption.text = param.format_value()
|
||||
if self.on_change:
|
||||
self.on_change(param)
|
||||
|
||||
def update_display(self):
|
||||
"""Force update of visual elements."""
|
||||
self._on_param_change(self.parameter)
|
||||
|
||||
|
||||
class LayerToggle:
|
||||
"""Checkbox toggle for layer visibility.
|
||||
|
||||
Layout: [x] Layer Name
|
||||
|
||||
Args:
|
||||
name: Display name for the layer
|
||||
layer: The ColorLayer or TileLayer to toggle
|
||||
pos: (x, y) position tuple
|
||||
width: Total widget width (default 150)
|
||||
height: Widget height (default 25)
|
||||
initial: Initial checked state (default True)
|
||||
on_change: Optional callback when toggled
|
||||
"""
|
||||
|
||||
def __init__(self, name: str, layer, pos: tuple,
|
||||
width: int = 150, height: int = 25,
|
||||
initial: bool = True,
|
||||
on_change: Optional[Callable] = None):
|
||||
self.name = name
|
||||
self.layer = layer
|
||||
self.pos = pos
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.checked = initial
|
||||
self.on_change = on_change
|
||||
|
||||
checkbox_size = height - 4
|
||||
|
||||
# Container frame
|
||||
self.frame = mcrfpy.Frame(
|
||||
pos=pos,
|
||||
size=(width, height),
|
||||
fill_color=mcrfpy.Color(40, 40, 45)
|
||||
)
|
||||
self.frame.on_click = self._on_click
|
||||
self.frame.on_enter = self._on_enter
|
||||
self.frame.on_exit = self._on_exit
|
||||
|
||||
# Checkbox box
|
||||
self.checkbox = mcrfpy.Frame(
|
||||
pos=(2, 2),
|
||||
size=(checkbox_size, checkbox_size),
|
||||
fill_color=mcrfpy.Color(60, 60, 70) if not initial else mcrfpy.Color(80, 140, 200),
|
||||
outline=1,
|
||||
outline_color=mcrfpy.Color(120, 120, 130)
|
||||
)
|
||||
self.frame.children.append(self.checkbox)
|
||||
|
||||
# Check mark (X)
|
||||
self.check_mark = mcrfpy.Caption(
|
||||
text="x" if initial else "",
|
||||
pos=(checkbox_size // 2 - 4, checkbox_size // 2 - 8),
|
||||
font_size=14,
|
||||
fill_color=mcrfpy.Color(255, 255, 255)
|
||||
)
|
||||
self.checkbox.children.append(self.check_mark)
|
||||
|
||||
# Label
|
||||
self.label = mcrfpy.Caption(
|
||||
text=name,
|
||||
pos=(checkbox_size + 8, height // 2 - 8),
|
||||
font_size=14,
|
||||
fill_color=mcrfpy.Color(200, 200, 210) if initial else mcrfpy.Color(120, 120, 130)
|
||||
)
|
||||
self.frame.children.append(self.label)
|
||||
|
||||
# Apply initial visibility
|
||||
if layer is not None:
|
||||
layer.visible = initial
|
||||
|
||||
def _on_click(self, pos, button, action):
|
||||
"""Handle click to toggle."""
|
||||
if button == mcrfpy.MouseButton.LEFT and action == mcrfpy.InputState.RELEASED:
|
||||
self.toggle()
|
||||
|
||||
def _on_enter(self, pos):
|
||||
"""Handle mouse enter - highlight."""
|
||||
self.frame.fill_color = mcrfpy.Color(50, 50, 60)
|
||||
|
||||
def _on_exit(self, pos):
|
||||
"""Handle mouse exit - unhighlight."""
|
||||
self.frame.fill_color = mcrfpy.Color(40, 40, 45)
|
||||
|
||||
def toggle(self):
|
||||
"""Toggle the checkbox state."""
|
||||
self.checked = not self.checked
|
||||
self._update_visual()
|
||||
if self.layer is not None:
|
||||
self.layer.visible = self.checked
|
||||
if self.on_change:
|
||||
self.on_change(self.name, self.checked)
|
||||
|
||||
def set_checked(self, checked: bool):
|
||||
"""Set checkbox state directly."""
|
||||
if checked != self.checked:
|
||||
self.checked = checked
|
||||
self._update_visual()
|
||||
if self.layer is not None:
|
||||
self.layer.visible = checked
|
||||
|
||||
def _update_visual(self):
|
||||
"""Update visual elements based on checked state."""
|
||||
if self.checked:
|
||||
self.checkbox.fill_color = mcrfpy.Color(80, 140, 200)
|
||||
self.check_mark.text = "x"
|
||||
self.label.fill_color = mcrfpy.Color(200, 200, 210)
|
||||
else:
|
||||
self.checkbox.fill_color = mcrfpy.Color(60, 60, 70)
|
||||
self.check_mark.text = ""
|
||||
self.label.fill_color = mcrfpy.Color(120, 120, 130)
|
||||
Loading…
Add table
Add a link
Reference in a new issue