Source code for simvx.editor.panels.tilemap_editor

"""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
# ============================================================================


[docs] class TileMapTool(Enum): """Available tilemap editing tools.""" PAINT = auto() ERASE = auto() FILL = auto() PICK = auto()
# ============================================================================ # 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.