"""TileMap Editor Panel -- Paint, erase, fill, and pick tiles on a TileMapLayer.
Provides an interactive toolbar with four tools (Paint, Erase, Fill, Pick) and
a brush size selector. Integrates with the 2D viewport to apply tile operations
at the mouse position, with full undo support via CallableCommand.
The panel listens for a TileMap selection in EditorState and delegates
viewport mouse events through a coordinate-conversion callback.
"""
from __future__ import annotations
import logging
from collections import deque
from enum import Enum, auto
from typing import TYPE_CHECKING
from simvx.core import (
CallableCommand,
Control,
Signal,
TileMap,
Vec2,
)
if TYPE_CHECKING:
from simvx.editor.state import EditorState
log = logging.getLogger(__name__)
__all__ = ["TileMapEditorPanel", "TileMapTool"]
# ============================================================================
# Tool enum
# ============================================================================
# ============================================================================
# Colours / Layout
# ============================================================================
_BG = (0.13, 0.13, 0.13, 1.0)
_TOOLBAR_BG = (0.10, 0.10, 0.10, 1.0)
_BTN_BG = (0.20, 0.20, 0.22, 1.0)
_BTN_HOVER = (0.28, 0.28, 0.32, 1.0)
_BTN_ACTIVE = (0.25, 0.50, 0.85, 1.0)
_TEXT = (0.85, 0.85, 0.85, 1.0)
_TEXT_DIM = (0.55, 0.55, 0.55, 1.0)
_TILE_PREVIEW_BG = (0.18, 0.18, 0.20, 1.0)
_TILE_PREVIEW_BORDER = (0.40, 0.65, 0.90, 0.80)
_BRUSH_INDICATOR = (1.0, 1.0, 1.0, 0.30)
_SEPARATOR = (0.25, 0.25, 0.25, 1.0)
_TOOLBAR_HEIGHT = 32.0
_BTN_W = 60.0
_BTN_H = 24.0
_BTN_PAD = 4.0
_PREVIEW_SIZE = 48.0
_PADDING = 6.0
_FONT = 11.0 / 14.0
# Tool labels and shortcut hints
_TOOL_LABELS = {
TileMapTool.PAINT: "Paint",
TileMapTool.ERASE: "Erase",
TileMapTool.FILL: "Fill",
TileMapTool.PICK: "Pick",
}
_TOOL_ICONS = {
TileMapTool.PAINT: "P",
TileMapTool.ERASE: "E",
TileMapTool.FILL: "F",
TileMapTool.PICK: "K",
}
# ============================================================================
# TileMapEditorPanel
# ============================================================================
[docs]
class TileMapEditorPanel(Control):
"""Tilemap painting toolbar and tile selector.
Provides Paint, Erase, Fill, and Pick tools with configurable brush size.
Applies operations to the active TileMap's first layer via the undo system.
Parameters
----------
editor_state:
The central ``EditorState`` for undo, selection, and scene access.
"""
def __init__(self, editor_state: EditorState, **kwargs):
super().__init__(**kwargs)
self.state = editor_state
# Tool state
self._selected_tool: TileMapTool = TileMapTool.PAINT
self._current_tile_id: int = 0
self._brush_size: int = 1
self._active_layer: int = 0
# Interaction state
self._is_painting: bool = False
self._painted_cells: dict[tuple[int, int], int] = {} # cells modified in current stroke
self._stroke_old_values: dict[tuple[int, int], int] = {} # pre-stroke values for undo
# Button hover tracking
self._hover_tool: TileMapTool | None = None
self._hover_brush: int | None = None
# Signals
self.tool_changed = Signal()
self.tile_changed = Signal()
# ======================================================================
# Properties
# ======================================================================
@property
def selected_tool(self) -> TileMapTool:
return self._selected_tool
@selected_tool.setter
def selected_tool(self, value: TileMapTool):
if self._selected_tool != value:
self._selected_tool = value
self.tool_changed.emit()
@property
def current_tile_id(self) -> int:
return self._current_tile_id
@current_tile_id.setter
def current_tile_id(self, value: int):
if self._current_tile_id != value:
self._current_tile_id = value
self.tile_changed.emit()
@property
def brush_size(self) -> int:
return self._brush_size
@brush_size.setter
def brush_size(self, value: int):
self._brush_size = max(1, min(5, value))
# ======================================================================
# TileMap resolution
# ======================================================================
def _get_tilemap(self) -> TileMap | None:
"""Return the selected TileMap, or the first TileMap in the scene."""
sel = self.state.selection.primary
if isinstance(sel, TileMap):
return sel
# Walk scene to find first TileMap
root = self.state.edited_scene.root if self.state.edited_scene else None
if root is None:
return None
return self._find_tilemap(root)
@staticmethod
def _find_tilemap(node) -> TileMap | None:
"""Depth-first search for a TileMap in the subtree."""
if isinstance(node, TileMap):
return node
for child in node.children:
result = TileMapEditorPanel._find_tilemap(child)
if result is not None:
return result
return None
# ======================================================================
# Tile operations
# ======================================================================
[docs]
def paint_at(self, grid_x: int, grid_y: int):
"""Paint the current tile at grid position, respecting brush size."""
tilemap = self._get_tilemap()
if tilemap is None:
return
cells = self._brush_cells(grid_x, grid_y)
old_values = {}
new_values = {}
for cx, cy in cells:
old_val = tilemap.get_cell(self._active_layer, cx, cy)
if old_val != self._current_tile_id:
old_values[(cx, cy)] = old_val
new_values[(cx, cy)] = self._current_tile_id
tilemap.set_cell(self._active_layer, cx, cy, self._current_tile_id)
if old_values:
self._record_stroke(tilemap, old_values, new_values, "Paint tiles")
[docs]
def erase_at(self, grid_x: int, grid_y: int):
"""Erase tiles at grid position, respecting brush size."""
tilemap = self._get_tilemap()
if tilemap is None:
return
cells = self._brush_cells(grid_x, grid_y)
old_values = {}
new_values = {}
for cx, cy in cells:
old_val = tilemap.get_cell(self._active_layer, cx, cy)
if old_val != -1:
old_values[(cx, cy)] = old_val
new_values[(cx, cy)] = -1
tilemap.erase_cell(self._active_layer, cx, cy)
if old_values:
self._record_stroke(tilemap, old_values, new_values, "Erase tiles")
[docs]
def fill_at(self, grid_x: int, grid_y: int):
"""Flood-fill from the given grid position with the current tile."""
tilemap = self._get_tilemap()
if tilemap is None:
return
layer = self._active_layer
target_id = tilemap.get_cell(layer, grid_x, grid_y)
fill_id = self._current_tile_id
if target_id == fill_id:
return # no-op: already the same tile
old_values: dict[tuple[int, int], int] = {}
new_values: dict[tuple[int, int], int] = {}
visited: set[tuple[int, int]] = set()
queue: deque[tuple[int, int]] = deque()
queue.append((grid_x, grid_y))
# Limit fill to prevent runaway on infinite empty maps
max_cells = 10000
while queue and len(visited) < max_cells:
x, y = queue.popleft()
if (x, y) in visited:
continue
visited.add((x, y))
cell_val = tilemap.get_cell(layer, x, y)
if cell_val != target_id:
continue
old_values[(x, y)] = cell_val
new_values[(x, y)] = fill_id
tilemap.set_cell(layer, x, y, fill_id)
for dx, dy in [(0, -1), (1, 0), (0, 1), (-1, 0)]:
nx, ny = x + dx, y + dy
if (nx, ny) not in visited:
queue.append((nx, ny))
if old_values:
self._record_stroke(tilemap, old_values, new_values, "Fill tiles")
[docs]
def pick_at(self, grid_x: int, grid_y: int):
"""Pick the tile ID at the given grid position (eyedropper)."""
tilemap = self._get_tilemap()
if tilemap is None:
return
tile_id = tilemap.get_cell(self._active_layer, grid_x, grid_y)
if tile_id >= 0:
self.current_tile_id = tile_id
# Auto-switch back to paint after picking
self.selected_tool = TileMapTool.PAINT
# ======================================================================
# Stroke painting (click + drag)
# ======================================================================
[docs]
def begin_stroke(self, grid_x: int, grid_y: int):
"""Begin a paint/erase stroke at the given grid position."""
self._is_painting = True
self._painted_cells.clear()
self._stroke_old_values.clear()
self._apply_stroke_at(grid_x, grid_y)
[docs]
def continue_stroke(self, grid_x: int, grid_y: int):
"""Continue the current stroke to a new grid position."""
if not self._is_painting:
return
self._apply_stroke_at(grid_x, grid_y)
[docs]
def end_stroke(self):
"""End the current stroke and push it as a single undo command."""
if not self._is_painting:
return
self._is_painting = False
tilemap = self._get_tilemap()
if tilemap is None or not self._stroke_old_values:
return
old_vals = dict(self._stroke_old_values)
new_vals = dict(self._painted_cells)
desc = "Paint stroke" if self._selected_tool == TileMapTool.PAINT else "Erase stroke"
self._record_stroke(tilemap, old_vals, new_vals, desc)
self._painted_cells.clear()
self._stroke_old_values.clear()
def _apply_stroke_at(self, grid_x: int, grid_y: int):
"""Apply the current tool at a grid position during a stroke (no undo push)."""
tilemap = self._get_tilemap()
if tilemap is None:
return
layer = self._active_layer
is_erase = self._selected_tool == TileMapTool.ERASE
new_id = -1 if is_erase else self._current_tile_id
for cx, cy in self._brush_cells(grid_x, grid_y):
if (cx, cy) in self._painted_cells:
continue # already painted this stroke
old_val = tilemap.get_cell(layer, cx, cy)
if old_val == new_id:
continue
self._stroke_old_values[(cx, cy)] = old_val
self._painted_cells[(cx, cy)] = new_id
if is_erase:
tilemap.erase_cell(layer, cx, cy)
else:
tilemap.set_cell(layer, cx, cy, new_id)
# ======================================================================
# Viewport interaction dispatch
# ======================================================================
[docs]
def handle_viewport_click(self, grid_x: int, grid_y: int):
"""Called by the 2D viewport when the user clicks while tilemap mode is active."""
tool = self._selected_tool
if tool == TileMapTool.PAINT:
self.begin_stroke(grid_x, grid_y)
elif tool == TileMapTool.ERASE:
self.begin_stroke(grid_x, grid_y)
elif tool == TileMapTool.FILL:
self.fill_at(grid_x, grid_y)
elif tool == TileMapTool.PICK:
self.pick_at(grid_x, grid_y)
[docs]
def handle_viewport_drag(self, grid_x: int, grid_y: int):
"""Called by the 2D viewport during mouse drag in tilemap mode."""
if self._selected_tool in (TileMapTool.PAINT, TileMapTool.ERASE):
self.continue_stroke(grid_x, grid_y)
[docs]
def handle_viewport_release(self):
"""Called by the 2D viewport when mouse is released in tilemap mode."""
self.end_stroke()
# ======================================================================
# Brush geometry
# ======================================================================
def _brush_cells(self, cx: int, cy: int) -> list[tuple[int, int]]:
"""Return all grid cells covered by the brush centred at (cx, cy)."""
if self._brush_size <= 1:
return [(cx, cy)]
half = self._brush_size // 2
cells = []
for dy in range(-half, half + 1):
for dx in range(-half, half + 1):
cells.append((cx + dx, cy + dy))
return cells
# ======================================================================
# Undo integration
# ======================================================================
def _record_stroke(
self,
tilemap: TileMap,
old_values: dict[tuple[int, int], int],
new_values: dict[tuple[int, int], int],
description: str,
):
"""Push a single undoable command for a set of cell changes."""
layer = self._active_layer
tm = tilemap # capture reference
def do_fn():
for (x, y), tid in new_values.items():
if tid == -1:
tm.erase_cell(layer, x, y)
else:
tm.set_cell(layer, x, y, tid)
def undo_fn():
for (x, y), tid in old_values.items():
if tid == -1:
tm.erase_cell(layer, x, y)
else:
tm.set_cell(layer, x, y, tid)
cmd = CallableCommand(do_fn, undo_fn, description)
# Don't re-execute — cells are already set
self.state.undo_stack._undo.append(cmd)
self.state.undo_stack._redo.clear()
self.state.undo_stack.changed.emit()
self.state.modified = True
# ======================================================================
# Input handling
# ======================================================================
def _on_gui_input(self, event):
"""Handle clicks on the toolbar buttons."""
if event.button == 1 and event.pressed:
mx, my = event.position.x, event.position.y
hit = self._hit_test(mx, my)
if hit is not None:
if isinstance(hit, TileMapTool):
self.selected_tool = hit
elif isinstance(hit, int):
self.brush_size = hit
# Hover tracking for button highlight
if not event.key and not event.char and event.button == 0:
mx, my = event.position.x, event.position.y
tool_hit = self._hit_test(mx, my)
self._hover_tool = tool_hit if isinstance(tool_hit, TileMapTool) else None
self._hover_brush = tool_hit if isinstance(tool_hit, int) else None
def _hit_test(self, mx: float, my: float) -> TileMapTool | int | None:
"""Return which toolbar element the point is over."""
vx, vy, vw, vh = self.get_global_rect()
# Tool buttons row
y0 = vy + _PADDING
x0 = vx + _PADDING
for i, tool in enumerate(TileMapTool):
bx = x0 + i * (_BTN_W + _BTN_PAD)
if bx <= mx <= bx + _BTN_W and y0 <= my <= y0 + _BTN_H:
return tool
# Brush size buttons row
y1 = y0 + _BTN_H + _PADDING
for sz in range(1, 6):
bx = x0 + (sz - 1) * (28 + _BTN_PAD)
if bx <= mx <= bx + 28 and y1 <= my <= y1 + _BTN_H:
return sz
return None
# ======================================================================
# Drawing
# ======================================================================
[docs]
def draw(self, renderer):
vx, vy, vw, vh = self.get_global_rect()
renderer.push_clip(vx, vy, vw, vh)
# Background
renderer.draw_filled_rect(vx, vy, vw, vh, _BG)
# Toolbar background
renderer.draw_filled_rect(vx, vy, vw, _TOOLBAR_HEIGHT, _TOOLBAR_BG)
# Tool buttons
y0 = vy + _PADDING
x0 = vx + _PADDING
for i, tool in enumerate(TileMapTool):
bx = x0 + i * (_BTN_W + _BTN_PAD)
is_active = tool == self._selected_tool
is_hover = tool == self._hover_tool
if is_active:
bg = _BTN_ACTIVE
elif is_hover:
bg = _BTN_HOVER
else:
bg = _BTN_BG
renderer.draw_filled_rect(bx, y0, _BTN_W, _BTN_H, bg)
label = _TOOL_LABELS[tool]
renderer.draw_text_coloured(label, bx + 4, y0 + 5, _FONT, _TEXT)
# Separator
sep_y = y0 + _BTN_H + _PADDING - 1
renderer.draw_line_coloured(vx + _PADDING, sep_y, vx + vw - _PADDING, sep_y, _SEPARATOR)
# Brush size row
y1 = sep_y + _PADDING
renderer.draw_text_coloured("Brush:", x0, y1 + 5, _FONT, _TEXT_DIM)
bx0 = x0 + 50
for sz in range(1, 6):
bx = bx0 + (sz - 1) * (28 + _BTN_PAD)
is_active = sz == self._brush_size
is_hover = sz == self._hover_brush
if is_active:
bg = _BTN_ACTIVE
elif is_hover:
bg = _BTN_HOVER
else:
bg = _BTN_BG
renderer.draw_filled_rect(bx, y1, 28, _BTN_H, bg)
renderer.draw_text_coloured(str(sz), bx + 10, y1 + 5, _FONT, _TEXT)
# Tile preview section
y2 = y1 + _BTN_H + _PADDING * 2
renderer.draw_text_coloured("Selected Tile:", x0, y2, _FONT, _TEXT_DIM)
y3 = y2 + 16
renderer.draw_filled_rect(x0, y3, _PREVIEW_SIZE, _PREVIEW_SIZE, _TILE_PREVIEW_BG)
renderer.draw_rect_coloured(x0, y3, _PREVIEW_SIZE, _PREVIEW_SIZE, _TILE_PREVIEW_BORDER)
# Tile ID text inside preview
tid_text = str(self._current_tile_id) if self._current_tile_id >= 0 else "None"
renderer.draw_text_coloured(tid_text, x0 + 4, y3 + _PREVIEW_SIZE / 2 - 6, _FONT, _TEXT)
# Brush size indicator
bx_info = x0 + _PREVIEW_SIZE + _PADDING * 2
renderer.draw_text_coloured(f"Brush: {self._brush_size}x{self._brush_size}", bx_info, y3 + 4, _FONT, _TEXT)
tool_text = f"Tool: {_TOOL_LABELS[self._selected_tool]}"
renderer.draw_text_coloured(tool_text, bx_info, y3 + 20, _FONT, _TEXT_DIM)
# TileMap status
tilemap = self._get_tilemap()
if tilemap is not None:
status = f"TileMap: {tilemap.name}"
renderer.draw_text_coloured(status, bx_info, y3 + 36, _FONT, _TEXT_DIM)
else:
renderer.draw_text_coloured("No TileMap selected", bx_info, y3 + 36, _FONT, (0.8, 0.3, 0.3, 1.0))
renderer.pop_clip()
# ======================================================================
# Process
# ======================================================================
[docs]
def process(self, dt: float):
pass # Event-driven, no per-frame logic needed.