480 lines
13 KiB
Python
480 lines
13 KiB
Python
from __future__ import annotations
|
|
|
|
from functools import lru_cache
|
|
from typing import TYPE_CHECKING, Iterable, Tuple, cast
|
|
|
|
from rich.segment import Segment
|
|
|
|
from textual.color import Color
|
|
from textual.css.types import AlignHorizontal, EdgeStyle, EdgeType
|
|
from textual.style import Style
|
|
|
|
if TYPE_CHECKING:
|
|
from typing_extensions import TypeAlias
|
|
|
|
from textual.content import Content
|
|
|
|
INNER = 1
|
|
OUTER = 2
|
|
|
|
BORDER_CHARS: dict[
|
|
EdgeType, tuple[tuple[str, str, str], tuple[str, str, str], tuple[str, str, str]]
|
|
] = {
|
|
# Three tuples for the top, middle, and bottom rows.
|
|
# The sub-tuples are the characters for the left, center, and right borders.
|
|
"": (
|
|
(" ", " ", " "),
|
|
(" ", " ", " "),
|
|
(" ", " ", " "),
|
|
),
|
|
"ascii": (
|
|
("+", "-", "+"),
|
|
("|", " ", "|"),
|
|
("+", "-", "+"),
|
|
),
|
|
"none": (
|
|
(" ", " ", " "),
|
|
(" ", " ", " "),
|
|
(" ", " ", " "),
|
|
),
|
|
"hidden": (
|
|
(" ", " ", " "),
|
|
(" ", " ", " "),
|
|
(" ", " ", " "),
|
|
),
|
|
"blank": (
|
|
(" ", " ", " "),
|
|
(" ", " ", " "),
|
|
(" ", " ", " "),
|
|
),
|
|
"round": (
|
|
("╭", "─", "╮"),
|
|
("│", " ", "│"),
|
|
("╰", "─", "╯"),
|
|
),
|
|
"solid": (
|
|
("┌", "─", "┐"),
|
|
("│", " ", "│"),
|
|
("└", "─", "┘"),
|
|
),
|
|
"double": (
|
|
("╔", "═", "╗"),
|
|
("║", " ", "║"),
|
|
("╚", "═", "╝"),
|
|
),
|
|
"dashed": (
|
|
("┏", "╍", "┓"),
|
|
("╏", " ", "╏"),
|
|
("┗", "╍", "┛"),
|
|
),
|
|
"heavy": (
|
|
("┏", "━", "┓"),
|
|
("┃", " ", "┃"),
|
|
("┗", "━", "┛"),
|
|
),
|
|
"inner": (
|
|
("▗", "▄", "▖"),
|
|
("▐", " ", "▌"),
|
|
("▝", "▀", "▘"),
|
|
),
|
|
"outer": (
|
|
("▛", "▀", "▜"),
|
|
("▌", " ", "▐"),
|
|
("▙", "▄", "▟"),
|
|
),
|
|
"thick": (
|
|
("█", "▀", "█"),
|
|
("█", " ", "█"),
|
|
("█", "▄", "█"),
|
|
),
|
|
"block": (
|
|
("▄", "▄", "▄"),
|
|
("█", " ", "█"),
|
|
("▀", "▀", "▀"),
|
|
),
|
|
"hkey": (
|
|
("▔", "▔", "▔"),
|
|
(" ", " ", " "),
|
|
("▁", "▁", "▁"),
|
|
),
|
|
"vkey": (
|
|
("▏", " ", "▕"),
|
|
("▏", " ", "▕"),
|
|
("▏", " ", "▕"),
|
|
),
|
|
"tall": (
|
|
("▊", "▔", "▎"),
|
|
("▊", " ", "▎"),
|
|
("▊", "▁", "▎"),
|
|
),
|
|
"panel": (
|
|
("▊", "█", "▎"),
|
|
("▊", " ", "▎"),
|
|
("▊", "▁", "▎"),
|
|
),
|
|
"tab": (
|
|
("▁", "▁", "▁"),
|
|
("▎", " ", "▊"),
|
|
("▔", "▔", "▔"),
|
|
),
|
|
"wide": (
|
|
("▁", "▁", "▁"),
|
|
("▎", " ", "▊"),
|
|
("▔", "▔", "▔"),
|
|
),
|
|
}
|
|
|
|
# Some of the borders are on the widget background and some are on the background of the parent
|
|
# This table selects which for each character, 0 indicates the widget, 1 selects the parent.
|
|
# 2 and 3 reverse a cross-combination of the background and foreground colors of 0 and 1.
|
|
BORDER_LOCATIONS: dict[
|
|
EdgeType, tuple[tuple[int, int, int], tuple[int, int, int], tuple[int, int, int]]
|
|
] = {
|
|
"": (
|
|
(0, 0, 0),
|
|
(0, 0, 0),
|
|
(0, 0, 0),
|
|
),
|
|
"ascii": (
|
|
(0, 0, 0),
|
|
(0, 0, 0),
|
|
(0, 0, 0),
|
|
),
|
|
"none": (
|
|
(0, 0, 0),
|
|
(0, 0, 0),
|
|
(0, 0, 0),
|
|
),
|
|
"hidden": (
|
|
(0, 0, 0),
|
|
(0, 0, 0),
|
|
(0, 0, 0),
|
|
),
|
|
"blank": (
|
|
(0, 0, 0),
|
|
(0, 0, 0),
|
|
(0, 0, 0),
|
|
),
|
|
"round": (
|
|
(0, 0, 0),
|
|
(0, 0, 0),
|
|
(0, 0, 0),
|
|
),
|
|
"solid": (
|
|
(0, 0, 0),
|
|
(0, 0, 0),
|
|
(0, 0, 0),
|
|
),
|
|
"double": (
|
|
(0, 0, 0),
|
|
(0, 0, 0),
|
|
(0, 0, 0),
|
|
),
|
|
"dashed": (
|
|
(0, 0, 0),
|
|
(0, 0, 0),
|
|
(0, 0, 0),
|
|
),
|
|
"heavy": (
|
|
(0, 0, 0),
|
|
(0, 0, 0),
|
|
(0, 0, 0),
|
|
),
|
|
"inner": (
|
|
(1, 1, 1),
|
|
(1, 1, 1),
|
|
(1, 1, 1),
|
|
),
|
|
"outer": (
|
|
(0, 0, 0),
|
|
(0, 0, 0),
|
|
(0, 0, 0),
|
|
),
|
|
"thick": (
|
|
(0, 0, 0),
|
|
(0, 0, 0),
|
|
(0, 0, 0),
|
|
),
|
|
"block": (
|
|
(1, 1, 1),
|
|
(0, 0, 0),
|
|
(1, 1, 1),
|
|
),
|
|
"hkey": (
|
|
(0, 0, 0),
|
|
(0, 0, 0),
|
|
(0, 0, 0),
|
|
),
|
|
"vkey": (
|
|
(0, 0, 0),
|
|
(0, 0, 0),
|
|
(0, 0, 0),
|
|
),
|
|
"tall": (
|
|
(2, 0, 1),
|
|
(2, 0, 1),
|
|
(2, 0, 1),
|
|
),
|
|
"panel": (
|
|
(2, 0, 1),
|
|
(2, 0, 1),
|
|
(2, 0, 1),
|
|
),
|
|
"tab": (
|
|
(1, 1, 1),
|
|
(0, 1, 3),
|
|
(1, 1, 1),
|
|
),
|
|
"wide": (
|
|
(1, 1, 1),
|
|
(0, 1, 3),
|
|
(1, 1, 1),
|
|
),
|
|
}
|
|
|
|
# Some borders (such as panel) require that the title (and subtitle) be draw in reverse.
|
|
# This is a mapping of the border type on to a tuple for the top and bottom borders, to indicate
|
|
# reverse colors is required.
|
|
BORDER_TITLE_FLIP: dict[str, tuple[bool, bool]] = {
|
|
"panel": (True, False),
|
|
"tab": (True, True),
|
|
}
|
|
|
|
# In a similar fashion, we extract the border _label_ locations for easier access when
|
|
# rendering a border label.
|
|
# The values are a pair with (title location, subtitle location).
|
|
BORDER_LABEL_LOCATIONS: dict[EdgeType, tuple[int, int]] = {
|
|
edge_type: (locations[0][1], locations[2][1])
|
|
for edge_type, locations in BORDER_LOCATIONS.items()
|
|
}
|
|
|
|
INVISIBLE_EDGE_TYPES = cast("frozenset[EdgeType]", frozenset(("", "none", "hidden")))
|
|
|
|
BorderValue: TypeAlias = Tuple[EdgeType, Color]
|
|
|
|
BoxSegments: TypeAlias = Tuple[
|
|
Tuple[Segment, Segment, Segment],
|
|
Tuple[Segment, Segment, Segment],
|
|
Tuple[Segment, Segment, Segment],
|
|
]
|
|
|
|
Borders: TypeAlias = Tuple[EdgeStyle, EdgeStyle, EdgeStyle, EdgeStyle]
|
|
|
|
REVERSE_STYLE = Style(reverse=True)
|
|
|
|
|
|
@lru_cache(maxsize=1024)
|
|
def get_box(
|
|
name: EdgeType,
|
|
inner_style: Style,
|
|
outer_style: Style,
|
|
style: Style,
|
|
) -> BoxSegments:
|
|
"""Get segments used to render a box.
|
|
|
|
Args:
|
|
name: Name of the box type.
|
|
inner_style: The inner style (widget background).
|
|
outer_style: The outer style (parent background).
|
|
style: Widget style.
|
|
|
|
Returns:
|
|
A tuple of 3 Segment triplets.
|
|
"""
|
|
_Segment = Segment
|
|
(
|
|
(top1, top2, top3),
|
|
(mid1, mid2, mid3),
|
|
(bottom1, bottom2, bottom3),
|
|
) = BORDER_CHARS[name]
|
|
|
|
(
|
|
(ltop1, ltop2, ltop3),
|
|
(lmid1, lmid2, lmid3),
|
|
(lbottom1, lbottom2, lbottom3),
|
|
) = BORDER_LOCATIONS[name]
|
|
|
|
inner = inner_style + style
|
|
outer = outer_style + style
|
|
|
|
styles = (
|
|
inner.rich_style,
|
|
outer.rich_style,
|
|
Style(outer.background, inner.foreground, reverse=True).rich_style,
|
|
Style(inner.background, outer.foreground, reverse=True).rich_style,
|
|
)
|
|
|
|
return (
|
|
(
|
|
_Segment(top1, styles[ltop1]),
|
|
_Segment(top2, styles[ltop2]),
|
|
_Segment(top3, styles[ltop3]),
|
|
),
|
|
(
|
|
_Segment(mid1, styles[lmid1]),
|
|
_Segment(mid2, styles[lmid2]),
|
|
_Segment(mid3, styles[lmid3]),
|
|
),
|
|
(
|
|
_Segment(bottom1, styles[lbottom1]),
|
|
_Segment(bottom2, styles[lbottom2]),
|
|
_Segment(bottom3, styles[lbottom3]),
|
|
),
|
|
)
|
|
|
|
|
|
def render_border_label(
|
|
label: tuple[Content, Style],
|
|
is_title: bool,
|
|
name: EdgeType,
|
|
width: int,
|
|
inner_style: Style,
|
|
outer_style: Style,
|
|
style: Style,
|
|
has_left_corner: bool,
|
|
has_right_corner: bool,
|
|
) -> Iterable[Segment]:
|
|
"""Render a border label (the title or subtitle) with optional markup.
|
|
|
|
The styling that may be embedded in the label will be reapplied after taking into
|
|
account the inner, outer, and border-specific, styles.
|
|
|
|
Args:
|
|
label: Tuple of label and style to render in the border.
|
|
is_title: Whether we are rendering the title (`True`) or the subtitle (`False`).
|
|
name: Name of the box type.
|
|
width: The width, in cells, of the space available for the whole edge.
|
|
This is the total space that may also be needed for the border corners and
|
|
the whitespace padding around the (sub)title. Thus, the effective space
|
|
available for the border label is:
|
|
- `width` if no corner is needed;
|
|
- `width - 2` if one corner is needed; and
|
|
- `width - 4` if both corners are needed.
|
|
inner_style: The inner style (widget background).
|
|
outer_style: The outer style (parent background).
|
|
style: Widget style.
|
|
console: The console that will render the markup in the label.
|
|
has_left_corner: Whether the border edge will have to render a left corner.
|
|
has_right_corner: Whether the border edge will have to render a right corner.
|
|
|
|
Returns:
|
|
A list of segments that represent the full label and surrounding padding.
|
|
"""
|
|
# How many cells do we need to reserve for surrounding blanks and corners?
|
|
corners_needed = has_left_corner + has_right_corner
|
|
cells_reserved = 2 * corners_needed
|
|
|
|
text_label, label_style = label
|
|
|
|
if not text_label.cell_length or width <= cells_reserved:
|
|
return
|
|
|
|
text_label = text_label.truncate(width - cells_reserved, ellipsis=True)
|
|
if has_left_corner:
|
|
text_label = text_label.pad_left(1)
|
|
if has_right_corner:
|
|
text_label = text_label.pad_right(1)
|
|
text_label = text_label.stylize_before(label_style)
|
|
|
|
label_style_location = BORDER_LABEL_LOCATIONS[name][0 if is_title else 1]
|
|
flip_top, flip_bottom = BORDER_TITLE_FLIP.get(name, (False, False))
|
|
|
|
inner = inner_style + style
|
|
outer = outer_style + style
|
|
|
|
base_style: Style
|
|
if label_style_location == 0:
|
|
base_style = inner
|
|
elif label_style_location == 1:
|
|
base_style = outer
|
|
elif label_style_location == 2:
|
|
base_style = Style(outer.background, inner.foreground, reverse=True)
|
|
elif label_style_location == 3:
|
|
base_style = Style(inner.background, outer.foreground, reverse=True)
|
|
else:
|
|
assert False
|
|
|
|
if (flip_top and is_title) or (flip_bottom and not is_title):
|
|
base_style = base_style.without_color + Style(
|
|
background=base_style.foreground,
|
|
foreground=base_style.background,
|
|
)
|
|
|
|
segments = text_label.render_segments(base_style)
|
|
yield from segments
|
|
|
|
|
|
def render_row(
|
|
box_row: tuple[Segment, Segment, Segment],
|
|
width: int,
|
|
left: bool,
|
|
right: bool,
|
|
label_segments: Iterable[Segment],
|
|
label_alignment: AlignHorizontal = "left",
|
|
) -> Iterable[Segment]:
|
|
"""Compose a box row with its padded label.
|
|
|
|
This is the function that actually does the work that `render_row` is intended
|
|
to do, but we have many lists of segments flowing around, so it becomes easier
|
|
to yield the segments bit by bit, and the aggregate everything into a list later.
|
|
|
|
Args:
|
|
box_row: Corners and side segments.
|
|
width: Total width of resulting line.
|
|
left: Render left corner.
|
|
right: Render right corner.
|
|
label_segments: The segments that make up the label.
|
|
label_alignment: Where to horizontally align the label.
|
|
|
|
Returns:
|
|
An iterable of segments.
|
|
"""
|
|
box1, box2, box3 = box_row
|
|
|
|
corners_needed = left + right
|
|
label_segments_list = list(label_segments)
|
|
|
|
label_length = sum((segment.cell_length for segment in label_segments_list), 0)
|
|
space_available = max(0, width - corners_needed - label_length)
|
|
|
|
if left:
|
|
yield box1
|
|
|
|
if not space_available:
|
|
yield from label_segments_list
|
|
elif not label_length:
|
|
yield Segment(box2.text * space_available, box2.style)
|
|
elif label_alignment == "left" or label_alignment == "right":
|
|
edge = Segment(box2.text * (space_available - 1), box2.style)
|
|
if label_alignment == "left":
|
|
yield Segment(box2.text, box2.style)
|
|
yield from label_segments_list
|
|
yield edge
|
|
else:
|
|
yield edge
|
|
yield from label_segments_list
|
|
yield Segment(box2.text, box2.style)
|
|
elif label_alignment == "center":
|
|
length_on_left = space_available // 2
|
|
length_on_right = space_available - length_on_left
|
|
yield Segment(box2.text * length_on_left, box2.style)
|
|
yield from label_segments_list
|
|
yield Segment(box2.text * length_on_right, box2.style)
|
|
else:
|
|
assert False
|
|
|
|
if right:
|
|
yield box3
|
|
|
|
|
|
_edge_type_normalization_table: dict[EdgeType, EdgeType] = {
|
|
# i.e. we normalize "border: none;" to "border: ;".
|
|
# As a result our layout-related calculations that include borders are simpler (and have better performance)
|
|
"none": "",
|
|
"hidden": "",
|
|
}
|
|
|
|
|
|
def normalize_border_value(value: BorderValue) -> BorderValue:
|
|
return _edge_type_normalization_table.get(value[0], value[0]), value[1]
|