Source code for simvx.editor.panels.scene_tree.dialogs

"""Scene Tree dialogs: Add Node popup and inline rename overlay."""

from __future__ import annotations

from typing import TYPE_CHECKING

from simvx.core import (
    Control,
    FocusMode,
    MouseButton,
    Node,
    Property,
    Signal,
    TextEdit,
    Vec2,
)

if TYPE_CHECKING:
    from simvx.editor.project_classes import ProjectClass, ProjectClassIndex


from .type_registry import (
    _DEFAULT_EXPANDED,
    _NODE_CATEGORIES,
    _NODE_DESCRIPTIONS,
    _NODE_ICONS,
    _RECENT_TYPES,
    _get_inheritance_chain,
    _record_recent_type,
)

__all__ = ["_AddNodeDialog", "_RenameOverlay"]
# ============================================================================


class _FilterEdit(TextEdit):
    """The Add Node filter field: Down steps into the list below it.

    A text field has no use for the vertical arrows and the router offers a key
    to the focus owner alone, so without this the rows would be reachable only
    by Tab. Filter, arrow down, Enter is the shortest path through the picker
    and the one every filtered list offers.

    Emits *step_into_list()* rather than reaching for the list itself.
    """

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.step_into_list = Signal()

    def _on_gui_input(self, event):
        if self.focused and event.pressed and event.button is None and event.key == "down":
            self.step_into_list.emit()
            event.handled = True
            return
        super()._on_gui_input(event)


class _AddNodeList(Control):
    """The row area of the Add Node dialog: its selection, its scroll, its input.

    A tab stop of its own, which is what makes the picker's keyboard reachable:
    the router moves focus here with Tab, and while this control holds it the
    arrow keys move the selection, Enter adds the selected type and Shift+Enter
    places it with the mouse. Typing a character hands the keyboard back to the
    filter field, so a filter can be refined without Tabbing back first.

    The dialog paints the rows (it owns the clip, the scroll thumb and the
    description footer) and hands them here on every rebuild; what lives on this
    control is the state the rows are drawn from and the input that changes it.

    Signals:
        activated(shift): confirm ``selected_index``; *shift* asks for placement.
        category_toggled(name): a category header was clicked.
        filter_requested(event): give the filter field the keyboard, and the
            event with it when the user typed a character.
    """

    ROW_HEIGHT = 22.0
    CATEGORY_HEIGHT = 24.0
    WHEEL_STEP = 30.0

    # The list owns the rows and is a Tab destination beside the filter field.
    focus_mode = Property(FocusMode.ALL, hint="Whether this control can take keyboard focus")

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        # Rows: ("category", cat_name, None) or ("type", name, cls), where cls is
        # a live ``type`` (built-in) or a ``ProjectClass`` record (user-defined).
        self.rows: list[tuple[str, str, object | None]] = []
        self.selected_index = -1  # keyboard selection
        self.hovered_index = -1  # row under the cursor
        self.scroll_offset = 0.0

        self.activated = Signal()
        self.category_toggled = Signal()
        self.filter_requested = Signal()

        # Entering by Tab lands on the first type, so Tab then Enter adds a node;
        # leaving drops the selection, so the highlight always shows where the
        # keyboard is.
        self.focus_entered.connect(self._on_focus_entered)
        self.focus_exited.connect(self._on_focus_exited)
        # Motion is delivered to the control under the cursor and to nobody
        # else, so a pointer that leaves the rows is heard here and not there.
        self.mouse_exited.connect(self._clear_hover)

    # -- state --

    def reset(self):
        """Clear selection, hover and scroll (the dialog opening or refiltering)."""
        self.selected_index = -1
        self.hovered_index = -1
        self.scroll_offset = 0.0

    def _on_focus_entered(self):
        if self._visible_in_hierarchy and self.selected_index < 0:
            self.select_first_type()

    def _on_focus_exited(self):
        self.selected_index = -1
        self.queue_redraw()

    def _clear_hover(self):
        if self.hovered_index != -1:
            self.hovered_index = -1
            self.queue_redraw()

    def selected_entry(self) -> tuple[str, str, object | None] | None:
        """The selected row, or None when nothing is selected."""
        if 0 <= self.selected_index < len(self.rows):
            return self.rows[self.selected_index]
        return None

    # -- row layout --

    def row_height(self, idx: int) -> float:
        """Pixel height of the row at *idx*."""
        if 0 <= idx < len(self.rows):
            return self.CATEGORY_HEIGHT if self.rows[idx][0] == "category" else self.ROW_HEIGHT
        return self.ROW_HEIGHT

    def row_y_offset(self, idx: int) -> float:
        """Cumulative Y offset to the top of row *idx*, in content space."""
        return sum(self.row_height(i) for i in range(idx))

    def total_content_height(self) -> float:
        """Pixel height of every visible row."""
        return sum(self.row_height(i) for i in range(len(self.rows)))

    def hit_test(self, content_y: float) -> int | None:
        """Row index at *content_y* (a Y offset into the scrolled content), or None."""
        y = 0.0
        for i in range(len(self.rows)):
            h = self.row_height(i)
            if y <= content_y < y + h:
                return i
            y += h
        return None

    def _content_y(self, screen_y: float) -> float:
        """Convert a screen Y to an offset into the scrolled content."""
        _, gy, _, _ = self.get_global_rect()
        return screen_y - gy + self.scroll_offset

    # -- scrolling --

    def scroll_by(self, delta: float):
        """Scroll the rows by *delta* pixels, clamped to the content."""
        max_scroll = max(0.0, self.total_content_height() - self.size.y)
        self.scroll_offset = max(0.0, min(max_scroll, self.scroll_offset + delta))

    def _ensure_selected_visible(self):
        """Scroll so the selected row sits inside the visible rect."""
        if self.selected_index < 0:
            return
        row_top = self.row_y_offset(self.selected_index)
        row_bot = row_top + self.row_height(self.selected_index)
        if row_top < self.scroll_offset:
            self.scroll_offset = row_top
        elif row_bot > self.scroll_offset + self.size.y:
            self.scroll_offset = row_bot - self.size.y

    # -- selection --

    def _next_type_index(self, start: int, direction: int = 1) -> int:
        """Next type row from *start* in *direction* (1 down, -1 up), or -1 if none.

        Category headers are skipped: they are not selectable.
        """
        idx = start + direction
        while 0 <= idx < len(self.rows):
            if self.rows[idx][0] == "type":
                return idx
            idx += direction
        return -1

    def select(self, idx: int):
        """Select row *idx* and scroll it into view."""
        self.selected_index = idx
        self._ensure_selected_visible()
        self.queue_redraw()

    def select_first_type(self) -> bool:
        """Select the first type row; False when the list holds none."""
        first = self._next_type_index(-1, 1)
        if first < 0:
            return False
        self.select(first)
        return True

    # -- input --

    def _on_gui_input(self, event):
        """Clicks, hover, the wheel and the keyboard over the rows."""
        if not self.visible:
            return

        if event.key in ("scroll_up", "scroll_down"):
            self.scroll_by(-self.WHEEL_STEP if event.key == "scroll_up" else self.WHEEL_STEP)
            event.handled = True
            return

        if event.button == MouseButton.LEFT:
            if event.pressed:
                self._on_press(event)
            return

        if event.key:
            if event.pressed:
                self._on_key(event)
            return

        if event.char:
            # Typing anywhere in the picker belongs to the filter field.
            self.filter_requested.emit(event)
            event.handled = True
            return

        if event.button is None:
            row = self.hit_test(self._content_y(event.position.y))
            hovered = row if row is not None and self.rows[row][0] == "type" else -1
            if hovered != self.hovered_index:
                self.hovered_index = hovered
                self.queue_redraw()

    def _on_press(self, event):
        """A left press over the rows: toggle a category, or choose a type."""
        event.handled = True  # the press is the list's whether or not it lands on a row
        idx = self.hit_test(self._content_y(event.position.y))
        if idx is None:
            return
        kind, name, cls = self.rows[idx]
        if kind == "category":
            self.category_toggled.emit(name)
        elif cls is not None:
            self.selected_index = idx
            self.activated.emit(False)

    def _on_key(self, event):
        """Arrow selection and Enter, while this control holds focus."""
        key = event.key
        if key == "down":
            nxt = self._next_type_index(self.selected_index, 1)
            if nxt >= 0:
                self.select(nxt)
            event.handled = True
        elif key == "up":
            nxt = self._next_type_index(self.selected_index, -1)
            if nxt >= 0:
                self.select(nxt)
            else:
                # Off the top of the list: the filter field is what sits above it.
                self.selected_index = -1
                self.filter_requested.emit(event)
            event.handled = True
        elif key in ("enter", "return", "shift+enter", "shift+return"):
            if self.selected_index >= 0:
                self.activated.emit(key.startswith("shift+"))
                event.handled = True


class _AddNodeDialog(Control):
    """Popup overlay listing available node types in collapsible categories with a filter.

    Uses the SceneTree popup system so it draws on top of all controls
    and receives input before any underlying widgets.

    Features:
        - Collapsible categories with type icons
        - Text filter with real-time narrowing
        - Recently used types section
        - Description footer with inheritance chain
        - Scroll indicator (visual scrollbar)

    Two controls hold the keyboard, and the router moves between them with Tab:
    the filter field, focused on open, and :class:`_AddNodeList`, which owns the
    rows. Escape dismisses the dialog through the router's overlay-cancel, and
    Enter in the filter field adds the first type that still matches.

    Emits *type_chosen(node_class)* when the user picks a type, then hides.
    """

    DIALOG_WIDTH = 300.0
    DIALOG_HEIGHT = 480.0
    ROW_HEIGHT = _AddNodeList.ROW_HEIGHT
    CATEGORY_HEIGHT = _AddNodeList.CATEGORY_HEIGHT
    HEADER_HEIGHT = 50.0
    FOOTER_HEIGHT = 50.0
    SCROLLBAR_WIDTH = 4.0

    # Raised by ``open_at`` rather than by construction, at a fixed card size.
    visible = Property(
        False,
        coerce=bool,
        hint="Whether this node and its subtree are drawn and picked",
        on_change="_on_visible_changed",
    )
    size_x = Property(DIALOG_WIDTH, range=(0, 10000), hint="Control width", on_change="_on_size_changed")
    size_y = Property(DIALOG_HEIGHT, range=(0, 10000), hint="Control height", on_change="_on_size_changed")

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.cancel_requested.connect(self._on_router_cancel)
        self.bg_colour = (0.14, 0.14, 0.17, 1.0)
        self.border_colour = (0.38, 0.38, 0.42, 1.0)
        self.text_colour = (0.92, 0.92, 0.92, 1.0)
        self.hover_colour = (0.30, 0.47, 0.77, 1.0)
        self.select_colour = (0.36, 0.55, 0.88, 1.0)
        self.category_bg = (0.18, 0.18, 0.22, 1.0)
        self.category_text = (0.70, 0.82, 1.0, 1.0)
        self.footer_bg = (0.12, 0.12, 0.15, 1.0)
        self.footer_sep = (0.30, 0.30, 0.35, 1.0)
        self.desc_colour = (0.60, 0.62, 0.66, 1.0)
        self.scrollbar_colour = (0.40, 0.42, 0.48, 0.6)
        self.font_size = 13.0

        self._filter_text = ""
        self._collapsed: set[str] = set()  # collapsed category names

        # Optional project-class index. Wired by the panel after construction.
        self._project_index: ProjectClassIndex | None = None
        self._project_classes: list[ProjectClass] = []

        # Filter text-edit (embedded)
        self._filter_edit = _FilterEdit(placeholder="Filter types...", name="AddNodeFilter")
        self._filter_edit.position = Vec2(6, 20)
        self._filter_edit.size = Vec2(self.DIALOG_WIDTH - 12, 24)
        self._filter_edit.font_size = 12.0
        self._filter_edit.text_changed.connect(self._on_filter_changed)
        self._filter_edit.text_submitted.connect(self._on_filter_submitted)
        self._filter_edit.step_into_list.connect(self._step_into_list)
        self.add_child(self._filter_edit)

        # The rows, between the header and the description footer.
        self._list = _AddNodeList(name="AddNodeList")
        self._list.position = Vec2(0, self.HEADER_HEIGHT)
        self._list.size = Vec2(self.DIALOG_WIDTH, self.DIALOG_HEIGHT - self.HEADER_HEIGHT - self.FOOTER_HEIGHT)
        self._list.activated.connect(self._confirm_selection)
        self._list.category_toggled.connect(self._toggle_category)
        self._list.filter_requested.connect(self._focus_filter)
        self.add_child(self._list)

        # Emitted with either a built-in ``type`` or a ``ProjectClass`` record;
        # the panel resolves project entries to live classes before instantiating.
        self.type_chosen = Signal()  # Direct add (at default position)
        self.type_place_chosen = Signal()  # Place with mouse (Shift+Enter)

        self._rebuild_rows()

    @property
    def _rows(self) -> list[tuple[str, str, object | None]]:
        """The visible rows, owned by the list control that navigates them.

        Each is ``("category", cat_name, None)`` or ``("type", name, cls)``, where
        ``cls`` is a live ``type`` (built-in) or a ``ProjectClass`` record
        (user-defined, resolved on selection).
        """
        return self._list.rows

    @_rows.setter
    def _rows(self, rows: list[tuple[str, str, object | None]]):
        self._list.rows = rows

    def set_project_index(self, index: ProjectClassIndex | None) -> None:
        """Wire (or clear) the project-class discovery cache.

        Called by :class:`SceneTreePanel` once the editor has a project root.
        The dialog refreshes the index lazily on each :meth:`show_at`.
        """
        self._project_index = index

    # -- public API --

    def show_at(self, x: float, y: float):
        """Open the dialog at the given screen position."""
        self.position = Vec2(x, y)
        self._filter_text = ""
        self._filter_edit.text = ""
        self._list.reset()
        if self._project_index is not None:
            self._project_classes = self._project_index.refresh()
        else:
            self._project_classes = []
        self._collapsed = {cat for cat, _ in _NODE_CATEGORIES if cat not in _DEFAULT_EXPANDED}
        self._rebuild_rows()
        # Light overlay (no dim): this picker previously had pause_tree_when_modal=False
        # and no backdrop, so it stays a no-dim, dismiss-on-outside popup that captures
        # input. (A dimmed dialog would be show_overlay("blocking").)
        self.show_overlay("light")
        # Override the default first-focusable focus with the filter edit, so
        # the dialog opens ready to type and Tab moves on to the list.
        self._filter_edit.set_focus()

    def dismiss(self):
        if not self.visible:
            return
        self._list.reset()
        self.close_overlay()
        self.visible = False

    def _on_router_cancel(self):
        if self.visible:
            self.dismiss()

    # -- keyboard --

    def _confirm_selection(self, shift: bool = False):
        """Add the selected type (*shift* places it with the mouse instead)."""
        entry = self._list.selected_entry()
        if entry is None:
            return
        kind, _name, cls = entry
        if kind != "type" or cls is None:
            return
        if shift:
            self.type_place_chosen.emit(cls)
        else:
            if isinstance(cls, type):
                _record_recent_type(cls)
            self.type_chosen.emit(cls)
        self.dismiss()

    def _on_filter_submitted(self, _text: str):
        """Enter in the filter field adds the selected type, or the first match."""
        if self._list.selected_entry() is None and not self._list.select_first_type():
            return
        self._confirm_selection()

    def _step_into_list(self):
        """Down in the filter field moves the keyboard to the rows."""
        self._list.set_focus()

    def _focus_filter(self, event=None):
        """Hand the keyboard back to the filter field, and the keystroke with it."""
        self._filter_edit.set_focus()
        if event is not None and event.char:
            self._filter_edit._internal_gui_input(event)

    def _toggle_category(self, name: str):
        """Expand or collapse the named category."""
        if name in self._collapsed:
            self._collapsed.discard(name)
        else:
            self._collapsed.add(name)
        self._rebuild_rows()

    # -- row layout helpers --

    def type_row_y(self, type_name: str) -> float | None:
        """Return the global Y position of the named type row, or None if not visible.

        Used by demo step handlers to compute click targets in the categorised layout.
        """
        _, gy, _, _ = self.get_global_rect()
        y = gy + self.HEADER_HEIGHT
        for kind, name, _cls in self._rows:
            h = self.CATEGORY_HEIGHT if kind == "category" else self.ROW_HEIGHT
            if kind == "type" and name == type_name:
                return y
            y += h
        return None

    # -- scroll indicator helpers --

    def _scroll_indicator_geometry(self) -> tuple[float, float, float, float] | None:
        """Return (x, y, w, h) for the scroll thumb, or None if content fits."""
        list_h = self.DIALOG_HEIGHT - self.HEADER_HEIGHT - self.FOOTER_HEIGHT
        total_h = self._list.total_content_height()
        if total_h <= list_h:
            return None
        gx, gy, gw, _ = self.get_global_rect()
        track_x = gx + gw - self.SCROLLBAR_WIDTH - 1
        track_y = gy + self.HEADER_HEIGHT
        ratio = list_h / total_h
        thumb_h = max(12.0, list_h * ratio)
        scroll_range = total_h - list_h
        if scroll_range > 0:
            thumb_y = track_y + (self._list.scroll_offset / scroll_range) * (list_h - thumb_h)
        else:
            thumb_y = track_y
        return track_x, thumb_y, self.SCROLLBAR_WIDTH, thumb_h

    # -- description footer helpers --

    def _focused_type(self) -> object | None:
        """Return the entry currently under keyboard selection or hover.

        May be either a built-in ``type`` or a :class:`ProjectClass` record.
        """
        idx = self._list.selected_index if self._list.selected_index >= 0 else self._list.hovered_index
        if 0 <= idx < len(self._rows):
            kind, _, cls = self._rows[idx]
            if kind == "type":
                return cls
        return None

    # -- internals --

    def _row_module_path(self, entry: object) -> str:
        """Module path shown as secondary text and matched by the filter.

        Built-ins are normalised to their public namespace (``simvx.core``)
        rather than the implementation submodule (``simvx.core.node``) so the
        filter matches the user's mental model and doesn't sweep in every
        class whose source file happens to be named ``node.py``.
        """
        from simvx.editor.project_classes import ProjectClass

        if isinstance(entry, ProjectClass):
            return entry.module_path
        if isinstance(entry, type):
            module = getattr(entry, "__module__", "") or ""
            # Collapse simvx.core.* -> simvx.core; same for simvx.graphics.*, etc.
            if module.startswith("simvx."):
                parts = module.split(".")
                if len(parts) >= 2:
                    return f"{parts[0]}.{parts[1]}"
            return module
        return ""

    def _row_class_name(self, entry: object) -> str:
        """Display name for an entry (built-in ``type`` or ``ProjectClass``)."""
        from simvx.editor.project_classes import ProjectClass

        if isinstance(entry, ProjectClass):
            return entry.name
        if isinstance(entry, type):
            return entry.__name__
        return ""

    def _row_matches_filter(self, name: str, entry: object) -> bool:
        """Substring match across both class name and module path."""
        if not self._filter_text:
            return True
        ft = self._filter_text
        return ft in name.lower() or ft in self._row_module_path(entry).lower()

    def _rebuild_rows(self):
        """Rebuild the flat row list from categories, respecting filter and collapse state."""
        rows: list[tuple[str, str, object | None]] = []
        ft = self._filter_text
        project_entries: list[tuple[str, ProjectClass]] = [(pc.name, pc) for pc in self._project_classes]

        if ft:
            # Filter mode: show all matching items grouped by category (all expanded).
            # Project entries appear at the top so user classes lead the list.
            project_matches = [(name, pc) for name, pc in project_entries if self._row_matches_filter(name, pc)]
            if project_matches:
                rows.append(("category", "Project", None))
                for name, pc in project_matches:
                    rows.append(("type", name, pc))

            for cat_name, items in _NODE_CATEGORIES:
                matches = [(name, cls) for name, cls in items if self._row_matches_filter(name, cls)]
                if matches:
                    rows.append(("category", cat_name, None))
                    for name, cls in matches:
                        rows.append(("type", name, cls))
        else:
            # Project section: always expanded and at the top when non-empty.
            if project_entries:
                rows.append(("category", "Project", None))
                for name, pc in project_entries:
                    rows.append(("type", name, pc))

            # Recent section: in-session most-recently-used types (built-in only;
            # user classes can be added through Project or via filter).
            if _RECENT_TYPES:
                rows.append(("category", "Recent", None))
                seen: set[type] = set()
                for cls in _RECENT_TYPES:
                    if cls not in seen:
                        seen.add(cls)
                        rows.append(("type", cls.__name__, cls))

            # Normal mode: respect collapse state
            for cat_name, items in _NODE_CATEGORIES:
                rows.append(("category", cat_name, None))
                if cat_name not in self._collapsed:
                    for name, cls in items:
                        rows.append(("type", name, cls))

        self._rows = rows

    @property
    def _filtered(self) -> list[tuple[str, object]]:
        """Flat list of visible (name, cls) tuples, category headers excluded."""
        return [(name, cls) for kind, name, cls in self._rows if kind == "type" and cls is not None]

    def _on_filter_changed(self, text: str):
        self._filter_text = text.lower()
        self._list.reset()
        self._rebuild_rows()

    def _on_gui_input(self, event):
        """The wheel over the dialog's chrome scrolls the list under it.

        Everything else about the rows belongs to :class:`_AddNodeList`: it
        hit-tests the clicks and the hover over them, and holds the keyboard
        while it owns focus. The header, the footer and the gutter are what is
        left, and a wheel over any of them means the same as one over the rows.
        """
        if not self.visible:
            return
        if event.key in ("scroll_up", "scroll_down"):
            self._list.scroll_by(-self._list.WHEEL_STEP if event.key == "scroll_up" else self._list.WHEEL_STEP)
            event.handled = True

    def on_draw(self, renderer):
        """Draw the dialog (an overlay, so it renders above siblings)."""
        if not self.visible:
            return

        gx, gy, gw, gh = self.get_global_rect()

        # Background + border
        renderer.draw_rect((gx, gy), (gw, gh), colour=self.bg_colour, filled=True)
        renderer.draw_rect((gx, gy), (gw, gh), colour=self.border_colour)

        # Title and hint
        renderer.draw_text("Add Node", (gx + 6, gy + 2), colour=(0.7, 0.85, 1.0, 1.0), scale=self.font_size / 16.0)
        renderer.draw_text("Shift = place", (gx + gw - 80, gy + 4), colour=(0.5, 0.5, 0.5, 1.0), scale=0.55)

        # Draw the filter edit (the list draws nothing of its own: its rows are
        # painted here, under this clip, from the state it holds).
        self._filter_edit._draw_recursive(renderer)

        # List area (between header and footer)
        list_y = gy + self.HEADER_HEIGHT
        list_h = gh - self.HEADER_HEIGHT - self.FOOTER_HEIGHT
        renderer.push_clip(gx, list_y, gw, list_h)

        scale = self.font_size / 16.0
        y_cursor = 0.0
        for i, (kind, name, cls) in enumerate(self._rows):
            rh = self.CATEGORY_HEIGHT if kind == "category" else self.ROW_HEIGHT
            row_y = list_y + y_cursor - self._list.scroll_offset
            y_cursor += rh

            # Cull off-screen rows
            if row_y + rh < list_y or row_y > list_y + list_h:
                continue

            if kind == "category":
                # Category header row
                renderer.draw_rect((gx + 1, row_y), (gw - 2, rh), colour=self.category_bg, filled=True)
                collapsed = name in self._collapsed and not self._filter_text
                # Recent category is never collapsible
                if name == "Recent":
                    arrow = "\u25bc"
                else:
                    arrow = "\u25b6" if collapsed else "\u25bc"  # right or down arrow
                renderer.draw_text(f" {arrow}  {name}", (gx + 4, row_y + 4), colour=self.category_text, scale=scale)
            else:
                # Type row (indented)
                is_selected = i == self._list.selected_index
                is_hovered = i == self._list.hovered_index
                if is_selected:
                    renderer.draw_rect((gx + 1, row_y), (gw - 2, rh), colour=self.select_colour, filled=True)
                elif is_hovered:
                    renderer.draw_rect((gx + 1, row_y), (gw - 2, rh), colour=self.hover_colour, filled=True)
                icon = _NODE_ICONS.get(cls, "\u2295") if isinstance(cls, type) else "\u2295"
                renderer.draw_text(f"    {icon}  {name}", (gx + 4, row_y + 3), colour=self.text_colour, scale=scale)
                # Module path -- right-aligned secondary text. Shown for both
                # built-ins (``simvx.core``) and project classes (``player.attack``)
                # so the picker disambiguates same-named symbols.
                module_path = self._row_module_path(cls)
                if module_path:
                    # Trim to a tail that fits the right gutter.
                    max_chars = 28
                    if len(module_path) > max_chars:
                        module_path = "..." + module_path[-(max_chars - 3) :]
                    text_x = gx + gw - len(module_path) * 6 - 12
                    renderer.draw_text(module_path, (text_x, row_y + 4), colour=self.desc_colour, scale=scale * 0.8)

        renderer.pop_clip()

        # -- Scroll indicator --
        geom = self._scroll_indicator_geometry()
        if geom:
            sx, sy, sw, sh = geom
            renderer.draw_rect((sx, sy), (sw, sh), colour=self.scrollbar_colour, filled=True)

        # -- Description footer --
        footer_y = gy + gh - self.FOOTER_HEIGHT
        renderer.draw_rect((gx, footer_y), (gw, self.FOOTER_HEIGHT), colour=self.footer_bg, filled=True)
        renderer.draw_line((gx, footer_y), (gx + gw, footer_y), colour=self.footer_sep)

        focused = self._focused_type()
        if focused is not None:
            from simvx.editor.project_classes import ProjectClass

            if isinstance(focused, ProjectClass):
                type_name = focused.name
                chain = ", ".join(focused.bases)
                # Description for project classes: the dotted module path so
                # users can locate the source file at a glance.
                desc = focused.module_path or str(focused.file_path)
            elif isinstance(focused, type):
                type_name = focused.__name__
                chain = _get_inheritance_chain(focused)
                desc = _NODE_DESCRIPTIONS.get(focused, "")
            else:
                type_name = ""
                chain = ""
                desc = ""
            title_text = f"{type_name}  extends {chain}" if chain else type_name
            renderer.draw_text(title_text, (gx + 6, footer_y + 6), colour=self.text_colour, scale=scale * 0.95)
            if desc:
                renderer.draw_text(desc, (gx + 6, footer_y + 24), colour=self.desc_colour, scale=scale * 0.85)


# ============================================================================
# Rename overlay -- small inline text field for renaming a node
# ============================================================================


class _RenameOverlay(Control):
    """Inline text field shown over a tree row to rename a node."""

    # Shown over the row being renamed, not on construction.
    visible = Property(
        False,
        coerce=bool,
        hint="Whether this node and its subtree are drawn and picked",
        on_change="_on_visible_changed",
    )

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self._edit = TextEdit(name="RenameEdit")
        self._edit.size = Vec2(180, 22)
        self._edit.font_size = 13.0
        self.add_child(self._edit)
        self._target_node: Node | None = None

        self.rename_confirmed = Signal()
        self._edit.text_submitted.connect(self._on_submit)

    def begin(self, node: Node, x: float, y: float):
        self._target_node = node
        self._edit.text = node.name
        self._edit.cursor_pos = len(node.name)
        self.position = Vec2(x, y)
        self.size = Vec2(180, 22)
        self.visible = True
        if self._tree:
            self._tree._set_focused_control(self._edit)

    def _on_submit(self, text: str):
        if self._target_node and text.strip():
            self.rename_confirmed.emit(self._target_node, text.strip())
        self.visible = False
        self._target_node = None

    def cancel(self):
        self.visible = False
        self._target_node = None

    def on_draw(self, renderer):
        if not self.visible:
            return
        # The child TextEdit handles its own drawing


# ============================================================================
# SceneTreePanel -- main panel