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

import logging

from ..descriptors import Property

from ..signals import Signal
from ..math.types import Vec2
from .ansi_parser import StyledSpan, parse_ansi
from .core import Colour, Control, ThemeColour
from ..input.enums import MouseButton

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)
[docs] @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
[docs] @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
[docs] @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
[docs] @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 == MouseButton.LEFT: 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 on_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_rect((x, y), (w, h), colour=self.bg_colour, filled=True) # 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(span.text, (text_x, text_y), colour=colour, scale=scale) text_x += renderer.text_width(span.text, scale) renderer.pop_clip() # Scrollbar if has_scrollbar: theme = self.get_theme() # Track renderer.draw_rect( (x + w - _SCROLLBAR_WIDTH, y), (_SCROLLBAR_WIDTH, h), colour=theme.scrollbar_track, filled=True ) # 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_rect((sx, sy), (sw, sh), colour=colour, filled=True)
# ============================================================================ # 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
[docs] @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 == MouseButton.LEFT: 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 on_draw(self, renderer): x, y, w, h = self._effective_rect() hh = self._header_height() scale = self.font_size / 14.0 # Background renderer.draw_rect((x, y), (w, h), colour=self.bg_colour, filled=True) # Header if self.show_header: renderer.draw_rect((x, y), (w, hh), colour=self.header_bg_colour, filled=True) text_y = y + (hh - self.font_size) / 2 renderer.draw_text(self.title, (x + _PADDING, text_y), colour=self.header_text_colour, scale=scale) 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_rect((bx, by), (bw, bh), colour=btn_bg, filled=True) clear_text = "Clear" tw = renderer.text_width(clear_text, scale * 0.85) renderer.draw_text( clear_text, (bx + (bw - tw) / 2, by + (bh - self.font_size * 0.85) / 2), colour=(0.56, 0.56, 0.58, 1.0), scale=scale * 0.85, ) renderer.draw_line((x, y + hh), (x + w, y + hh), colour=self.border_colour) # Draw rich text content self._sync_label_rect() self._rich_label.on_draw(renderer) # Border renderer.draw_rect((x, y), (w, h), colour=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 == MouseButton.LEFT 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 on_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 on_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.on_draw(renderer) # Input area background renderer.draw_rect((ix, iy), (iw, ih), colour=self.input_bg_colour, filled=True) # Input separator line renderer.draw_line((ix, iy), (ix + iw, iy), colour=self.border_colour) # Prompt prompt_y = iy + (ih - self.font_size) / 2 renderer.draw_text(self.prompt, (ix + _PADDING, prompt_y), colour=self.prompt_colour, scale=scale) prompt_w = renderer.text_width(self.prompt, scale) # Input text text_x = ix + _PADDING + prompt_w if self._input_text: renderer.draw_text(self._input_text, (text_x, prompt_y), colour=self.input_text_colour, scale=scale) # 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((cursor_x, iy + 4), (cursor_x, iy + ih - 4), colour=Colour.WHITE) # Border border = self.focus_colour if self.focused else self.border_colour renderer.draw_rect((x, y), (w, h), colour=border)