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