285 lines
8.2 KiB
Python
285 lines
8.2 KiB
Python
"""
|
|
A Canvas class used to render keylines.
|
|
|
|
!!! note
|
|
This API is experimental, and may change in the near future.
|
|
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from array import array
|
|
from collections import defaultdict
|
|
from dataclasses import dataclass
|
|
from operator import itemgetter
|
|
from typing import NamedTuple, Sequence
|
|
|
|
from rich.segment import Segment
|
|
from rich.style import Style
|
|
from typing_extensions import Literal, TypeAlias
|
|
|
|
from textual._box_drawing import BOX_CHARACTERS, Quad, combine_quads
|
|
from textual.color import Color
|
|
from textual.geometry import Offset, clamp
|
|
from textual.strip import Strip, StripRenderable
|
|
|
|
CanvasLineType: TypeAlias = Literal["thin", "heavy", "double"]
|
|
|
|
|
|
_LINE_TYPE_INDEX = {"thin": 1, "heavy": 2, "double": 3}
|
|
|
|
|
|
class _Span(NamedTuple):
|
|
"""Associates a sequence of character indices with a color."""
|
|
|
|
start: int
|
|
end: int # exclusive
|
|
color: Color
|
|
|
|
|
|
class Primitive:
|
|
"""Base class for a canvas primitive."""
|
|
|
|
def render(self, canvas: Canvas) -> None:
|
|
"""Render to the canvas.
|
|
|
|
Args:
|
|
canvas: Canvas instance.
|
|
"""
|
|
raise NotImplementedError()
|
|
|
|
|
|
@dataclass
|
|
class HorizontalLine(Primitive):
|
|
"""A horizontal line."""
|
|
|
|
origin: Offset
|
|
length: int
|
|
color: Color
|
|
line_type: CanvasLineType = "thin"
|
|
|
|
def render(self, canvas: Canvas) -> None:
|
|
x, y = self.origin
|
|
if y < 0 or y > canvas.height - 1:
|
|
return
|
|
box = canvas.box
|
|
box_line = box[y]
|
|
|
|
line_type_index = _LINE_TYPE_INDEX[self.line_type]
|
|
_combine_quads = combine_quads
|
|
|
|
right = x + self.length - 1
|
|
|
|
x_range = canvas.x_range(x, x + self.length)
|
|
|
|
if x in x_range:
|
|
box_line[x] = _combine_quads(box_line[x], (0, line_type_index, 0, 0))
|
|
if right in x_range:
|
|
box_line[right] = _combine_quads(
|
|
box_line[right], (0, 0, 0, line_type_index)
|
|
)
|
|
|
|
line_quad = (0, line_type_index, 0, line_type_index)
|
|
for box_x in canvas.x_range(x + 1, x + self.length - 1):
|
|
box_line[box_x] = _combine_quads(box_line[box_x], line_quad)
|
|
|
|
canvas.spans[y].append(_Span(x, x + self.length, self.color))
|
|
|
|
|
|
@dataclass
|
|
class VerticalLine(Primitive):
|
|
"""A vertical line."""
|
|
|
|
origin: Offset
|
|
length: int
|
|
color: Color
|
|
line_type: CanvasLineType = "thin"
|
|
|
|
def render(self, canvas: Canvas) -> None:
|
|
x, y = self.origin
|
|
if x < 0 or x >= canvas.width:
|
|
return
|
|
line_type_index = _LINE_TYPE_INDEX[self.line_type]
|
|
box = canvas.box
|
|
_combine_quads = combine_quads
|
|
|
|
y_range = canvas.y_range(y, y + self.length)
|
|
|
|
if y in y_range:
|
|
box[y][x] = _combine_quads(box[y][x], (0, 0, line_type_index, 0))
|
|
bottom = y + self.length - 1
|
|
|
|
if bottom in y_range:
|
|
box[bottom][x] = _combine_quads(box[bottom][x], (line_type_index, 0, 0, 0))
|
|
line_quad = (line_type_index, 0, line_type_index, 0)
|
|
|
|
for box_y in canvas.y_range(y + 1, y + self.length - 1):
|
|
box[box_y][x] = _combine_quads(box[box_y][x], line_quad)
|
|
|
|
spans = canvas.spans
|
|
span = _Span(x, x + 1, self.color)
|
|
for y in y_range:
|
|
spans[y].append(span)
|
|
|
|
|
|
@dataclass
|
|
class Rectangle(Primitive):
|
|
"""A rectangle."""
|
|
|
|
origin: Offset
|
|
width: int
|
|
height: int
|
|
color: Color
|
|
line_type: CanvasLineType = "thin"
|
|
|
|
def render(self, canvas: Canvas) -> None:
|
|
origin = self.origin
|
|
width = self.width
|
|
height = self.height
|
|
color = self.color
|
|
line_type = self.line_type
|
|
HorizontalLine(origin, width, color, line_type).render(canvas)
|
|
HorizontalLine(origin + (0, height - 1), width, color, line_type).render(canvas)
|
|
VerticalLine(origin, height, color, line_type).render(canvas)
|
|
VerticalLine(origin + (width - 1, 0), height, color, line_type).render(canvas)
|
|
|
|
|
|
class Canvas:
|
|
"""A character canvas."""
|
|
|
|
def __init__(self, width: int, height: int) -> None:
|
|
"""
|
|
|
|
Args:
|
|
width: Width of the canvas (in cells).
|
|
height Height of the canvas (in cells).
|
|
"""
|
|
self._width = width
|
|
self._height = height
|
|
blank_line = " " * width
|
|
array_type_code = "w" if sys.version_info >= (3, 13) else "u"
|
|
self.lines: list[array[str]] = [
|
|
array(array_type_code, blank_line) for _ in range(height)
|
|
]
|
|
self.box: list[defaultdict[int, Quad]] = [
|
|
defaultdict(lambda: (0, 0, 0, 0)) for _ in range(height)
|
|
]
|
|
self.spans: list[list[_Span]] = [[] for _ in range(height)]
|
|
|
|
@property
|
|
def width(self) -> int:
|
|
"""The canvas width."""
|
|
return self._width
|
|
|
|
@property
|
|
def height(self) -> int:
|
|
"""The canvas height."""
|
|
return self._height
|
|
|
|
def x_range(self, start: int, end: int) -> range:
|
|
"""Range of x values, clipped to the canvas dimensions.
|
|
|
|
Args:
|
|
start: Start index.
|
|
end: End index.
|
|
|
|
Returns:
|
|
A range object.
|
|
"""
|
|
return range(
|
|
clamp(start, 0, self._width),
|
|
clamp(end, 0, self._width),
|
|
)
|
|
|
|
def y_range(self, start: int, end: int) -> range:
|
|
"""Range of y values, clipped to the canvas dimensions.
|
|
|
|
Args:
|
|
start: Start index.
|
|
end: End index.
|
|
|
|
Returns:
|
|
A range object.
|
|
"""
|
|
return range(
|
|
clamp(start, 0, self._height),
|
|
clamp(end, 0, self._height),
|
|
)
|
|
|
|
def render(
|
|
self, primitives: Sequence[Primitive], base_style: Style
|
|
) -> StripRenderable:
|
|
"""Render the canvas.
|
|
|
|
Args:
|
|
primitives: A sequence of primitives.
|
|
base_style: The base style of the canvas.
|
|
|
|
Returns:
|
|
A Rich renderable for the canvas.
|
|
"""
|
|
for primitive in primitives:
|
|
primitive.render(self)
|
|
|
|
get_box = BOX_CHARACTERS.__getitem__
|
|
for box, line in zip(self.box, self.lines):
|
|
for offset, quad in box.items():
|
|
line[offset] = get_box(quad)
|
|
|
|
width = self._width
|
|
span_sort_key = itemgetter(0, 1)
|
|
strips: list[Strip] = []
|
|
color = (
|
|
Color.from_rich_color(base_style.bgcolor)
|
|
if base_style.bgcolor
|
|
else Color.parse("transparent")
|
|
)
|
|
_Segment = Segment
|
|
for raw_spans, line in zip(self.spans, self.lines):
|
|
text = line.tounicode()
|
|
|
|
if raw_spans:
|
|
segments: list[Segment] = []
|
|
colors = [color] + [span.color for span in raw_spans]
|
|
spans = [
|
|
(0, False, 0),
|
|
*(
|
|
(span.start, False, index)
|
|
for index, span in enumerate(raw_spans, 1)
|
|
),
|
|
*(
|
|
(span.end, True, index)
|
|
for index, span in enumerate(raw_spans, 1)
|
|
),
|
|
(width, True, 0),
|
|
]
|
|
spans.sort(key=span_sort_key)
|
|
color_indices: set[int] = set()
|
|
color_remove = color_indices.discard
|
|
color_add = color_indices.add
|
|
for (offset, leaving, style_id), (next_offset, _, _) in zip(
|
|
spans, spans[1:]
|
|
):
|
|
if leaving:
|
|
color_remove(style_id)
|
|
else:
|
|
color_add(style_id)
|
|
if next_offset > offset:
|
|
segments.append(
|
|
_Segment(
|
|
text[offset:next_offset],
|
|
base_style
|
|
+ Style.from_color(
|
|
colors[
|
|
max(color_indices) if color_indices else 0
|
|
].rich_color
|
|
),
|
|
)
|
|
)
|
|
strips.append(Strip(segments, width))
|
|
else:
|
|
strips.append(Strip([_Segment(text, base_style)], width))
|
|
|
|
return StripRenderable(strips, width)
|