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 Property
from ..input.enums import MouseButton
from ..signals import Signal
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. A view already displaying this item's tree keeps its rows until :meth:`TreeView.refresh` is called. """ item.parent = self self.children.append(item) return item
[docs] def remove_child(self, item: TreeItem): """Remove a child item and clear its parent. A view already displaying this item's tree keeps its rows until :meth:`TreeView.refresh` is called. """ 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. Invalidation is explicit: after editing the item graph (adding, removing or re-labelling items, or setting ``expanded`` in code), call :meth:`refresh` for the change to reach the screen. 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 indent = Property(20.0, range=(0, 200), hint="Horizontal offset per depth level, in pixels") row_height = Property(22.0, range=(8, 200), hint="Height of one row, in pixels") font_size = Property(14.0, range=(8, 72), hint="Font size") size_x = Property(250.0, range=(0, 10000), hint="Control width", on_change="_on_size_changed") size_y = Property(300.0, range=(0, 10000), hint="Control height", on_change="_on_size_changed") 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._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]] = [] # -------------------------------------------------------------- root property @property def root(self) -> TreeItem | None: return self._root
[docs] @root.setter def root(self, value: TreeItem | None): self._root = value self.refresh()
# -------------------------------------------------------- flat row management
[docs] def refresh(self): """Rebuild the visible rows from the current ``TreeItem`` graph. The widget flattens the hierarchy into a row cache and reuses it every frame, so an item added, removed or re-labelled after the first draw is invisible until the cache is dropped. Call this once after a batch of edits:: branch.add_child(TreeItem("New")) branch.expanded = True tree.refresh() Assigning :attr:`root` refreshes too, and clicking a row's arrow refreshes itself; ``refresh()`` is how code says it without handing the same root back. A redraw is requested as part of the call. """ 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() event.handled = True 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() event.handled = True return # Mouse-move hover tracking (button=0, no key/char) if event.button is None and not event.key and not event.char: self._resolve_hover(event.position) return if event.button != MouseButton.LEFT or not event.pressed: return if not self._row_map: return px = event.position.x py = event.position.y # 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) event.handled = True 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.refresh() 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) event.handled = True 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 on_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 / 16.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))