Source code for simvx.core.ui.tree

"""TreeView -- hierarchical tree display with expand/collapse.

TreeItem is a lightweight data node. TreeView renders the hierarchy
with indentation, selection highlight, and expand/collapse arrows.
Uses virtual scrolling: only visible rows are drawn each frame.
"""

import logging
from typing import Any

from ..descriptors import Signal
from ..math.types import Vec2
from .core import Control, ThemeColour

log = logging.getLogger(__name__)

__all__ = ["TreeItem", "TreeView"]


[docs] class TreeItem: """Data node for a tree hierarchy. Attributes: text: Display text for this item. children: Child items. expanded: Whether children are visible. data: Arbitrary user data attached to this item. parent: Parent TreeItem (set internally by add_child/remove_child). badge_text: Optional trailing icon/text drawn at the right edge of the row (e.g. an error indicator). Hit-tested separately so panels can respond to badge clicks via ``TreeView.item_badge_clicked``. badge_tooltip: Tooltip text shown when hovering the badge region. Example: root = TreeItem("Root") root.add_child(TreeItem("Child A")) root.add_child(TreeItem("Child B", data={"key": 42})) """ __slots__ = ("text", "children", "expanded", "data", "parent", "badge_text", "badge_tooltip") def __init__( self, text: str = "", children: list[TreeItem] | None = None, expanded: bool = True, data: Any = None, badge_text: str = "", badge_tooltip: str = "", ): self.text = text self.expanded = expanded self.data = data self.parent: TreeItem | None = None self.children: list[TreeItem] = [] self.badge_text = badge_text self.badge_tooltip = badge_tooltip if children: for child in children: self.add_child(child)
[docs] def add_child(self, item: TreeItem) -> TreeItem: """Add a child item and set its parent.""" item.parent = self self.children.append(item) return item
[docs] def remove_child(self, item: TreeItem): """Remove a child item and clear its parent.""" if item in self.children: item.parent = None self.children.remove(item)
[docs] def __repr__(self) -> str: return f"TreeItem({self.text!r}, children={len(self.children)})"
[docs] class TreeView(Control): """Scrollable tree widget with expand/collapse and selection. Renders a TreeItem hierarchy with indentation, clickable expand/collapse arrows, and selection highlight. Uses virtual scrolling -- only rows visible in the viewport are drawn. Example: root = TreeItem("Scene") root.add_child(TreeItem("Player")) root.add_child(TreeItem("Enemies", children=[ TreeItem("Goblin"), TreeItem("Dragon"), ])) tree = TreeView(root=root) tree.item_selected.connect(lambda item: print(item.text)) """ _draw_caching = True _draws_children = True bg_colour = ThemeColour("tree_bg") text_colour = ThemeColour("text") select_colour = ThemeColour("tree_select") hover_colour = ThemeColour("tree_hover") arrow_colour = ThemeColour("tree_arrow") def __init__(self, root: TreeItem | None = None, **kwargs): super().__init__(**kwargs) self._root: TreeItem | None = root self.selected: TreeItem | None = None self.indent = 20.0 self.row_height = 22.0 self.font_size = 14.0 self._hovered_item: TreeItem | None = None self._row_map: list[tuple[TreeItem, float, float, int]] = [] self._scroll_y: float = 0.0 self._content_height: float = 0.0 # Virtual scroll: flattened tree cache self._flat_rows: list[tuple[TreeItem, int]] | None = None # (item, depth) # Signals self.item_selected = Signal() self.item_expanded = Signal() self.item_collapsed = Signal() # Emitted when a user clicks the trailing badge region of a row. Used # by the scene tree to navigate from an error icon to its source. self.item_badge_clicked = Signal() # badge-region hit-test overlays, parallel to _row_map self._badge_rects: list[tuple[TreeItem, float, float, float, float]] = [] self.size = Vec2(250, 300) # -------------------------------------------------------------- root property @property def root(self) -> TreeItem | None: return self._root
[docs] @root.setter def root(self, value: TreeItem | None): self._root = value self._flat_rows = None self._row_map.clear() if hasattr(self, "_draw_dirty"): self.queue_redraw()
# -------------------------------------------------------- flat row management def _invalidate_flat_rows(self): """Mark the flattened row cache as stale and request redraw.""" self._flat_rows = None self._row_map.clear() self.queue_redraw() def _ensure_flat_rows(self): """Rebuild the flat row list if stale.""" if self._flat_rows is not None: return self._flat_rows = [] if self._root: self._flatten(self._root, 0) def _flatten(self, item: TreeItem, depth: int): """Recursively flatten visible tree items into _flat_rows.""" self._flat_rows.append((item, depth)) if item.expanded: for child in item.children: self._flatten(child, depth + 1) # ------------------------------------------------------------------- input def _on_gui_input(self, event): # Scroll handling if event.key == "scroll_up": self._scroll_y = max(0, self._scroll_y - self.row_height * 2) self.queue_redraw() return if event.key == "scroll_down": _, _, _, h = self.get_global_rect() max_scroll = max(0, self._content_height - h) self._scroll_y = min(max_scroll, self._scroll_y + self.row_height * 2) self.queue_redraw() return # Mouse-move hover tracking (button=0, no key/char) if event.button == 0 and not event.key and not event.char: self._resolve_hover(event.position) return if event.button != 1 or not event.pressed: return if not self._row_map: return 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] # Badge click: takes priority over row selection so clicking an error # icon dispatches navigation instead of just selecting the node. for item, bx, by, bw, bh in self._badge_rects: if bx <= px <= bx + bw and by <= py <= by + bh: self.item_badge_clicked.emit(item) return for item, row_x, row_y, depth in self._row_map: if row_y <= py < row_y + self.row_height: # Check if click is on the expand/collapse arrow area arrow_x = row_x + depth * self.indent if item.children and arrow_x <= px < arrow_x + self.row_height: item.expanded = not item.expanded self._invalidate_flat_rows() if item.expanded: self.item_expanded.emit(item) else: self.item_collapsed.emit(item) else: self.selected = item self.queue_redraw() self.item_selected.emit(item) break def _resolve_hover(self, position): """Set _hovered_item from mouse position using _row_map.""" py = position.y if hasattr(position, "y") else position[1] old = self._hovered_item self._hovered_item = None for item, _rx, row_y, _depth in self._row_map: if row_y <= py < row_y + self.row_height: self._hovered_item = item break if self._hovered_item is not old: self.queue_redraw() def _update_mouse_over(self, mouse_pos): """Override to clear hover highlight when the mouse leaves the widget.""" super()._update_mouse_over(mouse_pos) if not self.mouse_over and self._hovered_item is not None: self._hovered_item = None self.queue_redraw() # -------------------------------------------------------------------- draw
[docs] def draw(self, renderer): x, y, w, h = self.get_global_rect() # Background renderer.draw_rect((x, y), (w, h), colour=self.bg_colour, filled=True) # Clip to widget bounds renderer.push_clip(x, y, w, h) self._draw_visible_rows(renderer, x, y, w, h) # Scrollbar thumb (if content overflows) if self._content_height > h and h > 0: sb_w = 6.0 sb_x = x + w - sb_w - 2 visible_ratio = h / self._content_height thumb_h = max(20.0, h * visible_ratio) max_scroll = self._content_height - h scroll_ratio = self._scroll_y / max_scroll if max_scroll > 0 else 0 thumb_y = y + scroll_ratio * (h - thumb_h) renderer.draw_rect((sb_x, thumb_y), (sb_w, thumb_h), colour=(0.4, 0.4, 0.4, 0.5), filled=True) renderer.pop_clip()
def _draw_visible_rows(self, renderer, x: float, y: float, w: float, h: float): """Draw only the rows visible in the viewport defined by (x, y, w, h). Populates ``_row_map`` with the visible rows for hit testing. Can be called externally (e.g. by FileBrowser) to render the tree into a custom region. """ self._ensure_flat_rows() total_rows = len(self._flat_rows) self._content_height = total_rows * self.row_height # Compute visible range first_visible = max(0, int(self._scroll_y / self.row_height)) visible_count = int(h / self.row_height) + 2 # +2 for partial rows at top/bottom last_visible = min(total_rows, first_visible + visible_count) # Build row map for hit testing (only visible rows) self._row_map.clear() self._badge_rects.clear() scale = self.font_size / 14.0 for i in range(first_visible, last_visible): item, depth = self._flat_rows[i] row_y = y + i * self.row_height - self._scroll_y indent_px = depth * self.indent row_x = x + indent_px # Store for hit testing self._row_map.append((item, x, row_y, depth)) # Selection / hover highlight if item is self.selected: renderer.draw_rect((x, row_y), (w, self.row_height), colour=self.select_colour, filled=True) elif item is self._hovered_item: renderer.draw_rect((x, row_y), (w, self.row_height), colour=self.hover_colour, filled=True) # Expand/collapse arrow if item.children: arrow_cx = row_x + self.row_height * 0.5 arrow_cy = row_y + self.row_height * 0.5 half = 4.0 if item.expanded: # Down-pointing triangle v renderer.draw_line( (arrow_cx - half, arrow_cy - half * 0.5), (arrow_cx, arrow_cy + half * 0.5), colour=self.arrow_colour, ) renderer.draw_line( (arrow_cx, arrow_cy + half * 0.5), (arrow_cx + half, arrow_cy - half * 0.5), colour=self.arrow_colour, ) else: # Right-pointing triangle > renderer.draw_line( (arrow_cx - half * 0.5, arrow_cy - half), (arrow_cx + half * 0.5, arrow_cy), colour=self.arrow_colour, ) renderer.draw_line( (arrow_cx + half * 0.5, arrow_cy), (arrow_cx - half * 0.5, arrow_cy + half), colour=self.arrow_colour, ) # Item text text_x = row_x + self.row_height # leave space for arrow text_y = row_y + (self.row_height - self.font_size) / 2 renderer.draw_text(item.text, (text_x, text_y), colour=self.text_colour, scale=scale) # Trailing badge (clickable) — drawn at the right edge of the row # and recorded in _badge_rects for hit testing in _on_gui_input. if item.badge_text: badge_w = renderer.text_width(item.badge_text, scale) + 10.0 badge_x = x + w - badge_w - 6.0 badge_bg = (0.6, 0.2, 0.2, 0.35) renderer.draw_rect((badge_x, row_y + 2), (badge_w, self.row_height - 4), colour=badge_bg, filled=True) renderer.draw_text(item.badge_text, (badge_x + 5, text_y), colour=(1.0, 0.85, 0.3, 1.0), scale=scale) self._badge_rects.append((item, badge_x, row_y + 2, badge_w, self.row_height - 4))