ajhahn.de
← the-way-out
Python 1534 lines
"""In-game level editor.

A pygame-native palette → canvas editor that writes ``.txt`` files in
the same tokenised format the runtime loads. Output goes to
``~/.the-way-out/custom_levels/<name>.txt`` and is picked up by
:mod:`level_catalog` automatically, so a fresh level appears in the
level menu the next time it's opened.

Layout (anchored to the screen size, scales with it):

* **Canvas** on the left: scrolling grid, the level being authored.
* **Palette** on the right: a narrow strip of tile thumbnails grouped
  by category (terrain, special, hazard, enemy, prop). Click to select.
  A preview panel below the grid shows the selected tile at a large
  size; its < > buttons (or the mouse wheel) cycle the variant.
* **Toolbar** at the bottom: file name (click to rename), grid size,
  Save / Test / Clear buttons.

Controls (also displayed in the toolbar/hint):

* LMB / RMB:           place selected tile / clear to floor
* Shift + LMB/RMB drag: box-fill / box-erase a rectangle
* Q (or Pick):         eyedropper — next canvas click adopts that cell
* Border:              re-wall the outer ring
* Mouse wheel:         cycle variant on the selected tile
* WASD / arrows:       pan camera
* Esc:                 back to main menu
* F5 (or Test):        save + launch the level
* Ctrl+S (or Save):    save only

The editor is intentionally one self-contained file. It reuses
``tiles.REGISTRY`` for what tiles exist and ``tileset.sprite`` /
``interactables`` images for the canvas previews — so a new tile added
to the registry shows up automatically in the palette.
"""

import json
import re
from pathlib import Path

import pygame

import level_catalog
import theme
import tileset
from interactables import Gate, KeyItem, Lever, PressurePlate, Spikes
from settings import TILE_SIZE
from static_objects import TileTextures
from tiles import PALETTE_CATEGORIES, REGISTRY, chars_for

# Used both as a directory and as the legal-filename charset.
SAFE_NAME = re.compile(r"[^a-zA-Z0-9_\-]+")
MAX_NAME = 32


def sanitize(name):
    """Squash any non-portable characters out of a filename stem,
    collapse runs of underscores, trim, and clamp length so the user
    can paste anything weird and still get something writable."""
    cleaned = SAFE_NAME.sub("_", name).strip("_")
    cleaned = re.sub(r"_+", "_", cleaned)[:MAX_NAME]
    return cleaned or "untitled"


class LevelEditor:
    """The editor surface + state. One instance lives for the whole
    session; ``new_level`` / ``open_level`` reset the grid."""

    # Palette layout. Cells are square thumbnails; the category headers
    # sit between them. CELL / CELL_GAP are sized so six columns fill
    # the narrow palette exactly: 6*CELL + 5*CELL_GAP == PALETTE_W - 2*PAD.
    # PALETTE_W is the expanded panel content width; PALETTE_GRIP_W is
    # the always-visible collapsed rail. Total panel width when the
    # drawer is expanded = PALETTE_GRIP_W + PALETTE_W.
    PALETTE_W = 330
    PALETTE_GRIP_W = 44
    PALETTE_PAD = 18
    CELL = 44
    CELL_GAP = 6

    # Hover-drawer animation seconds (linear). 0.14 ≈ 8 frames at 60 fps.
    PALETTE_ANIM_TIME = 0.14

    # Selected-tile preview panel: a large sprite plus the < > buttons
    # that step its variant.
    PREVIEW_SIZE = 64
    VARIANT_BTN = 30

    # Toolbar
    TOOLBAR_H = 110

    # Load-picker modal: one list row's height.
    PICKER_ROW_H = 56

    # Default grid for a fresh level (with auto-walled border)
    DEFAULT_COLS = 30
    DEFAULT_ROWS = 20

    # --- lifecycle -----------------------------------------------------

    def __init__(self, width, height):
        self.width = width
        self.height = height

        # Fonts
        self.font = theme.font(36)
        self.label_font = theme.font(22)
        self.small_font = theme.font(18)
        self.hint_font = theme.font(20)
        self.head_font = theme.font(26)

        # Geometry — canvas fills everything left of the collapsed
        # palette rail and above the toolbar. The rail is the
        # always-visible portion of the drawer; the rest of the panel
        # slides in as an overlay over the canvas on hover, so the
        # canvas extent never reflows. palette_rect / _grip_rect are
        # placeholders here and get their real coordinates from
        # _layout_palette() once self._palette_anim is initialised.
        self.canvas_rect = pygame.Rect(
            0, 0,
            width - self.PALETTE_GRIP_W,
            height - self.TOOLBAR_H)
        self.toolbar_rect = pygame.Rect(
            0, height - self.TOOLBAR_H, width, self.TOOLBAR_H)
        self.palette_rect = pygame.Rect(0, 0, 0, 0)
        self._grip_rect = pygame.Rect(0, 0, 0, 0)

        # State that resets per-level
        self.grid = []
        self.cols = 0
        self.rows = 0
        self.name = "my_level"
        self.cam_x = 0.0
        self.cam_y = 0.0
        self.message = ""
        self.message_timer = 0.0

        # Palette / selection
        self.selected_char = 'W'
        self.selected_variant = 1
        self._palette_rects = []        # list of (pygame.Rect, char)
        self._variant_btn_rects = {}    # 'prev'/'next' -> pygame.Rect
        self._tile_thumb_cache = {}     # (char, variant, size) -> Surface

        # Hover-drawer state: 0.0 = collapsed rail, 1.0 = fully expanded.
        # _layout_palette() projects this onto _grip_rect / palette_rect
        # so the existing _build_palette_layout() math (B25) keeps
        # working unchanged regardless of where the drawer sits.
        self._palette_anim = 0.0
        self._palette_label_surf = None  # cached vertical letter stack
        self._palette_label_col = None   # last colour the cache was built for

        # Toolbar / buttons
        self._toolbar_rects = {}        # name -> pygame.Rect
        self._name_rect = pygame.Rect(0, 0, 0, 0)
        self.editing_name = False

        # Drag-painting: while LMB or RMB is held, every frame where
        # the mouse is on a new cell paints/erases.
        self._mouse_buttons = [False, False, False]  # left, middle, right
        self._last_painted_cell = None

        # 'paint' (default) or 'pick' (eyedropper, toggled with Q).
        self.tool = 'paint'
        # Shift+drag box fill/erase: anchor cell while the drag is live.
        self._box_start = None
        self._box_erase = False

        # ``request_test`` flips True when the user hits F5/Test; main
        # reads & clears it to switch into the game state.
        self.request_test = False
        self.test_level_id = None

        # Load picker (modal overlay listing saved custom maps). Closed
        # by default; opened from the toolbar's Load button.
        self.picker_open = False
        self.picker_entries = []
        self.picker_scroll = 0

        # Theme picker (modal overlay listing the floor/wall presets in
        # tileset.THEMES). ``theme`` is the current map's theme id; it is
        # written to the map's <name>.json sidecar on save and restored
        # from that sidecar on open. new_level / open_level (re)set it.
        self.theme = tileset.DEFAULT_THEME
        self.theme_picker_open = False

        self.new_level()
        self._layout_palette()
        self._build_toolbar_layout()

    def new_level(self, cols=None, rows=None, name=None):
        """Reset the grid to the requested size (or default) with a
        wall border and a single 'P' near the top-left so the level is
        legal-ish out of the box."""
        if cols is not None:
            self.cols = cols
        else:
            self.cols = self.DEFAULT_COLS
        if rows is not None:
            self.rows = rows
        else:
            self.rows = self.DEFAULT_ROWS
        if name is not None:
            self.name = sanitize(name)
        self.grid = [['.' for _ in range(self.cols)]
                     for _ in range(self.rows)]
        self._wall_border()
        # Helpful defaults so a smashed save still loads.
        if self.rows > 2 and self.cols > 2:
            self.grid[1][1] = 'P'
            self.grid[self.rows - 2][self.cols - 2] = 'X'
        self.theme = tileset.DEFAULT_THEME
        self.cam_x = 0.0
        self.cam_y = 0.0

    def open_level(self, entry):
        """Load an existing level file into the editor. Used to tweak
        a built-in level or continue work on a custom one."""
        try:
            with open(entry.file) as f:
                lines = [ln.rstrip('\n') for ln in f if ln.strip()]
        except (FileNotFoundError, OSError) as e:
            self._flash(f"Could not open {entry.file}: {e}")
            return

        grid = []
        for line in lines:
            # Reuse the runtime parser so dense and tokenised rows
            # round-trip identically.
            if ' ' in line:
                grid.append(line.split())
            else:
                grid.append(list(line))
        if not grid:
            self._flash("Empty level — starting blank instead")
            self.new_level()
            return

        cols = max(len(r) for r in grid)
        for row in grid:
            row.extend('W' * (cols - len(row)))
        self.rows = len(grid)
        self.cols = cols
        self.grid = grid
        # Default name = file stem; user can rename before saving.
        self.name = sanitize(Path(entry.file).stem)
        # Restore the map's theme from its sidecar (built-ins / un-themed
        # maps have none and resolve to the default).
        self.theme = level_catalog.read_custom_theme(entry.file)
        self.cam_x = 0.0
        self.cam_y = 0.0

    def reset_pointer_state(self):
        """Drop any in-flight drag / box / held-button state.

        The editor instance lives for the whole session, so a Shift-drag
        box that was interrupted mid-gesture (Esc to menu, Test, window
        focus loss) would otherwise keep ``_box_start`` / a stuck
        ``_mouse_buttons`` entry set and commit a spurious box-fill on
        the next visit. ``main`` calls this whenever the editor (re)gains
        or loses control. Snapping the drawer closed at the same time
        means the user always lands on the collapsed rail when they
        come back, matching the "default smaller, hover to expand"
        intent of B27."""
        self._box_start = None
        self._box_erase = False
        self._mouse_buttons = [False, False, False]
        self._last_painted_cell = None
        self.picker_open = False
        self.theme_picker_open = False
        self._palette_anim = 0.0
        self._layout_palette()

    # --- palette geometry (drawer) ------------------------------------

    def _layout_palette(self):
        """Place ``_grip_rect`` and ``palette_rect`` from ``_palette_anim``.

        ``canvas_rect`` is permanent — set once in ``__init__`` — and
        never reflows. The drawer slides in from the right: at anim=0
        ``palette_rect`` sits off-screen to the right of the grip, so
        no palette tile click can land while collapsed; at anim=1 the
        palette is flush with the screen's right edge, exactly where
        the fixed 330 px panel used to be."""
        panel_x = round(
            (self.width - self.PALETTE_GRIP_W)
            - self.PALETTE_W * self._palette_anim)
        h = self.height - self.TOOLBAR_H
        self._grip_rect = pygame.Rect(
            panel_x, 0, self.PALETTE_GRIP_W, h)
        self.palette_rect = pygame.Rect(
            panel_x + self.PALETTE_GRIP_W, 0, self.PALETTE_W, h)
        self._build_palette_layout()

    # --- frame ---------------------------------------------------------

    def update(self, dt):
        # Decay the toast message.
        if self.message_timer > 0:
            self.message_timer = max(0.0, self.message_timer - dt)
            if self.message_timer == 0:
                self.message = ""

        # Drawer hover-state animation. The drawer expands while the
        # cursor is over the rail or the panel body and contracts when
        # it leaves. Gated on no-buttons-held and no-active-box-drag
        # so a paint stroke aimed at the right edge of the canvas
        # doesn't open the drawer mid-gesture (which would then block
        # painting via the _screen_to_cell overlay guard).
        mx, my = pygame.mouse.get_pos()
        hot = (
            mx >= self._grip_rect.left
            and my < self.height - self.TOOLBAR_H
            and not any(self._mouse_buttons)
            and self._box_start is None
            and not self.editing_name
        )
        target = 1.0 if hot else 0.0
        if self._palette_anim != target:
            step = dt / self.PALETTE_ANIM_TIME
            if self._palette_anim < target:
                self._palette_anim = min(
                    target, self._palette_anim + step)
            else:
                self._palette_anim = max(
                    target, self._palette_anim - step)
            new_x = round(
                (self.width - self.PALETTE_GRIP_W)
                - self.PALETTE_W * self._palette_anim)
            if new_x != self._grip_rect.left:
                self._layout_palette()

        # Camera pan on held keys. Read pressed-state every frame so it
        # feels continuous (event-driven would tick once per repeat).
        pan_speed = TILE_SIZE * 12 * dt  # ~12 tiles/sec
        keys = pygame.key.get_pressed()
        if (not self.editing_name and not self.picker_open
                and not self.theme_picker_open):
            if keys[pygame.K_a] or keys[pygame.K_LEFT]:
                self.cam_x -= pan_speed
            if keys[pygame.K_d] or keys[pygame.K_RIGHT]:
                self.cam_x += pan_speed
            if keys[pygame.K_w] or keys[pygame.K_UP]:
                self.cam_y -= pan_speed
            if keys[pygame.K_s] or keys[pygame.K_DOWN]:
                self.cam_y += pan_speed
        self._clamp_camera()

        # Drag-paint: if a button is still held, keep applying while
        # the cursor crosses cells. Suppressed during a Shift box-drag
        # and while the eyedropper is active (those are click-only).
        if ((self._mouse_buttons[0] or self._mouse_buttons[2])
                and self._box_start is None and self.tool == 'paint'):
            mx, my = pygame.mouse.get_pos()
            if self.canvas_rect.collidepoint(mx, my):
                cell = self._screen_to_cell(mx, my)
                if cell is not None and cell != self._last_painted_cell:
                    self._paint_cell(cell, erase=self._mouse_buttons[2])

    def _clamp_camera(self):
        max_x = max(0, self.cols * TILE_SIZE - self.canvas_rect.width)
        max_y = max(0, self.rows * TILE_SIZE - self.canvas_rect.height)
        self.cam_x = max(0, min(self.cam_x, max_x))
        self.cam_y = max(0, min(self.cam_y, max_y))

    # --- input ---------------------------------------------------------

    def handle_input(self, event):
        """Return ``'back'`` to leave the editor, ``'test'`` to enter
        game state with the currently-saved level, or ``None``."""
        if self.picker_open:
            return self._handle_picker_input(event)
        if self.theme_picker_open:
            return self._handle_theme_picker_input(event)

        if event.type == pygame.KEYDOWN:
            if self.editing_name:
                if event.key == pygame.K_RETURN:
                    self.editing_name = False
                    self.name = sanitize(self.name) or "untitled"
                elif event.key == pygame.K_ESCAPE:
                    # Cancel the rename. Re-sanitize so backspacing the
                    # name down to empty can't leave self.name == "".
                    self.editing_name = False
                    self.name = sanitize(self.name)
                elif event.key == pygame.K_BACKSPACE:
                    self.name = self.name[:-1]
                else:
                    ch = event.unicode
                    if ch and (ch.isalnum() or ch in "_-") and len(self.name) < MAX_NAME:
                        self.name += ch
                return None

            if event.key == pygame.K_ESCAPE:
                return 'back'
            if event.key == pygame.K_F5:
                return self._do_test()
            if event.key == pygame.K_s and (pygame.key.get_mods()
                                            & pygame.KMOD_CTRL):
                self._do_save()
                return None
            if event.key == pygame.K_q and not (
                    pygame.key.get_mods() & pygame.KMOD_META):
                self.tool = 'pick' if self.tool == 'paint' else 'paint'
                return None

        elif event.type == pygame.MOUSEBUTTONDOWN:
            mx, my = event.pos
            shift = pygame.key.get_mods() & pygame.KMOD_SHIFT
            if event.button == 1:
                self._mouse_buttons[0] = True
                if shift and self.canvas_rect.collidepoint(mx, my):
                    cell = self._screen_to_cell(mx, my)
                    if cell is not None:
                        self._box_start = cell
                        self._box_erase = False
                        return None
                result = self._click_left(mx, my)
                if result is not None:
                    return result
            elif event.button == 3:
                self._mouse_buttons[2] = True
                if shift and self.canvas_rect.collidepoint(mx, my):
                    cell = self._screen_to_cell(mx, my)
                    if cell is not None:
                        self._box_start = cell
                        self._box_erase = True
                        return None
                if self.canvas_rect.collidepoint(mx, my):
                    cell = self._screen_to_cell(mx, my)
                    if cell is not None:
                        self._paint_cell(cell, erase=True)
            elif event.button == 4:    # wheel up
                self._cycle_variant(1)
            elif event.button == 5:    # wheel down
                self._cycle_variant(-1)

        elif event.type == pygame.MOUSEBUTTONUP:
            if event.button == 1:
                self._mouse_buttons[0] = False
                self._last_painted_cell = None
                if self._box_start is not None and not self._box_erase:
                    end = self._screen_to_cell(*event.pos) \
                        or self._box_start
                    self._commit_box(end, erase=False)
            elif event.button == 3:
                self._mouse_buttons[2] = False
                self._last_painted_cell = None
                if self._box_start is not None and self._box_erase:
                    end = self._screen_to_cell(*event.pos) \
                        or self._box_start
                    self._commit_box(end, erase=True)

        elif event.type == pygame.MOUSEWHEEL:
            # Some platforms emit MOUSEWHEEL instead of buttons 4/5.
            if event.y:
                self._cycle_variant(1 if event.y > 0 else -1)
        return None

    def _click_left(self, mx, my):
        # Drawer rail: pop open even if the hover signal is flaky
        # (touchpad scroll wheels, OS-level cursor warps). The rail is
        # the only mouse target while the drawer is collapsed, so a
        # click here is unambiguous intent to expand.
        if self._grip_rect.collidepoint(mx, my):
            self._palette_anim = 1.0
            self._layout_palette()
            return

        # Palette
        if self.palette_rect.collidepoint(mx, my):
            for rect, ch in self._palette_rects:
                if rect.collidepoint(mx, my):
                    self.selected_char = ch
                    # Reset variant when switching tiles so it can't go
                    # out of range silently.
                    self.selected_variant = 1
                    return
            # < > buttons under the preview panel step the variant the
            # same way the wheel does (_cycle_variant no-ops when the
            # selected tile has only one variant).
            if self._variant_btn_rects['prev'].collidepoint(mx, my):
                self._cycle_variant(-1)
            elif self._variant_btn_rects['next'].collidepoint(mx, my):
                self._cycle_variant(1)
            return

        # Toolbar
        if self.toolbar_rect.collidepoint(mx, my):
            if self._name_rect.collidepoint(mx, my):
                self.editing_name = True
                return
            for name, rect in self._toolbar_rects.items():
                if rect.collidepoint(mx, my):
                    if name == 'save':
                        self._do_save()
                    elif name == 'test':
                        # Propagate 'test' out so handle_input can
                        # return it to main — without this the
                        # toolbar Test button silently saves but
                        # never launches a test session (F5 worked
                        # because it returned _do_test() directly).
                        return self._do_test()
                    elif name == 'load':
                        self._open_picker()
                    elif name == 'theme':
                        self._open_theme_picker()
                    elif name == 'clear':
                        self.new_level(self.cols, self.rows, self.name)
                        self._flash("Cleared")
                    elif name == 'border':
                        self._wall_border()
                        self._flash("Border walled")
                    elif name == 'pick':
                        self.tool = ('pick' if self.tool == 'paint'
                                     else 'paint')
                    elif name == 'grow_w':
                        self._resize(self.cols + 2, self.rows)
                    elif name == 'shrink_w':
                        self._resize(max(6, self.cols - 2), self.rows)
                    elif name == 'grow_h':
                        self._resize(self.cols, self.rows + 2)
                    elif name == 'shrink_h':
                        self._resize(self.cols, max(6, self.rows - 2))
                    return
            return

        # Canvas
        if self.canvas_rect.collidepoint(mx, my):
            cell = self._screen_to_cell(mx, my)
            if cell is None:
                return
            if self.tool == 'pick':
                self._pick_cell(cell)
            else:
                self._paint_cell(cell, erase=False)

    def _cycle_variant(self, delta):
        spec = REGISTRY.get(self.selected_char)
        if spec is None or spec.variant_count <= 1:
            return
        self.selected_variant = (
            (self.selected_variant - 1 + delta) % spec.variant_count) + 1

    # --- grid editing --------------------------------------------------

    def _screen_to_cell(self, sx, sy):
        if not self.canvas_rect.collidepoint(sx, sy):
            return None
        # canvas_rect now extends under the expanded palette drawer
        # (B27), so reject any point sitting under the live overlay —
        # the user is targeting the palette, not a cell.
        if self._palette_anim > 0 and sx >= self._grip_rect.left:
            return None
        # int() to match _cell_to_screen and the canvas draw loop
        # (both use int(self.cam_*)); a float cam here would shift the
        # hit-test off the drawn grid by the sub-pixel camera fraction.
        wx = sx - self.canvas_rect.left + int(self.cam_x)
        wy = sy - self.canvas_rect.top + int(self.cam_y)
        c = int(wx // TILE_SIZE)
        r = int(wy // TILE_SIZE)
        if 0 <= r < self.rows and 0 <= c < self.cols:
            return (r, c)
        return None

    def _cell_to_screen(self, r, c):
        return (self.canvas_rect.left + c * TILE_SIZE - int(self.cam_x),
                self.canvas_rect.top + r * TILE_SIZE - int(self.cam_y))

    def _paint_cell(self, cell, erase=False):
        r, c = cell
        if erase:
            self.grid[r][c] = '.'
        else:
            spec = REGISTRY.get(self.selected_char)
            # Singleton enforcement: P and X must be unique.
            if spec is not None and spec.category == 'special':
                self._clear_char(spec.char)
            self.grid[r][c] = self._selected_token()
        self._last_painted_cell = cell

    def _clear_char(self, ch):
        """Remove every existing occurrence of ``ch`` from the grid.
        Used for singleton tiles (P, X) so painting another puts the
        spawn where the user pointed instead of leaving two."""
        for r in range(self.rows):
            for c in range(self.cols):
                if self.grid[r][c] and self.grid[r][c][0] == ch:
                    self.grid[r][c] = '.'

    def _wall_border(self):
        """Set the whole outer ring to wall. Shared by ``new_level``
        and the toolbar Border button."""
        for r in range(self.rows):
            self.grid[r][0] = 'W'
            self.grid[r][self.cols - 1] = 'W'
        for c in range(self.cols):
            self.grid[0][c] = 'W'
            self.grid[self.rows - 1][c] = 'W'

    def _selected_token(self):
        """The token string the current selection writes — letter plus
        a variant suffix only when it has a non-default variant (keeps
        saved files readable)."""
        spec = REGISTRY.get(self.selected_char)
        if (spec is not None and spec.variant_count > 1
                and self.selected_variant > 1):
            return f"{spec.char}{self.selected_variant}"
        return self.selected_char

    def _pick_cell(self, cell):
        """Eyedropper: load the token under the cursor into the
        selection, then drop back to paint so the next click draws."""
        r, c = cell
        tok = self.grid[r][c] or '.'
        ch = tok[0]
        if ch not in REGISTRY:
            ch = '.'
        self.selected_char = ch
        spec = REGISTRY.get(ch)
        v = _token_variant(tok)
        self.selected_variant = (
            v if spec is not None and 1 <= v <= spec.variant_count
            else 1)
        self.tool = 'paint'

    def _commit_box(self, end_cell, erase):
        """Fill (or erase) the rectangle spanned by the Shift-drag.
        Singleton specials (P/X) can't sensibly tile a box, so they
        fall back to a single placement at the release cell."""
        (r0, c0), (r1, c1) = self._box_start, end_cell
        self._box_start = None
        spec = REGISTRY.get(self.selected_char)
        if (not erase and spec is not None
                and spec.category == 'special'):
            self._paint_cell(end_cell, erase=False)
            return
        token = '.' if erase else self._selected_token()
        for r in range(min(r0, r1), max(r0, r1) + 1):
            for c in range(min(c0, c1), max(c0, c1) + 1):
                self.grid[r][c] = token

    def _resize(self, new_cols, new_rows):
        """Grow / shrink the grid, keeping existing content in place.
        New cells default to floor; the new outer border becomes wall
        only if it's the very edge."""
        new_grid = [['.' for _ in range(new_cols)] for _ in range(new_rows)]
        for r in range(min(self.rows, new_rows)):
            for c in range(min(self.cols, new_cols)):
                new_grid[r][c] = self.grid[r][c]
        # Re-wall border row/cols at the new edges.
        for r in range(new_rows):
            if new_grid[r][0] == '.':
                new_grid[r][0] = 'W'
            if new_grid[r][new_cols - 1] == '.':
                new_grid[r][new_cols - 1] = 'W'
        for c in range(new_cols):
            if new_grid[0][c] == '.':
                new_grid[0][c] = 'W'
            if new_grid[new_rows - 1][c] == '.':
                new_grid[new_rows - 1][c] = 'W'
        self.rows = new_rows
        self.cols = new_cols
        self.grid = new_grid
        self._clamp_camera()

    # --- save / load / test --------------------------------------------

    def _validate(self):
        """Return a list of warnings (empty if everything is fine).
        Only blocks 'no player start' — anything else is a soft warn so
        the user can experiment freely."""
        warnings = []
        flat = [tok for row in self.grid for tok in row]
        chars = [t[0] if t else '.' for t in flat]
        if 'P' not in chars:
            warnings.append("No 'P' player start — level won't be playable")
        if 'X' not in chars:
            warnings.append("No 'X' exit — player can't escape")
        triggers = sum(c in ('L', 'Y') for c in chars)
        # G *cells* are counted, but multiple adjacent G are one panel
        # at runtime. Cheap approximation: count G runs as panels.
        gate_runs = 0
        prev = None
        for c in chars:
            if c == 'G' and prev != 'G':
                gate_runs += 1
            prev = c
        if triggers and gate_runs and triggers != gate_runs:
            warnings.append(
                f"{triggers} trigger(s) but ~{gate_runs} gate panel(s) — "
                "pairings may be off")
        return warnings

    def _do_save(self):
        # Last line of defence: every entry path *should* keep self.name
        # safe, but sanitize at the write boundary too so an empty name
        # can never produce a ".txt" dot-file / "custom_" id.
        self.name = sanitize(self.name)
        warnings = self._validate()
        level_catalog.ensure_custom_dir()
        path = level_catalog.CUSTOM_DIR / f"{self.name}.txt"
        try:
            with open(path, 'w') as f:
                for row in self.grid:
                    f.write(' '.join(row) + '\n')
        except OSError as e:
            self._flash(f"Save failed: {e}")
            return False
        # Theme sidecar: <name>.json beside the .txt. Non-fatal — the map
        # itself is already written; a sidecar failure just means the map
        # reopens with the default theme.
        sidecar = level_catalog.CUSTOM_DIR / f"{self.name}.json"
        try:
            with open(sidecar, 'w') as f:
                json.dump({"theme": self.theme}, f)
        except OSError:
            pass
        if warnings:
            self._flash(f"Saved with warnings: {warnings[0]}")
        else:
            self._flash(f"Saved → {path.name}")
        return True

    def _do_test(self):
        if not self._do_save():
            return None
        # Custom-level id matches the convention in level_catalog.
        self.test_level_id = f"custom_{self.name}"
        self.request_test = True
        return 'test'

    def _flash(self, msg, secs=2.6):
        self.message = msg
        self.message_timer = secs

    # --- load picker (modal) ------------------------------------------

    def _open_picker(self):
        """Open the modal Load picker over the canvas.

        Lists the player's saved custom maps only (built-in levels are
        deliberately excluded — the picker exists to *reopen what you
        saved*). ``_scan_custom`` is the same scan the level menu uses,
        so a map saved this session shows up without a restart."""
        self.picker_entries = level_catalog._scan_custom()
        self.picker_scroll = 0
        self.picker_open = True

    def _picker_panel(self):
        """Centred rect for the modal Load-picker panel."""
        w = min(560, self.width - 120)
        h = min(620, self.height - 160)
        rect = pygame.Rect(0, 0, w, h)
        rect.center = (self.width // 2, self.height // 2)
        return rect

    def _picker_list_rect(self):
        """Scrolling-list area inside the panel (below the title, above
        the footer hint). Rows are clipped to this rect."""
        panel = self._picker_panel()
        return pygame.Rect(panel.left, panel.top + 84,
                           panel.width, panel.height - 84 - 52)

    def _picker_visible_count(self):
        return max(1, self._picker_list_rect().height // self.PICKER_ROW_H)

    def _picker_rows(self):
        """``(rect, entry)`` for every list row, in catalog order.

        Rows are positioned absolutely against ``picker_scroll``; rows
        outside the list area simply fall outside ``_picker_list_rect``
        and the caller clips them. Shared by draw and input so hit-test
        and render never disagree."""
        lr = self._picker_list_rect()
        rows = []
        for i, entry in enumerate(self.picker_entries):
            y = lr.top + (i - self.picker_scroll) * self.PICKER_ROW_H
            rect = pygame.Rect(lr.left + 24, y,
                               lr.width - 48, self.PICKER_ROW_H)
            rows.append((rect, entry))
        return rows

    def _scroll_picker(self, delta):
        max_scroll = max(0, len(self.picker_entries)
                         - self._picker_visible_count())
        self.picker_scroll = max(
            0, min(self.picker_scroll + delta, max_scroll))

    def _handle_picker_input(self, event):
        """Drive the modal Load picker. Always returns ``None`` — the
        picker never leaves the editor or starts a test."""
        if event.type == pygame.KEYDOWN:
            if event.key == pygame.K_ESCAPE:
                self.picker_open = False
            return None

        if event.type == pygame.MOUSEWHEEL:
            if event.y:
                self._scroll_picker(-1 if event.y > 0 else 1)
            return None

        if event.type == pygame.MOUSEBUTTONDOWN:
            if event.button == 4:        # wheel up
                self._scroll_picker(-1)
                return None
            if event.button == 5:        # wheel down
                self._scroll_picker(1)
                return None
            if event.button != 1:
                return None
            if not self._picker_panel().collidepoint(event.pos):
                # Click off the panel dismisses the picker.
                self.picker_open = False
                return None
            list_rect = self._picker_list_rect()
            if list_rect.collidepoint(event.pos):
                for rect, entry in self._picker_rows():
                    if rect.collidepoint(event.pos):
                        self.open_level(entry)
                        self._flash(f"Loaded {entry.title}")
                        self.picker_open = False
                        break
        return None

    def _draw_picker(self, screen):
        """Modal overlay: a centred panel listing saved custom maps."""
        dim = pygame.Surface(screen.get_size(), pygame.SRCALPHA)
        dim.fill((*theme.BG, 210))
        screen.blit(dim, (0, 0))

        panel = self._picker_panel()
        pygame.draw.rect(screen, theme.shade(theme.BG, 6), panel,
                         border_radius=10)
        pygame.draw.rect(screen, theme.LINE_C, panel, 2, border_radius=10)

        title = self.font.render("LOAD MAP", True, theme.TITLE_C)
        screen.blit(title, title.get_rect(
            midtop=(panel.centerx, panel.top + 22)))

        list_rect = self._picker_list_rect()
        if not self.picker_entries:
            empty = self.label_font.render(
                "No custom maps yet — save one first", True, theme.MUTED)
            screen.blit(empty, empty.get_rect(center=list_rect.center))
        else:
            prev_clip = screen.get_clip()
            screen.set_clip(list_rect)
            mp = pygame.mouse.get_pos()
            for rect, entry in self._picker_rows():
                if (rect.bottom < list_rect.top
                        or rect.top > list_rect.bottom):
                    continue
                hov = (rect.collidepoint(mp)
                       and list_rect.collidepoint(mp))
                col = theme.ACCENT if hov else theme.INK
                name = self.head_font.render(entry.title, True, col)
                screen.blit(name, name.get_rect(
                    midleft=(rect.left + 16, rect.centery)))
            screen.set_clip(prev_clip)

        hint = self.hint_font.render(
            "Click a map to load   ·   Esc cancel", True, theme.MUTED)
        screen.blit(hint, hint.get_rect(
            midbottom=(panel.centerx, panel.bottom - 16)))

    # --- theme picker (modal) -----------------------------------------

    def _open_theme_picker(self):
        """Open the modal theme picker over the canvas. Lists the
        floor/wall presets in ``tileset.THEMES``; the picked theme is
        saved into the map's sidecar by ``_do_save``."""
        self.theme_picker_open = True

    def _theme_label(self):
        """Toolbar-button caption — the current map theme's display
        name, or a plain ``"Theme"`` if the id is somehow unknown."""
        for tid, name, _f, _w in tileset.THEMES:
            if tid == self.theme:
                return f"Theme: {name}"
        return "Theme"

    def _theme_picker_panel(self):
        """Centred rect for the modal theme-picker panel, sized to the
        fixed number of presets (no scrolling needed)."""
        rows = len(tileset.THEMES)
        w = min(480, self.width - 120)
        h = min(84 + rows * self.PICKER_ROW_H + 52, self.height - 120)
        rect = pygame.Rect(0, 0, w, h)
        rect.center = (self.width // 2, self.height // 2)
        return rect

    def _theme_picker_list_rect(self):
        """Row area inside the panel (below the title, above the hint)."""
        panel = self._theme_picker_panel()
        return pygame.Rect(panel.left, panel.top + 84,
                           panel.width, panel.height - 84 - 52)

    def _theme_picker_rows(self):
        """``(rect, preset)`` for every theme row, in ``tileset.THEMES``
        order. Shared by draw and input so they never disagree."""
        lr = self._theme_picker_list_rect()
        rows = []
        for i, preset in enumerate(tileset.THEMES):
            y = lr.top + i * self.PICKER_ROW_H
            rect = pygame.Rect(lr.left + 24, y,
                               lr.width - 48, self.PICKER_ROW_H)
            rows.append((rect, preset))
        return rows

    def _handle_theme_picker_input(self, event):
        """Drive the modal theme picker. Always returns ``None`` — the
        picker never leaves the editor or starts a test."""
        if event.type == pygame.KEYDOWN:
            if event.key == pygame.K_ESCAPE:
                self.theme_picker_open = False
            return None

        if event.type == pygame.MOUSEBUTTONDOWN:
            if event.button != 1:
                return None
            if not self._theme_picker_panel().collidepoint(event.pos):
                # Click off the panel dismisses the picker.
                self.theme_picker_open = False
                return None
            for rect, (tid, name, _f, _w) in self._theme_picker_rows():
                if rect.collidepoint(event.pos):
                    self.theme = tid
                    self._flash(f"Theme: {name}")
                    self.theme_picker_open = False
                    break
        return None

    def _draw_theme_picker(self, screen):
        """Modal overlay: a centred panel listing the floor/wall themes,
        each with two tile swatches. The current theme row is marked."""
        dim = pygame.Surface(screen.get_size(), pygame.SRCALPHA)
        dim.fill((*theme.BG, 210))
        screen.blit(dim, (0, 0))

        panel = self._theme_picker_panel()
        pygame.draw.rect(screen, theme.shade(theme.BG, 6), panel,
                         border_radius=10)
        pygame.draw.rect(screen, theme.LINE_C, panel, 2, border_radius=10)

        title = self.font.render("MAP THEME", True, theme.TITLE_C)
        screen.blit(title, title.get_rect(
            midtop=(panel.centerx, panel.top + 22)))

        mp = pygame.mouse.get_pos()
        sw = 38      # tile-swatch size
        for rect, (tid, name, floor, wall) in self._theme_picker_rows():
            selected = (tid == self.theme)
            hov = rect.collidepoint(mp)
            col = theme.ACCENT if (hov or selected) else theme.INK
            # Floor + wall swatches so the player sees the actual look.
            for i, tname in enumerate((floor, wall)):
                sr = pygame.Rect(rect.left + 16 + i * (sw + 8),
                                 rect.centery - sw // 2, sw, sw)
                img = tileset.tile(tname)
                if img is not None:
                    screen.blit(pygame.transform.scale(img, (sw, sw)), sr)
                else:
                    pygame.draw.rect(screen, theme.shade(theme.BG, 14), sr)
                pygame.draw.rect(screen, theme.LINE_C, sr, 1)
            label = self.head_font.render(name, True, col)
            screen.blit(label, label.get_rect(midleft=(
                rect.left + 16 + 2 * (sw + 8) + 12, rect.centery)))
            if selected:
                mark = self.label_font.render("●", True, theme.ACCENT)
                screen.blit(mark, mark.get_rect(
                    midright=(rect.right - 16, rect.centery)))

        hint = self.hint_font.render(
            "Click a theme   ·   Esc cancel", True, theme.MUTED)
        screen.blit(hint, hint.get_rect(
            midbottom=(panel.centerx, panel.bottom - 16)))

    # --- draw ---------------------------------------------------------

    def draw(self, screen):
        # Backdrop — canvas/palette/toolbar are shades derived from the
        # shared BG so the split stays one family, not three tuples.
        # The palette's own backdrop is drawn inside _draw_palette,
        # *after* _draw_canvas, so the expanded drawer cleanly overlays
        # the canvas instead of being painted over by it.
        screen.fill(theme.BG)
        pygame.draw.rect(screen, theme.shade(theme.BG, -6), self.canvas_rect)
        pygame.draw.rect(screen, theme.shade(theme.BG, -2), self.toolbar_rect)

        self._draw_canvas(screen)
        self._draw_palette(screen)
        self._draw_toolbar(screen)
        self._draw_message(screen)
        if self.picker_open:
            self._draw_picker(screen)
        if self.theme_picker_open:
            self._draw_theme_picker(screen)

    # ----- canvas -----------------------------------------------------

    def _draw_canvas(self, screen):
        # Clip to the canvas area so tiles drawn past the panel boundary
        # don't bleed into the palette.
        prev_clip = screen.get_clip()
        screen.set_clip(self.canvas_rect)

        # Tiles. Only the visible window costs anything — we skip every
        # row/col outside the camera.
        cw = self.canvas_rect.width
        ch = self.canvas_rect.height
        first_col = max(0, int(self.cam_x) // TILE_SIZE)
        first_row = max(0, int(self.cam_y) // TILE_SIZE)
        last_col = min(self.cols - 1,
                       (int(self.cam_x) + cw) // TILE_SIZE)
        last_row = min(self.rows - 1,
                       (int(self.cam_y) + ch) // TILE_SIZE)

        floor_img = tileset.tile(tileset.FLOOR_TILE)
        wall_img = tileset.tile(tileset.WALL_TILE)
        floor_tex = TileTextures.get('floor')
        wall_tex = TileTextures.get('wall')

        for r in range(first_row, last_row + 1):
            for c in range(first_col, last_col + 1):
                sx, sy = self._cell_to_screen(r, c)
                # Floor underneath everything.
                screen.blit(floor_img or floor_tex, (sx, sy))
                token = self.grid[r][c]
                if not token or token == '.':
                    continue
                ch_ = token[0]
                if ch_ == 'W':
                    screen.blit(wall_img or wall_tex, (sx, sy))
                else:
                    img = self._thumbnail(ch_, _token_variant(token),
                                          large=True)
                    if img is not None:
                        screen.blit(img, (sx, sy))

        # Grid lines (subtle) — a touch above the canvas shade.
        grid_col = theme.shade(theme.BG, 22)
        for c in range(first_col, last_col + 2):
            x = self.canvas_rect.left + c * TILE_SIZE - int(self.cam_x)
            pygame.draw.line(screen, grid_col,
                             (x, self.canvas_rect.top),
                             (x, self.canvas_rect.bottom), 1)
        for r in range(first_row, last_row + 2):
            y = self.canvas_rect.top + r * TILE_SIZE - int(self.cam_y)
            pygame.draw.line(screen, grid_col,
                             (self.canvas_rect.left, y),
                             (self.canvas_rect.right, y), 1)

        # Cursor preview — eyedropper shows just a cyan box (no ghost,
        # since picking doesn't place the current selection).
        mx, my = pygame.mouse.get_pos()
        cell = self._screen_to_cell(mx, my)
        if cell is not None and not self.editing_name:
            r, c = cell
            sx, sy = self._cell_to_screen(r, c)
            if self.tool == 'pick':
                pygame.draw.rect(screen, theme.ACCENT,
                                 (sx, sy, TILE_SIZE, TILE_SIZE), 3)
            else:
                preview = self._thumbnail(self.selected_char,
                                          self.selected_variant,
                                          large=True)
                if preview is not None:
                    ghost = preview.copy()
                    ghost.set_alpha(160)
                    screen.blit(ghost, (sx, sy))
                pygame.draw.rect(screen, theme.ACCENT,
                                 (sx, sy, TILE_SIZE, TILE_SIZE), 2)

        # Box fill/erase marquee while a Shift-drag is live.
        if self._box_start is not None:
            r0, c0 = self._box_start
            r1, c1 = self._screen_to_cell(mx, my) or self._box_start
            x0, y0 = self._cell_to_screen(min(r0, r1), min(c0, c1))
            w = (abs(c1 - c0) + 1) * TILE_SIZE
            h = (abs(r1 - r0) + 1) * TILE_SIZE
            tint = theme.FAIL if self._box_erase else theme.ACCENT
            ov = pygame.Surface((w, h), pygame.SRCALPHA)
            ov.fill((*tint, 45))
            screen.blit(ov, (x0, y0))
            pygame.draw.rect(screen, tint, (x0, y0, w, h), 2)

        screen.set_clip(prev_clip)

    # ----- palette ----------------------------------------------------

    def _build_palette_layout(self):
        """Compute click rects for every palette tile + remember the
        category headers' y-positions (used for drawing)."""
        self._palette_rects = []
        self._palette_headers = []  # (y, label)

        x0 = self.palette_rect.left + self.PALETTE_PAD
        y = self.palette_rect.top + 90  # leave room for "PALETTE" title
        max_x = self.palette_rect.right - self.PALETTE_PAD
        cell = self.CELL
        gap = self.CELL_GAP

        for category in PALETTE_CATEGORIES:
            chars = chars_for(category)
            if not chars:
                continue
            self._palette_headers.append((y, category.upper()))
            y += 32
            x = x0
            for ch in chars:
                if x + cell > max_x:
                    x = x0
                    y += cell + gap
                rect = pygame.Rect(x, y, cell, cell)
                self._palette_rects.append((rect, ch))
                x += cell + gap
            y += cell + gap + 8

        # Selected-tile preview panel sits below the last category.
        self._palette_info_y = y + 6

        # Panel geometry: a PREVIEW_SIZE sprite centred in the panel,
        # flanked by the < > variant buttons. _draw_palette mirrors
        # these offsets for the cosmetic parts (name, separator, text).
        iy = self._palette_info_y
        ix = self.palette_rect.left + self.PALETTE_PAD
        pw = self.palette_rect.width - 2 * self.PALETTE_PAD
        sprite_top = iy + 56
        self._preview_sprite_pos = (
            ix + (pw - self.PREVIEW_SIZE) // 2, sprite_top)
        btn = self.VARIANT_BTN
        by = sprite_top + (self.PREVIEW_SIZE - btn) // 2
        self._variant_btn_rects = {
            'prev': pygame.Rect(ix + 8, by, btn, btn),
            'next': pygame.Rect(ix + pw - 8 - btn, by, btn, btn),
        }

    def _draw_palette(self, screen):
        # Rail (always visible) — the hover affordance + the only piece
        # of the drawer shown while it's collapsed.
        pygame.draw.rect(
            screen, theme.shade(theme.BG, 4), self._grip_rect)
        self._draw_palette_grip(screen)

        if self._palette_anim <= 0:
            return

        # Panel body backdrop — drawn after the canvas so the expanded
        # drawer cleanly overlays. The 10-shade matches the previous
        # fixed-panel tone from B25.
        pygame.draw.rect(
            screen, theme.shade(theme.BG, 10), self.palette_rect)

        # Title
        title = self.font.render("PALETTE", True, theme.TITLE_C)
        screen.blit(title, title.get_rect(
            midtop=(self.palette_rect.centerx,
                    self.palette_rect.top + 24)))

        # Headers
        for y, label in self._palette_headers:
            h = self.head_font.render(label, True, theme.MUTED)
            screen.blit(h, (self.palette_rect.left + self.PALETTE_PAD, y))
            pygame.draw.line(
                screen, theme.LINE_C,
                (self.palette_rect.left + self.PALETTE_PAD + 120, y + 12),
                (self.palette_rect.right - self.PALETTE_PAD, y + 12), 1)

        # Tile thumbnails
        mp = pygame.mouse.get_pos()
        for rect, ch in self._palette_rects:
            is_sel = (ch == self.selected_char)
            is_hov = rect.collidepoint(mp)
            # Backplate
            bp_color = (theme.shade(theme.BG, 28) if is_sel
                        else theme.shade(theme.BG, 16) if is_hov
                        else theme.shade(theme.BG, 6))
            pygame.draw.rect(screen, bp_color, rect, border_radius=6)
            # Tile preview — the thumb for char's variant 1
            img = self._thumbnail(ch, 1, large=False)
            if img is not None:
                screen.blit(img, img.get_rect(center=rect.center))
            # Letter overlay (top-left)
            letter = self.small_font.render(ch, True, theme.INK)
            screen.blit(letter, (rect.left + 4, rect.top + 2))
            # Border
            border = (theme.ACCENT if is_sel
                      else theme.MUTED if is_hov
                      else theme.LINE_C)
            pygame.draw.rect(screen, border, rect, 2, border_radius=6)

        # Selected-tile preview panel — name, a thin separator, a large
        # sprite flanked by the < > variant buttons, the variant counter,
        # then a short description. Same flat visual language as the
        # CharacterMenu stat block. The buttons step the variant like the
        # wheel; they grey out for single-variant tiles.
        spec = REGISTRY.get(self.selected_char)
        if spec is None:
            return
        ix = self.palette_rect.left + self.PALETTE_PAD
        iy = self._palette_info_y
        w = self.palette_rect.width - 2 * self.PALETTE_PAD
        card = pygame.Rect(ix, iy, w, 150)
        name = self.font.render(spec.label, True, theme.TITLE_C)
        screen.blit(name, (card.left + 16, card.top + 10))
        pygame.draw.line(screen, theme.LINE_C,
                         (card.left + 16, card.top + 48),
                         (card.right - 16, card.top + 48), 2)

        # Large sprite preview of the current selection + variant, on a
        # faint backplate so a dark tile still reads against the panel.
        sx, sy = self._preview_sprite_pos
        plate = pygame.Rect(sx, sy, self.PREVIEW_SIZE, self.PREVIEW_SIZE)
        pygame.draw.rect(screen, theme.shade(theme.BG, 6), plate,
                         border_radius=6)
        preview = self._thumbnail(self.selected_char, self.selected_variant,
                                  large=False, size=self.PREVIEW_SIZE)
        if preview is not None:
            screen.blit(preview, (sx, sy))

        # < > variant buttons — interactive only for multi-variant tiles.
        has_variants = spec.variant_count > 1
        for key, glyph in (('prev', "<"), ('next', ">")):
            rect = self._variant_btn_rects[key]
            hov = has_variants and rect.collidepoint(mp)
            col = (theme.ACCENT if hov
                   else theme.INK if has_variants
                   else theme.LINE_C)
            pygame.draw.rect(screen, col, rect, 2, border_radius=6)
            g = self.head_font.render(glyph, True, col)
            screen.blit(g, g.get_rect(center=rect.center))

        # Variant counter, centred under the sprite.
        vtext = (f"variant {self.selected_variant} / {spec.variant_count}"
                 if has_variants else "single variant")
        v = self.small_font.render(vtext, True, theme.MUTED)
        screen.blit(v, v.get_rect(
            midtop=(card.centerx, sy + self.PREVIEW_SIZE + 8)))

        # Short wrapped description below the panel.
        desc_lines = self._wrap(spec.description, card.width - 30,
                                self.small_font)
        desc_y = sy + self.PREVIEW_SIZE + 32
        for i, line in enumerate(desc_lines[:2]):
            s = self.small_font.render(line, True, theme.MUTED)
            screen.blit(s, (card.left + 16, desc_y + i * 18))

    def _draw_palette_grip(self, screen):
        """Draw the always-visible rail: a 32×32 thumbnail of the
        currently-selected tile, a vertical 'PALETTE' letter stack, and
        — while the drawer is expanded — an ACCENT stripe on the rail's
        left edge as a hover affordance."""
        mp = pygame.mouse.get_pos()
        hot = self._grip_rect.collidepoint(mp)

        # 32×32 selected-tile thumb at the top of the rail so the user
        # always sees what's currently selected even when the drawer is
        # closed. Cached via the regular _thumbnail path; size keys
        # share with the existing palette grid cache only when 32
        # happens to match CELL-12, which it does (44-12 == 32).
        thumb = self._thumbnail(
            self.selected_char, self.selected_variant,
            large=False, size=32)
        if thumb is not None:
            screen.blit(thumb, thumb.get_rect(
                midtop=(self._grip_rect.centerx,
                        self._grip_rect.top + 12)))

        # Vertical letter stack — one small_font letter per row,
        # centred horizontally. ACCENT tint while hovered, MUTED at
        # rest. The stack surface is cached and only rebuilt when the
        # colour flips, so the per-frame cost is one blit.
        col = theme.ACCENT if hot else theme.MUTED
        if (self._palette_label_surf is None
                or self._palette_label_col != col):
            self._palette_label_col = col
            letters = [theme.text_surface(self.small_font, ch, col)
                       for ch in "PALETTE"]
            line_h = self.small_font.get_height() + 2
            stack = pygame.Surface(
                (self._grip_rect.width, line_h * len(letters)),
                pygame.SRCALPHA)
            for i, s in enumerate(letters):
                stack.blit(s, s.get_rect(
                    midtop=(stack.get_width() // 2, i * line_h)))
            self._palette_label_surf = stack
        label = self._palette_label_surf
        screen.blit(label, label.get_rect(
            midtop=(self._grip_rect.centerx,
                    self._grip_rect.top + 56)))

        # Accent stripe on the rail's left edge while the drawer is
        # open — mirrors the toolbar button underline language.
        if self._palette_anim > 0:
            pygame.draw.line(
                screen, theme.ACCENT,
                (self._grip_rect.left, self._grip_rect.top + 4),
                (self._grip_rect.left, self._grip_rect.bottom - 4), 2)

    def _wrap(self, text, max_w, font):
        words = text.split()
        lines, cur = [], []
        for w in words:
            cur.append(w)
            if font.size(' '.join(cur))[0] > max_w:
                cur.pop()
                if cur:
                    lines.append(' '.join(cur))
                cur = [w]
        if cur:
            lines.append(' '.join(cur))
        return lines

    # ----- toolbar ----------------------------------------------------

    def _build_toolbar_layout(self):
        # Button rects are positioned right-anchored; the filename and
        # size info take the left side.
        h = self.toolbar_rect.height
        y = self.toolbar_rect.top
        # Filename area (top-left)
        self._name_rect = pygame.Rect(
            24, y + 16, 460, 44)
        # Buttons stacked horizontally, anchored to the right. Flat
        # text-only buttons share the menu/list language — no coloured
        # chips. Destructive intent ('clear') is signalled by FAIL on
        # hover; the eyedropper ('pick') latches to ACCENT while armed.
        spec = [
            ('pick',   "Pick (Q)", 160, 'tool'),
            ('border', "Border", 140, 'tool'),
            ('clear',  "Clear", 130, 'danger'),
            ('test',   "Test (F5)", 180, 'tool'),
            ('load',   "Load", 130, 'tool'),
            ('theme',  "Theme", 210, 'tool'),
            ('save',   "Save (Ctrl+S)", 220, 'tool'),
        ]
        right = self.toolbar_rect.right - 20
        self._toolbar_rects = {}
        for name, _label, width, _kind in reversed(spec):
            rect = pygame.Rect(right - width, y + 18, width, h - 36)
            self._toolbar_rects[name] = rect
            right -= width + 12
        self._toolbar_button_meta = {n: (lbl, k) for n, lbl, _w, k in spec}

        # Size +/- arrows (smaller, in the middle of the toolbar)
        mid_x = self._name_rect.right + 30
        my = y + 22
        for i, key in enumerate(['shrink_w', 'grow_w']):
            self._toolbar_rects[key] = pygame.Rect(
                mid_x + i * 38, my, 32, 32)
        for i, key in enumerate(['shrink_h', 'grow_h']):
            self._toolbar_rects[key] = pygame.Rect(
                mid_x + 90 + i * 38, my, 32, 32)
        self._toolbar_button_meta.update({
            'shrink_w': ("-W", 'tool'),
            'grow_w':   ("+W", 'tool'),
            'shrink_h': ("-H", 'tool'),
            'grow_h':   ("+H", 'tool'),
        })

    def _draw_toolbar(self, screen):
        # Filename "input"
        col = (theme.shade(theme.BG, 18) if self.editing_name
               else theme.shade(theme.BG, 8))
        pygame.draw.rect(screen, col, self._name_rect, border_radius=6)
        pygame.draw.rect(screen, theme.LINE_C,
                         self._name_rect, 2, border_radius=6)
        label = self.small_font.render(
            "FILE", True, theme.MUTED)
        screen.blit(label, (self._name_rect.left + 12,
                            self._name_rect.top - 18))
        cursor = "_" if (self.editing_name and
                         (pygame.time.get_ticks() // 400) % 2 == 0) else ""
        name_surf = self.label_font.render(
            f"{self.name}{cursor}.txt", True, theme.INK)
        screen.blit(name_surf, name_surf.get_rect(
            midleft=(self._name_rect.left + 16,
                     self._name_rect.centery)))

        # Grid size readout
        size = self.label_font.render(
            f"{self.cols} × {self.rows}", True, theme.MUTED)
        screen.blit(size, (self._name_rect.right + 200,
                           self.toolbar_rect.top + 28))

        # Buttons — flat text + thin underline. Hover or active state
        # lights the text/underline ACCENT; destructive 'clear' goes
        # FAIL on hover only.
        mp = pygame.mouse.get_pos()
        for name, rect in self._toolbar_rects.items():
            text, kind = self._toolbar_button_meta[name]
            # The theme button shows the current map theme's name.
            if name == 'theme':
                text = self._theme_label()
            active = (name == 'pick' and self.tool == 'pick')
            hov = rect.collidepoint(mp)
            if active:
                text_col = theme.ACCENT
            elif hov:
                text_col = theme.FAIL if kind == 'danger' else theme.ACCENT
            else:
                text_col = theme.INK
            t = self.label_font.render(text, True, text_col)
            screen.blit(t, t.get_rect(center=rect.center))
            if hov or active:
                pygame.draw.line(screen, text_col,
                                 (rect.left + 12, rect.bottom - 6),
                                 (rect.right - 12, rect.bottom - 6), 2)

        # Bottom-line hint
        d = theme.HINT_DOT
        hint = self.hint_font.render(
            f"LMB place {d} RMB clear {d} Shift+drag box {d} Q pick {d} "
            f"Wheel variant {d} WASD pan {d} Esc back {d} F5 test",
            True, theme.MUTED)
        screen.blit(hint, hint.get_rect(
            midbottom=(self.toolbar_rect.centerx,
                       self.toolbar_rect.bottom - 6)))

    def _draw_message(self, screen):
        if not self.message:
            return
        s = self.label_font.render(self.message, True, theme.INK)
        pad = s.get_rect().inflate(28, 14)
        pad.center = (self.canvas_rect.centerx,
                      self.toolbar_rect.top - 40)
        bg = pygame.Surface(pad.size, pygame.SRCALPHA)
        bg.fill((*theme.BG, 220))
        screen.blit(bg, pad)
        pygame.draw.rect(screen, theme.LINE_C, pad, 2, border_radius=8)
        screen.blit(s, s.get_rect(center=pad.center))

    # --- thumbnail rendering ------------------------------------------

    def _thumbnail(self, char, variant, large, size=None):
        """Cached preview surface for one tile.

        ``large=True`` returns a TILE_SIZE x TILE_SIZE image suitable
        for the canvas; ``large=False`` returns a thumbnail-sized image
        for the palette grid. An explicit ``size`` overrides both — the
        selected-tile preview panel uses it for a larger sprite."""
        if size is None:
            size = TILE_SIZE if large else self.CELL - 12
        key = (char, variant, size)
        if key in self._tile_thumb_cache:
            return self._tile_thumb_cache[key]
        surf = self._build_thumbnail(char, variant, size)
        self._tile_thumb_cache[key] = surf
        return surf

    def _build_thumbnail(self, char, variant, size):
        spec = REGISTRY.get(char)
        if spec is None:
            return None

        # Prop tiles draw straight from the tileset.
        if spec.tileset_category is not None:
            base = tileset.sprite(spec.tileset_category, variant)
            return _scale_surface(base, size)

        # Procedural / built-in tiles — fall through by character.
        surf = pygame.Surface((TILE_SIZE, TILE_SIZE), pygame.SRCALPHA)
        if char == 'W':
            wall = (tileset.tile(tileset.WALL_TILE)
                    or TileTextures.get('wall'))
            surf.blit(wall, (0, 0))
        elif char == '.':
            floor = (tileset.tile(tileset.FLOOR_TILE)
                     or TileTextures.get('floor'))
            surf.blit(floor, (0, 0))
        elif char == 'P':
            _draw_marker(surf, theme.SUCCESS, "P")
        elif char == 'X':
            _draw_exit_marker(surf)
        elif spec.category == 'enemy':
            _draw_marker(surf, theme.FAIL, char)
        elif char == 'S':
            Spikes._build_images()
            surf.blit(Spikes._imgs['up'], (0, 0))
        elif char == 'L':
            Lever._build_images()
            surf.blit(Lever._imgs[False], (0, 0))
        elif char == 'Y':
            PressurePlate._build_images()
            surf.blit(PressurePlate._imgs[False], (0, 0))
        elif char == 'G':
            Gate._build_images()
            surf.blit(Gate._imgs[False], (0, 0))
        elif char == 'K':
            KeyItem._build_image()
            surf.blit(KeyItem._img, (0, 0))
        else:
            # Last-resort marker for an unknown tile char — keep a loud
            # magenta so it visibly screams "missing art" in the editor.
            _draw_marker(surf, (180, 60, 180), char)
        return _scale_surface(surf, size)


# --- module helpers --------------------------------------------------------

def _token_variant(token):
    """Same logic as ``levels._cell_variant`` — duplicated here to keep
    editor.py importable without dragging the level runtime along."""
    digits = token[1:]
    return int(digits) if digits.isdigit() else 1


def _scale_surface(surf, size):
    if size == TILE_SIZE:
        return surf
    return pygame.transform.smoothscale(surf, (size, size))


def _draw_marker(surf, color, letter):
    """Generic 'this tile has no art' marker — circle + bold letter."""
    cx = cy = TILE_SIZE // 2
    pygame.draw.circle(surf, color, (cx, cy), TILE_SIZE // 2 - 6)
    pygame.draw.circle(surf, theme.BG,
                       (cx, cy), TILE_SIZE // 2 - 6, 3)
    f = theme.font(32)
    s = f.render(letter, True, theme.BG)
    surf.blit(s, s.get_rect(center=(cx, cy)))


def _draw_exit_marker(surf):
    """Door-shaped silhouette so the exit reads as something to head
    toward, not a generic marker."""
    rect = pygame.Rect(8, 6, TILE_SIZE - 16, TILE_SIZE - 12)
    pygame.draw.rect(surf, theme.shade(theme.BG, +6), rect, border_radius=4)
    pygame.draw.rect(surf, theme.SUCCESS, rect, 3, border_radius=4)
    inner = rect.inflate(-14, -10)
    pygame.draw.rect(surf, theme.shade(theme.SUCCESS, -30), inner)