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 ..decorators import on_gui_input
from ..descriptors import Property
from ..input.controls import Pointer
from ..input.enums import PointerPhase
from ..input.events import PointerEvent, ScrollEvent
from ..signals import Signal
from .ansi_parser import StyledSpan, parse_ansi
from .command_line import CommandLine
from .containers import Container
from .core import Control, ThemeColour
from .enums import PointerFilter
from .widgets import Label

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") """ pointer_filter = Property(PointerFilter.STOP, hint="How this control takes part in the pointer hit test") 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") size_x = Property(400.0, range=(0, 10000), hint="Control width", on_change="_on_size_changed") size_y = Property(200.0, range=(0, 10000), hint="Control height", on_change="_on_size_changed") text_colour = ThemeColour("text") bg_colour = ThemeColour("bg_darkest") def __init__(self, text: str = "", **kwargs): super().__init__(**kwargs) # 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 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, from the top of the text. Assigning it scrolls: the value is clamped to the text, and it sets :attr:`auto_scroll` to whether the view now rests at the bottom, so scrolling up holds the view still while text arrives and scrolling back down follows it again. """ return self._scroll_y
[docs] @scroll_y.setter def scroll_y(self, value: float): bottom = self._max_scroll() self._scroll_y = max(0.0, min(float(value), bottom)) self._auto_scroll = self._scroll_y >= bottom - 1.0 self.queue_redraw()
@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
# ---------------------------------------------------------------- # 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 / 16.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._local_rect() return max(0.0, self._content_height() - h + _PADDING * 2) def _scroll_to_bottom(self): self._scroll_y = self._max_scroll() # ---------------------------------------------------------------- # Scrollbar geometry # ---------------------------------------------------------------- def _scrollbar_needed(self) -> bool: _, _, _, h = self._local_rect() return self._content_height() > h - _PADDING * 2 def _scrollbar_thumb_rect(self) -> tuple[float, float, float, float]: x, y, w, h = self._local_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 # ----------------------------------------------------------------
[docs] def on_gui_input(self, event): if isinstance(event, ScrollEvent): self.scroll_y -= _SCROLL_STEP if event.scroll[1] > 0 else -_SCROLL_STEP return True if not isinstance(event, PointerEvent): return None # Scrollbar drag if event.button in Pointer.PRIMARY: if event.phase is PointerPhase.DOWN: sx, sy, sw, sh = self._scrollbar_thumb_rect() if sw > 0: px, py = self.to_local(event.position) 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 True elif event.phase is PointerPhase.UP and self._dragging_scrollbar: self._dragging_scrollbar = False return None if self._dragging_scrollbar: py = self.to_local(event.position).y _, _, _, h = self._local_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
# ---------------------------------------------------------------- # Drawing # ----------------------------------------------------------------
[docs] def on_draw(self, renderer): x, y, w, h = self._local_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.rect((x, y), (w, h), fill=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.text(span.text, (text_x, text_y), fill=colour, scale=scale) text_x += renderer.text_width(span.text, scale) renderer.pop_clip() # Scrollbar if has_scrollbar: theme = self.get_theme() # Track renderer.rect((x + w - _SCROLLBAR_WIDTH, y), (_SCROLLBAR_WIDTH, h), fill=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.rect((sx, sy), (sw, sh), fill=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") """ pointer_filter = Property(PointerFilter.STOP, hint="How this control takes part in the pointer hit test") show_header = Property(True, hint="Show header bar") auto_scroll = Property(True, hint="Auto-scroll to bottom") size_x = Property(400.0, range=(0, 10000), hint="Control width", on_change="_on_size_changed") size_y = Property(200.0, range=(0, 10000), hint="Control height", on_change="_on_size_changed") 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.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.add_child(self._rich_label)
[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()
@property def scroll_y(self) -> float: """How far the output is scrolled, in pixels; assigning it scrolls, as :attr:`RichTextLabel.scroll_y` does.""" return self._rich_label.scroll_y
[docs] @scroll_y.setter def scroll_y(self, value: float): self._rich_label.scroll_y = value
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._local_rect() btn_w = 50.0 btn_h = _HEADER_HEIGHT - 4 return (x + w - btn_w - 4, y + 2, btn_w, btn_h)
[docs] def on_gui_input(self, event): # Check clear button click if not isinstance(event, PointerEvent): return None if self.show_header and event.button in Pointer.PRIMARY: bx, by, bw, bh = self._clear_button_rect() px, py = self.to_local(event.position) if bx <= px <= bx + bw and by <= py <= by + bh: if event.phase is PointerPhase.UP: self.clear() return True
def _sync_label_rect(self): """Lay the label out under the header, inset one pixel so the panel's border shows.""" _, _, w, h = self._local_rect() hh = self._header_height() Container._place(self._rich_label, 1.0, hh, max(0.0, w - 2.0), max(0.0, h - hh - 1.0)) self._rich_label.font_size = self.font_size self._rich_label.bg_colour = self.bg_colour
[docs] def on_update(self, dt: float): self._sync_label_rect()
[docs] def on_draw(self, renderer): x, y, w, h = self._local_rect() hh = self._header_height() scale = self.font_size / 16.0 # Background renderer.rect((x, y), (w, h), fill=self.bg_colour) # Header if self.show_header: renderer.rect((x, y), (w, hh), fill=self.header_bg_colour) text_y = y + (hh - self.font_size) / 2 renderer.text(self.title, (x + _PADDING, text_y), fill=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.rect((bx, by), (bw, bh), fill=btn_bg) clear_text = "Clear" tw = renderer.text_width(clear_text, scale * 0.85) renderer.text( clear_text, (bx + (bw - tw) / 2, by + (bh - self.font_size * 0.85) / 2), fill=(0.56, 0.56, 0.58, 1.0), scale=scale * 0.85, ) renderer.line((x, y + hh), (x + w, y + hh), stroke=self.border_colour) # Border renderer.rect((x, y), (w, h), stroke=self.border_colour)
# ============================================================================ # ConsoleWidget: Interactive console with output and input # ============================================================================
[docs] class ConsoleWidget(Container): """Interactive console: scrolling output above a prompt and a command line with history. The output is an :class:`OutputPanel`, with ANSI colour, and the input a :class:`CommandLine`, so Enter submits and Up and Down recall earlier commands. The wheel scrolls the output wherever over the console it turns. Example: console = ConsoleWidget() console.command_entered.connect(lambda cmd: console.write(f">>> {cmd}")) console.write("Welcome to SimVX console.") """ pointer_filter = Property(PointerFilter.STOP, hint="How this control takes part in the pointer hit test") prompt = Property(">>> ", hint="Input prompt string", on_change="_on_prompt_changed") size_x = Property(400.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("bg_darkest") prompt_colour = ThemeColour("success") border_colour = ThemeColour("border") focus_colour = ThemeColour("input_focus") def __init__(self, **kwargs): super().__init__(**kwargs) self.command_entered = Signal() self._output = self.add_child(OutputPanel(title="Console")) self._prompt_label = self.add_child(Label(self.prompt)) self._prompt_label.text_colour = self.prompt_colour self._line = self.add_child(CommandLine()) self._line.text_submitted.connect(self.command_entered.emit) self._line.focus_entered.connect(self.queue_redraw) self._line.focus_exited.connect(self.queue_redraw) # ---------------------------------------------------------------- # Public API # ----------------------------------------------------------------
[docs] @property def command_line(self) -> CommandLine: """The input line: its ``history`` and ``max_history`` are the console's.""" return self._line
[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()
# ---------------------------------------------------------------- # Input # ---------------------------------------------------------------- @on_gui_input(ScrollEvent) def _scroll_output(self, event) -> bool: """The wheel over the prompt or the header scrolls the output, as it does over the text.""" self._output.scroll_y -= _SCROLL_STEP if event.scroll[1] > 0 else -_SCROLL_STEP return True # ---------------------------------------------------------------- # Layout and drawing # ---------------------------------------------------------------- def _on_prompt_changed(self, _old=None, new=None): self._prompt_label.text = self.prompt def _update_layout(self): # Inset one pixel all round so the border drawn under the children shows. w, h = self.size_x - 2.0, self.size_y - 2.0 line_y = 1.0 + max(0.0, h - _INPUT_HEIGHT) prompt_w = self._prompt_label.get_minimum_size().x self._output.bg_colour = self.bg_colour Container._place(self._output, 1.0, 1.0, max(0.0, w), max(0.0, line_y - 1.0)) Container._place(self._prompt_label, 1.0 + _PADDING, line_y, prompt_w, _INPUT_HEIGHT) line_x = 1.0 + _PADDING + prompt_w Container._place(self._line, line_x, line_y, max(0.0, w + 1.0 - line_x), _INPUT_HEIGHT)
[docs] def on_draw(self, renderer): x, y, w, h = self._local_rect() renderer.rect((x, y), (w, h), fill=self.bg_colour) border = self.focus_colour if self._line.focused else self.border_colour renderer.rect((x, y), (w, h), stroke=border)