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 (
    # Animation
    Control,
    MouseButton,
    # 2D Lights
    Node,
    Signal,
    TextEdit,
    # TileMap
    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 _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
        - Keyboard navigation (Up/Down/Enter/Escape/Tab)
        - Recently used types section
        - Description footer with inheritance chain
        - Scroll indicator (visual scrollbar)

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

    DIALOG_WIDTH = 300.0
    DIALOG_HEIGHT = 480.0
    ROW_HEIGHT = 22.0
    CATEGORY_HEIGHT = 24.0
    HEADER_HEIGHT = 50.0
    FOOTER_HEIGHT = 50.0
    SCROLLBAR_WIDTH = 4.0

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.modal = True
        self.dismiss_on_outside_click = True
        self.pause_tree_when_modal = False
        self.top_level = True
        self.visible = False
        self.size = Vec2(self.DIALOG_WIDTH, self.DIALOG_HEIGHT)
        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 = ""
        # Visible rows: list of ("category", cat_name, None) or ("type", name, cls).
        # ``cls`` is either a live ``type`` (built-in) or a ``ProjectClass``
        # record (user-defined, resolved on selection).
        self._rows: list[tuple[str, str, object | None]] = []
        self._hovered_index = -1
        self._selected_index = -1  # keyboard selection
        self._scroll_offset = 0.0
        self._collapsed: set[str] = set()  # collapsed category names
        self._focus_in_list = False  # True when keyboard focus is in the type list

        # 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 = TextEdit(placeholder="Filter types...", name="AddNodeFilter")
        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.add_child(self._filter_edit)

        # 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+click)

        self._rebuild_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._hovered_index = -1
        self._selected_index = -1
        self._scroll_offset = 0.0
        self._focus_in_list = False
        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()
        self.show_modal()
        if self._tree:
            # Override the default first-focusable focus with the filter edit.
            self._tree._set_focused_control(self._filter_edit)

    def dismiss(self):
        if not self.visible:
            return
        self._hovered_index = -1
        self._selected_index = -1
        self.close_modal()

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

    # -- keyboard navigation --

    def _next_type_index(self, start: int, direction: int = 1) -> int:
        """Find the next type row index from *start* in *direction* (1=down, -1=up).

        Skips category header rows.  Returns -1 if no type row found.
        """
        idx = start + direction
        while 0 <= idx < len(self._rows):
            if self._rows[idx][0] == "type":
                return idx
            idx += direction
        return -1

    def _ensure_selected_visible(self):
        """Scroll so the keyboard-selected row is within the visible list area."""
        if self._selected_index < 0:
            return
        list_h = self.DIALOG_HEIGHT - self.HEADER_HEIGHT - self.FOOTER_HEIGHT
        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 + list_h:
            self._scroll_offset = row_bot - list_h

    def _confirm_selection(self, shift: bool = False):
        """Confirm the currently keyboard-selected type."""
        if 0 <= self._selected_index < len(self._rows):
            kind, name, cls = self._rows[self._selected_index]
            if kind == "type" and cls is not None:
                if shift:
                    self.type_place_chosen.emit(cls)
                else:
                    if isinstance(cls, type):
                        _record_recent_type(cls)
                    self.type_chosen.emit(cls)
                self.dismiss()

    # -- row layout helpers --

    def _row_height(self, idx: int) -> float:
        """Return the pixel height of 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:
        """Return cumulative Y offset to the top of row *idx*."""
        y = 0.0
        for i in range(idx):
            y += self._row_height(i)
        return y

    def _total_content_height(self) -> float:
        """Total pixel height of all visible rows."""
        return sum(self._row_height(i) for i in range(len(self._rows)))

    def _hit_test_row(self, local_y: float) -> int | None:
        """Given a Y offset within the list area, return the row index or None."""
        y = 0.0
        for i in range(len(self._rows)):
            h = self._row_height(i)
            if y <= local_y < y + h:
                return i
            y += h
        return None

    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._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._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._selected_index if self._selected_index >= 0 else self._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]]:
        """Backward-compatible flat list of visible (name, cls) tuples (excludes category headers)."""
        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._hovered_index = -1
        self._selected_index = -1
        self._scroll_offset = 0.0
        self._rebuild_rows()

    def _on_gui_input(self, event):
        """Handle keyboard navigation, hover tracking, scroll, and clicks."""
        if not self.visible:
            return

        # Mouse press: route based on click region (filter area vs list area).
        if event.button == MouseButton.LEFT and event.pressed and event.position is not None:
            px = event.position.x if hasattr(event.position, "x") else event.position[0]
            py = event.position.y if hasattr(event.position, "y") else event.position[1]
            gx, gy, gw, gh = self.get_global_rect()

            # Click outside the dialog rect: dismiss.
            if not (gx <= px < gx + gw and gy <= py < gy + gh):
                self.dismiss()
                event.handled = True
                return

            list_y_start = gy + self.HEADER_HEIGHT
            if py < list_y_start:
                self._focus_in_list = False
                if self._tree:
                    self._tree._set_focused_control(self._filter_edit)
                return

            footer_y = gy + gh - self.FOOTER_HEIGHT
            if list_y_start <= py < footer_y:
                self._focus_in_list = True
                row_idx = self._hit_test_row(py - list_y_start + self._scroll_offset)
                if row_idx is not None and 0 <= row_idx < len(self._rows):
                    kind, name, cls = self._rows[row_idx]
                    if kind == "category":
                        if name in self._collapsed:
                            self._collapsed.discard(name)
                        else:
                            self._collapsed.add(name)
                        self._rebuild_rows()
                        event.handled = True
                        return
                    if kind == "type" and cls is not None:
                        self._selected_index = row_idx
                        shift = getattr(event, "shift", False)
                        if shift:
                            self.type_place_chosen.emit(cls)
                        else:
                            if isinstance(cls, type):
                                _record_recent_type(cls)
                            self.type_chosen.emit(cls)
                        self.dismiss()
                        event.handled = True
                        return

        # Handle keyboard events (key presses only, not releases or char events)
        if event.key and event.button is None and event.pressed:
            key = event.key
            if key == "escape":
                self.dismiss()
                return
            if key == "down":
                start = self._selected_index if self._selected_index >= 0 else -1
                nxt = self._next_type_index(start, 1)
                if nxt >= 0:
                    self._selected_index = nxt
                    self._focus_in_list = True
                    self._ensure_selected_visible()
                return
            if key == "up":
                if self._selected_index >= 0:
                    nxt = self._next_type_index(self._selected_index, -1)
                    if nxt >= 0:
                        self._selected_index = nxt
                        self._ensure_selected_visible()
                    else:
                        # At top of list -- move focus back to filter
                        self._selected_index = -1
                        self._focus_in_list = False
                        if self._tree:
                            self._tree._set_focused_control(self._filter_edit)
                return
            if key in ("enter", "return"):
                if self._selected_index >= 0:
                    self._confirm_selection()
                    return
            if key == "tab":
                if self._focus_in_list:
                    # Tab back to filter
                    self._focus_in_list = False
                    self._selected_index = -1
                    if self._tree:
                        self._tree._set_focused_control(self._filter_edit)
                else:
                    # Tab into list -- select first type row
                    nxt = self._next_type_index(-1, 1)
                    if nxt >= 0:
                        self._selected_index = nxt
                        self._focus_in_list = True
                        self._ensure_selected_visible()
                return
            if key in ("scroll_up", "scroll_down"):
                delta = -30.0 if key == "scroll_up" else 30.0
                list_h = self.DIALOG_HEIGHT - self.HEADER_HEIGHT - self.FOOTER_HEIGHT
                max_scroll = max(0.0, self._total_content_height() - list_h)
                self._scroll_offset = max(0.0, min(max_scroll, self._scroll_offset + delta))
                return

        # Forward char events to filter (typing text)
        if event.char:
            self._filter_edit._internal_gui_input(event)
            return

        # Forward non-navigation key events to filter (e.g. backspace)
        if event.key and event.button is None:
            if event.key not in ("escape", "down", "up", "enter", "return", "tab", "scroll_up", "scroll_down"):
                self._filter_edit._internal_gui_input(event)
            return

        # Mouse move -- hover tracking
        py = (
            event.position[1]
            if isinstance(event.position, tuple | list)
            else (event.position.y if hasattr(event.position, "y") else 0)
        )

        gx, gy, gw, gh = self.get_global_rect()
        list_y_start = gy + self.HEADER_HEIGHT
        footer_y = gy + gh - self.FOOTER_HEIGHT

        # Hit-test rows for hover highlighting
        if list_y_start <= py < footer_y:
            row_idx = self._hit_test_row(py - list_y_start + self._scroll_offset)
            if row_idx is not None and 0 <= row_idx < len(self._rows) and self._rows[row_idx][0] == "type":
                self._hovered_index = row_idx
            else:
                self._hovered_index = -1
        else:
            self._hovered_index = -1

    def on_draw(self, renderer):
        """Draw the dialog (top_level 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 / 14.0)
        renderer.draw_text("Shift = place", (gx + gw - 80, gy + 4), colour=(0.5, 0.5, 0.5, 1.0), scale=0.55)

        # Position and draw filter edit (below title)
        self._filter_edit.position = Vec2(6, 20)
        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 / 14.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._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._selected_index)
                is_hovered = (i == self._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."""

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.visible = False
        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