"""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
# ============================================================================