164 lines
5.3 KiB
Python
164 lines
5.3 KiB
Python
"""Pane frames: one grey box, with two corners fading out of it.
|
|
|
|
The panes draw their own frames instead of using Textual's ``border`` so the
|
|
corners can be coloured individually. Two of them — the top right and the
|
|
bottom left — run white → light grey → bright cyan → cyan along both arms;
|
|
everything else, including the other two corners, is the border grey.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from rich.cells import cell_len
|
|
from rich.console import Group, RenderableType
|
|
from rich.text import Text
|
|
from textual.widget import Widget
|
|
|
|
#: The frame itself: one grey, the same grey as the scrollbar thumb.
|
|
EDGE = "bright_black"
|
|
|
|
#: A faded corner runs white → light grey → bright cyan → cyan, then settles
|
|
#: back into the border grey.
|
|
CORNER_GRADIENT = ("bright_white", "white", "bright_cyan", "cyan")
|
|
|
|
|
|
def fade(distance: int) -> str:
|
|
"""Colour ``distance`` cells away from a corner (0 is the corner itself)."""
|
|
if 0 <= distance < len(CORNER_GRADIENT):
|
|
return CORNER_GRADIENT[distance]
|
|
return EDGE
|
|
|
|
|
|
def side_style(row: int, rows: int, side: str = "left") -> str:
|
|
"""Colour of a side cell: only two corners fade, so only one end glows."""
|
|
if side == "right":
|
|
return fade(row + 1) # fades down from the top-right corner
|
|
return fade(rows - row) # fades up from the bottom-left corner
|
|
|
|
|
|
def inner_size(width: int, height: int) -> tuple[int, int]:
|
|
"""The content box inside the frame."""
|
|
return max(1, width - 2), max(1, height - 2)
|
|
|
|
|
|
def _edge_cells(width: int, faded_end: str = "right") -> list[tuple[str, str]]:
|
|
"""Inner cells of an edge; only one end of it fades into a corner."""
|
|
inner = max(0, width - 2)
|
|
if faded_end == "left":
|
|
return [("─", fade(column + 1)) for column in range(inner)]
|
|
return [("─", fade(inner - column)) for column in range(inner)]
|
|
|
|
|
|
def _paint(line: Text, cells: list[tuple[str, str]]) -> None:
|
|
"""Append cells, grouping neighbours that share a style into one span."""
|
|
style: str | None = None
|
|
run: list[str] = []
|
|
for char, cell_style in cells:
|
|
if cell_style != style:
|
|
if run:
|
|
line.append("".join(run), style=style)
|
|
run, style = [], cell_style
|
|
run.append(char)
|
|
if run:
|
|
line.append("".join(run), style=style)
|
|
|
|
|
|
def top(width: int, title: str = "") -> Text:
|
|
width = max(2, width)
|
|
cells = _edge_cells(width, "right") # only the top-right corner fades
|
|
if title:
|
|
label = f" {title} "
|
|
start = 1
|
|
if start + cell_len(label) <= len(cells):
|
|
for offset, char in enumerate(label):
|
|
cells[start + offset] = (char, "bold")
|
|
|
|
line = Text(no_wrap=True)
|
|
line.append("╭", style=EDGE)
|
|
_paint(line, cells)
|
|
line.append("╮", style=f"bold {fade(0)}")
|
|
return line
|
|
|
|
|
|
def bottom(width: int) -> Text:
|
|
width = max(2, width)
|
|
line = Text(no_wrap=True)
|
|
line.append("╰", style=f"bold {fade(0)}")
|
|
_paint(line, _edge_cells(width, "left")) # only the bottom-left fades
|
|
line.append("╯", style=EDGE)
|
|
return line
|
|
|
|
|
|
def side() -> Text:
|
|
return Text("│", style=EDGE)
|
|
|
|
|
|
def side_row(row: int, rows: int, side: str = "left") -> Text:
|
|
return Text("│", style=side_style(row, rows, side))
|
|
|
|
|
|
def wrap(
|
|
lines: list[Text], width: int, height: int, title: str = ""
|
|
) -> RenderableType:
|
|
"""Frame ``lines`` (already sized for the content box) in a pane."""
|
|
width = max(4, width)
|
|
height = max(2, height)
|
|
inner_width, inner_height = inner_size(width, height)
|
|
rows = len(lines[:inner_height])
|
|
|
|
framed: list[Text] = [top(width, title)]
|
|
for index, line in enumerate(lines[:inner_height]):
|
|
row = Text(no_wrap=True)
|
|
row.append_text(side_row(index, rows, "left"))
|
|
row.append_text(line)
|
|
pad = inner_width - cell_len(line.plain)
|
|
if pad > 0:
|
|
row.append(" " * pad)
|
|
row.append_text(side_row(index, rows, "right"))
|
|
framed.append(row)
|
|
while len(framed) < height - 1:
|
|
index = len(framed) - 1
|
|
row = Text(no_wrap=True)
|
|
row.append_text(side_row(index, inner_height, "left"))
|
|
row.append(" " * inner_width)
|
|
row.append_text(side_row(index, inner_height, "right"))
|
|
framed.append(row)
|
|
framed.append(bottom(width))
|
|
return Group(*framed[:height])
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# frame pieces, for containers that cannot draw their own content
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
class FrameTop(Widget):
|
|
can_focus = False
|
|
|
|
def __init__(self, title: str = "", *, id: str | None = None) -> None:
|
|
super().__init__(id=id)
|
|
self.title = title
|
|
|
|
def render(self) -> RenderableType:
|
|
return top(max(2, self.size.width), self.title)
|
|
|
|
|
|
class FrameBottom(Widget):
|
|
can_focus = False
|
|
|
|
def render(self) -> RenderableType:
|
|
return bottom(max(2, self.size.width))
|
|
|
|
|
|
class FrameSide(Widget):
|
|
can_focus = False
|
|
|
|
def __init__(self, side: str = "left", *, id: str | None = None) -> None:
|
|
super().__init__(id=id)
|
|
self.side = side
|
|
|
|
def render(self) -> RenderableType:
|
|
rows = max(1, self.size.height)
|
|
return Group(
|
|
*[side_row(row, rows, self.side) for row in range(rows)]
|
|
)
|