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