Test suite modernization

This commit is contained in:
John McCardle 2026-02-09 08:15:18 -05:00
commit 52fdfd0347
141 changed files with 9947 additions and 4665 deletions

View 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',
]

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

View 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

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

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