Source code for simvx.core.ui.rich_text

"""Rich text widgets — RichTextLabel, OutputPanel, ConsoleWidget.

RichTextLabel renders ANSI-styled text with coloured spans.
OutputPanel wraps it with a header bar for command output display.
ConsoleWidget adds an input line with command history.
"""


from __future__ import annotations

import logging

from ..descriptors import Property, Signal
from ..math.types import Vec2
from .ansi_parser import StyledSpan, parse_ansi
from .core import Colour, Control, ThemeColour

log = logging.getLogger(__name__)

__all__ = ["RichTextLabel", "OutputPanel", "ConsoleWidget"]

# Layout constants
_PADDING = 4.0
_HEADER_HEIGHT = 24.0
_INPUT_HEIGHT = 28.0
_SCROLLBAR_WIDTH = 8.0
_SCROLL_STEP = 20.0


def _brighten(colour: tuple[float, float, float, float], amount: float = 0.15) -> tuple[float, float, float, float]:
    """Brighten a colour (for bold text rendering)."""
    return (min(1.0, colour[0] + amount), min(1.0, colour[1] + amount), min(1.0, colour[2] + amount), colour[3])


# ============================================================================
# RichTextLabel — ANSI-styled read-only text display
# ============================================================================


[docs] class RichTextLabel(Control): """Read-only text display that renders ANSI-styled text with coloured spans. Supports multi-line text with per-span colouring from ANSI escape codes. Auto-scrolls to bottom when new text is appended. Scrollable via mouse wheel. Example: label = RichTextLabel() label.text = "\\033[32mSuccess\\033[0m: operation completed" label.append("\\033[31mError\\033[0m: file not found") """ font_size = Property(14.0, range=(8, 72), hint="Font size") wrap = Property(False, hint="Word wrap long lines") max_lines = Property(1000, range=(1, 100000), hint="Maximum stored lines") text_colour = ThemeColour("text") bg_colour = ThemeColour("bg_darkest") def __init__(self, text: str = "", **kwargs): super().__init__(**kwargs) self.font_size = 14.0 self.wrap = False self.max_lines = 1000 # Internal state self._lines: list[str] = [] # raw lines (with ANSI codes) self._parsed_lines: list[list[StyledSpan]] = [] # parsed span lists per line self._scroll_y: float = 0.0 # pixel offset self._auto_scroll: bool = True self._dragging_scrollbar: bool = False self._drag_start_y: float = 0.0 self._drag_start_scroll: float = 0.0 self._rect_override: tuple[float, float, float, float] | None = None self.size = Vec2(400, 200) if text: self.text = text # ---------------------------------------------------------------- # text property # ---------------------------------------------------------------- @property def text(self) -> str: return "\n".join(self._lines) @text.setter def text(self, value: str): self._lines = value.split("\n") if value else [] self._trim_lines() self._reparse() if self._auto_scroll: self._scroll_to_bottom() # ---------------------------------------------------------------- # Public API # ----------------------------------------------------------------
[docs] def append(self, text: str): """Append text (with automatic newline between calls).""" new_lines = text.split("\n") self._lines.extend(new_lines) # Parse only new lines incrementally new_parsed = [parse_ansi(line) if line else [] for line in new_lines] self._parsed_lines.extend(new_parsed) # Trim if over max_lines (adjusts both lists) if len(self._lines) > self.max_lines: excess = len(self._lines) - self.max_lines del self._lines[:excess] del self._parsed_lines[:excess] if self._auto_scroll: self._scroll_to_bottom()
[docs] def clear(self): """Clear all text content.""" self._lines.clear() self._parsed_lines.clear() self._scroll_y = 0.0
[docs] def line_height(self) -> float: """Line height in pixels based on current font size.""" return self.font_size * 1.4
@property def scroll_y(self) -> float: """Vertical scroll offset in pixels.""" return self._scroll_y @scroll_y.setter def scroll_y(self, value: float): self._scroll_y = value @property def auto_scroll(self) -> bool: """Whether new content auto-scrolls to bottom.""" return self._auto_scroll @auto_scroll.setter def auto_scroll(self, value: bool): self._auto_scroll = value @property def rect_override(self) -> tuple[float, float, float, float] | None: """Override rect for layout/drawing (x, y, w, h). None uses global rect.""" return self._rect_override @rect_override.setter def rect_override(self, value: tuple[float, float, float, float] | None): self._rect_override = value # ---------------------------------------------------------------- # Internal # ---------------------------------------------------------------- def _trim_lines(self): """Remove oldest lines if max_lines exceeded.""" if len(self._lines) > self.max_lines: excess = len(self._lines) - self.max_lines del self._lines[:excess] def _reparse(self): """Re-parse all lines into styled spans.""" self._parsed_lines = [parse_ansi(line) if line else [] for line in self._lines] def _font_scale(self) -> float: return self.font_size / 14.0 def _content_height(self) -> float: """Total content height in pixels.""" return len(self._parsed_lines) * self.line_height() def _max_scroll(self) -> float: """Maximum scroll offset in pixels.""" _, _, _, h = self._effective_rect() return max(0.0, self._content_height() - h + _PADDING * 2) def _clamp_scroll(self): self._scroll_y = max(0.0, min(self._scroll_y, self._max_scroll())) def _scroll_to_bottom(self): self._scroll_y = self._max_scroll() # ---------------------------------------------------------------- # Scrollbar geometry # ---------------------------------------------------------------- def _scrollbar_needed(self) -> bool: _, _, _, h = self._effective_rect() return self._content_height() > h - _PADDING * 2 def _scrollbar_thumb_rect(self) -> tuple[float, float, float, float]: x, y, w, h = self._effective_rect() content_h = self._content_height() if content_h <= h: return (0, 0, 0, 0) ratio = h / content_h thumb_h = max(20.0, h * ratio) max_scroll = self._max_scroll() scroll_ratio = self._scroll_y / max_scroll if max_scroll > 0 else 0.0 thumb_y = y + scroll_ratio * (h - thumb_h) return (x + w - _SCROLLBAR_WIDTH, thumb_y, _SCROLLBAR_WIDTH, thumb_h) # ---------------------------------------------------------------- # Input # ---------------------------------------------------------------- def _on_gui_input(self, event): if event.key == "scroll_up": self._auto_scroll = False self._scroll_y -= _SCROLL_STEP self._clamp_scroll() return if event.key == "scroll_down": self._scroll_y += _SCROLL_STEP self._clamp_scroll() # Re-enable auto-scroll if at bottom if self._scroll_y >= self._max_scroll() - 1.0: self._auto_scroll = True return # Scrollbar drag if event.button == 1: if event.pressed: sx, sy, sw, sh = self._scrollbar_thumb_rect() if sw > 0: 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] if sx <= px <= sx + sw and sy <= py <= sy + sh: self._dragging_scrollbar = True self._drag_start_y = py self._drag_start_scroll = self._scroll_y self._auto_scroll = False return else: if self._dragging_scrollbar: self._dragging_scrollbar = False if self._scroll_y >= self._max_scroll() - 1.0: self._auto_scroll = True return if self._dragging_scrollbar and event.position: py = event.position.y if hasattr(event.position, "y") else event.position[1] _, _, _, h = self._effective_rect() content_h = self._content_height() if content_h > h: thumb_h = max(20.0, h * (h / content_h)) usable = h - thumb_h if usable > 0: delta = (py - self._drag_start_y) / usable * self._max_scroll() self._scroll_y = self._drag_start_scroll + delta self._clamp_scroll() # ---------------------------------------------------------------- # Drawing # ---------------------------------------------------------------- def _effective_rect(self) -> tuple[float, float, float, float]: return self._rect_override if self._rect_override else self.get_global_rect()
[docs] def draw(self, renderer): x, y, w, h = self._effective_rect() scale = self._font_scale() lh = self.line_height() has_scrollbar = self._scrollbar_needed() text_area_w = w - ((_SCROLLBAR_WIDTH + 2) if has_scrollbar else 0) # Background renderer.draw_filled_rect(x, y, w, h, self.bg_colour) # Clip text area renderer.push_clip(x, y, text_area_w, h) # Determine visible line range first_line = max(0, int(self._scroll_y / lh)) last_line = min(len(self._parsed_lines), first_line + int(h / lh) + 2) for li in range(first_line, last_line): spans = self._parsed_lines[li] line_y = y + _PADDING + li * lh - self._scroll_y if line_y + lh < y or line_y > y + h: continue text_y = line_y + (lh - self.font_size) / 2 text_x = x + _PADDING for span in spans: if not span.text: continue colour = _brighten(span.colour) if span.bold else span.colour renderer.draw_text_coloured(span.text, text_x, text_y, scale, colour) text_x += renderer.text_width(span.text, scale) renderer.pop_clip() # Scrollbar if has_scrollbar: theme = self.get_theme() # Track renderer.draw_filled_rect(x + w - _SCROLLBAR_WIDTH, y, _SCROLLBAR_WIDTH, h, theme.scrollbar_track) # Thumb sx, sy, sw, sh = self._scrollbar_thumb_rect() if sw > 0: colour = theme.scrollbar_hover if self._dragging_scrollbar else theme.scrollbar_fg renderer.draw_filled_rect(sx, sy, sw, sh, colour)
# ============================================================================ # OutputPanel — Command output display with header # ============================================================================
[docs] class OutputPanel(Control): """Panel that displays command output with ANSI colour support. Includes a header bar with title and clear button. Wraps a RichTextLabel for styled text rendering. Example: panel = OutputPanel(title="Build Output") panel.write("\\033[32mCompiling...\\033[0m") panel.write("\\033[31mError: undefined reference\\033[0m") """ show_header = Property(True, hint="Show header bar") auto_scroll = Property(True, hint="Auto-scroll to bottom") bg_colour = ThemeColour("bg_darkest") header_bg_colour = ThemeColour("bg_darker") header_text_colour = ThemeColour("text_label") border_colour = ThemeColour("border") def __init__(self, title: str = "Output", **kwargs): super().__init__(**kwargs) self.title = title self.show_header = True self.auto_scroll = True self.font_size = 14.0 self._clear_hovered = False # Internal rich text label self._rich_label = RichTextLabel() self._rich_label.bg_colour = self.bg_colour self._rect_override: tuple[float, float, float, float] | None = None self.size = Vec2(400, 200) @property def rect_override(self) -> tuple[float, float, float, float] | None: """Override rect for layout/drawing (x, y, w, h). None uses global rect.""" return self._rect_override @rect_override.setter def rect_override(self, value: tuple[float, float, float, float] | None): self._rect_override = value def _effective_rect(self) -> tuple[float, float, float, float]: return self._rect_override if self._rect_override else self.get_global_rect()
[docs] def write(self, text: str): """Append output text.""" self._rich_label.auto_scroll = self.auto_scroll self._rich_label.append(text)
[docs] def clear(self): """Clear all output.""" self._rich_label.clear()
def _header_height(self) -> float: return _HEADER_HEIGHT if self.show_header else 0.0 def _clear_button_rect(self) -> tuple[float, float, float, float]: """Get the clear button rect in the header.""" x, y, w, _ = self._effective_rect() btn_w = 50.0 btn_h = _HEADER_HEIGHT - 4 return (x + w - btn_w - 4, y + 2, btn_w, btn_h) def _on_gui_input(self, event): # Check clear button click if self.show_header and event.button == 1: bx, by, bw, bh = self._clear_button_rect() if event.position: 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] if bx <= px <= bx + bw and by <= py <= by + bh: if not event.pressed: self.clear() return # Forward scroll and scrollbar events to the rich label # Adjust the label's rect to match the content area self._sync_label_rect() self._rich_label._on_gui_input(event) def _sync_label_rect(self): """Sync internal label geometry with our content area.""" x, y, w, h = self._effective_rect() hh = self._header_height() self._rich_label.position = Vec2(self.position.x, self.position.y) self._rich_label.size = Vec2(w, h - hh) self._rich_label.font_size = self.font_size self._rich_label.bg_colour = self.bg_colour self._rich_label.rect_override = (x, y + hh, w, h - hh)
[docs] def draw(self, renderer): x, y, w, h = self._effective_rect() hh = self._header_height() scale = self.font_size / 14.0 # Background renderer.draw_filled_rect(x, y, w, h, self.bg_colour) # Header if self.show_header: renderer.draw_filled_rect(x, y, w, hh, self.header_bg_colour) text_y = y + (hh - self.font_size) / 2 renderer.draw_text_coloured(self.title, x + _PADDING, text_y, scale, self.header_text_colour) bx, by, bw, bh = self._clear_button_rect() btn_bg = (0.24, 0.24, 0.27, 1.0) if self._clear_hovered else (0.17, 0.17, 0.19, 1.0) renderer.draw_filled_rect(bx, by, bw, bh, btn_bg) clear_text = "Clear" tw = renderer.text_width(clear_text, scale * 0.85) renderer.draw_text_coloured( clear_text, bx + (bw - tw) / 2, by + (bh - self.font_size * 0.85) / 2, scale * 0.85, (0.56, 0.56, 0.58, 1.0), ) renderer.draw_line_coloured(x, y + hh, x + w, y + hh, self.border_colour) # Draw rich text content self._sync_label_rect() self._rich_label.draw(renderer) # Border renderer.draw_rect_coloured(x, y, w, h, self.border_colour)
# ============================================================================ # ConsoleWidget — Interactive console with output and input # ============================================================================
[docs] class ConsoleWidget(Control): """Interactive console with output display and input line. Displays command output in a RichTextLabel with ANSI colour support. Single-line input at the bottom with command history navigation. Example: console = ConsoleWidget() console.command_entered.connect(lambda cmd: console.write(f">>> {cmd}")) console.write("Welcome to SimVX console.") """ prompt = Property(">>> ", hint="Input prompt string") max_history = Property(100, range=(1, 10000), hint="Max command history entries") bg_colour = ThemeColour("bg_darkest") input_bg_colour = ThemeColour("bg_input") input_text_colour = ThemeColour("text") prompt_colour = ThemeColour("success") border_colour = ThemeColour("border") focus_colour = ThemeColour("input_focus") def __init__(self, **kwargs): super().__init__(**kwargs) self.prompt = ">>> " self.max_history = 100 self.font_size = 14.0 # Internal output panel self._output = OutputPanel(title="Console") self._output.font_size = self.font_size # Input state self._input_text: str = "" self._cursor_pos: int = 0 self._cursor_blink: float = 0.0 # Command history self._history: list[str] = [] self._history_index: int = -1 # -1 = not browsing history self._saved_input: str = "" # saved input when browsing history # Signals self.command_entered = Signal() self.size = Vec2(400, 300) # ---------------------------------------------------------------- # Public API # ----------------------------------------------------------------
[docs] def write(self, text: str): """Add text to the output display.""" self._output.write(text)
[docs] def clear(self): """Clear the output display.""" self._output.clear()
# ---------------------------------------------------------------- # Geometry # ---------------------------------------------------------------- def _input_rect(self) -> tuple[float, float, float, float]: """Get the input line rect.""" x, y, w, h = self.get_global_rect() return (x, y + h - _INPUT_HEIGHT, w, _INPUT_HEIGHT) def _output_rect(self) -> tuple[float, float, float, float]: """Get the output area rect.""" x, y, w, h = self.get_global_rect() return (x, y, w, h - _INPUT_HEIGHT) def _font_scale(self) -> float: return self.font_size / 14.0 # ---------------------------------------------------------------- # Input handling # ---------------------------------------------------------------- def _on_gui_input(self, event): # Focus on click if event.button == 1 and event.pressed: if self.is_point_inside(event.position): self.set_focus() # Forward scroll events to output panel if event.key in ("scroll_up", "scroll_down"): self._sync_output_rect() self._output._rich_label._on_gui_input(event) return if not self.focused: return # Keyboard input if event.key and event.pressed: self._handle_key(event.key) return # Character input if event.char and len(event.char) == 1: self._input_text = self._input_text[: self._cursor_pos] + event.char + self._input_text[self._cursor_pos :] self._cursor_pos += 1 self._cursor_blink = 0.0 def _handle_key(self, key: str): self._cursor_blink = 0.0 if key == "enter": cmd = self._input_text if cmd: self._history.append(cmd) if len(self._history) > self.max_history: del self._history[0] self._history_index = -1 self._input_text = "" self._cursor_pos = 0 self.command_entered.emit(cmd) return if key == "backspace": if self._cursor_pos > 0: self._input_text = self._input_text[: self._cursor_pos - 1] + self._input_text[self._cursor_pos :] self._cursor_pos -= 1 return if key == "delete": if self._cursor_pos < len(self._input_text): self._input_text = self._input_text[: self._cursor_pos] + self._input_text[self._cursor_pos + 1 :] return if key == "left": self._cursor_pos = max(0, self._cursor_pos - 1) return if key == "right": self._cursor_pos = min(len(self._input_text), self._cursor_pos + 1) return if key == "home": self._cursor_pos = 0 return if key == "end": self._cursor_pos = len(self._input_text) return if key == "up": if not self._history: return if self._history_index == -1: self._saved_input = self._input_text self._history_index = len(self._history) - 1 elif self._history_index > 0: self._history_index -= 1 self._input_text = self._history[self._history_index] self._cursor_pos = len(self._input_text) return if key == "down": if self._history_index == -1: return if self._history_index < len(self._history) - 1: self._history_index += 1 self._input_text = self._history[self._history_index] else: self._history_index = -1 self._input_text = self._saved_input self._cursor_pos = len(self._input_text) return # ---------------------------------------------------------------- # Process # ----------------------------------------------------------------
[docs] def process(self, dt: float): self._cursor_blink += dt if self._cursor_blink > 1.0: self._cursor_blink = 0.0
# ---------------------------------------------------------------- # Drawing # ---------------------------------------------------------------- def _sync_output_rect(self): """Sync the internal output panel geometry.""" ox, oy, ow, oh = self._output_rect() self._output.size = Vec2(ow, oh) self._output.font_size = self.font_size self._output.bg_colour = self.bg_colour self._output.rect_override = (ox, oy, ow, oh) hh = self._output._header_height() self._output._rich_label.rect_override = (ox, oy + hh, ow, oh - hh)
[docs] def draw(self, renderer): x, y, w, h = self.get_global_rect() scale = self._font_scale() ix, iy, iw, ih = self._input_rect() # Draw output panel self._sync_output_rect() self._output.draw(renderer) # Input area background renderer.draw_filled_rect(ix, iy, iw, ih, self.input_bg_colour) # Input separator line renderer.draw_line_coloured(ix, iy, ix + iw, iy, self.border_colour) # Prompt prompt_y = iy + (ih - self.font_size) / 2 renderer.draw_text_coloured(self.prompt, ix + _PADDING, prompt_y, scale, self.prompt_colour) prompt_w = renderer.text_width(self.prompt, scale) # Input text text_x = ix + _PADDING + prompt_w if self._input_text: renderer.draw_text_coloured(self._input_text, text_x, prompt_y, scale, self.input_text_colour) # Cursor if self.focused and self._cursor_blink < 0.5: cursor_x = text_x + renderer.text_width(self._input_text[: self._cursor_pos], scale) renderer.draw_line_coloured(cursor_x, iy + 4, cursor_x, iy + ih - 4, Colour.WHITE) # Border border = self.focus_colour if self.focused else self.border_colour renderer.draw_rect_coloured(x, y, w, h, border)