186 lines
6.3 KiB
Python
186 lines
6.3 KiB
Python
"""Pane frames: one grey box whose corners glow in the active palette.
|
|
|
|
The panes draw their own frames instead of using Textual's ``border`` so the
|
|
corners can be coloured. Two of them — the top right and the bottom left —
|
|
run a short fade out of the palette's hot end and back into the border grey;
|
|
everything else, including the other two corners, is that grey.
|
|
|
|
The ladder is whatever palette the app is on (``c`` cycles them), which is why
|
|
every function here takes ``stops`` rather than reading a module constant.
|
|
"""
|
|
|
|
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
|
|
|
|
from ..palettes import DEFAULT_PALETTE, PALETTES
|
|
from .common import active_stops
|
|
|
|
#: The frame itself: one grey, the same grey as the scrollbar thumb.
|
|
EDGE = "bright_black"
|
|
|
|
#: How many cells a corner fade reaches into the edge.
|
|
FADE_CELLS = 4
|
|
|
|
|
|
def corner_fade(distance: int, stops: tuple[str, ...] = ()) -> str:
|
|
"""Colour ``distance`` cells out from a corner; 0 is the corner glyph.
|
|
|
|
The fade is short — four cells — and walks backwards down the palette from
|
|
its hot end, so the corner glows in the palette's brightest colour and then
|
|
hands over to the plain border grey.
|
|
"""
|
|
if not stops:
|
|
stops = PALETTES[DEFAULT_PALETTE]
|
|
distance = max(0, distance)
|
|
if distance >= FADE_CELLS:
|
|
return EDGE
|
|
index = len(stops) - 1 - distance
|
|
return stops[index] if index >= 0 else EDGE
|
|
|
|
|
|
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, stops: tuple[str, ...]) -> 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 [("─", corner_fade(column + 1, stops)) for column in range(inner)]
|
|
return [("─", corner_fade(inner - column, stops)) 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 = "", stops: tuple[str, ...] = ()) -> Text:
|
|
width = max(2, width)
|
|
cells = _edge_cells(width, "right", stops) # 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 {corner_fade(0, stops)}")
|
|
return line
|
|
|
|
|
|
def bottom(width: int, stops: tuple[str, ...] = ()) -> Text:
|
|
width = max(2, width)
|
|
line = Text(no_wrap=True)
|
|
line.append("╰", style=f"bold {corner_fade(0, stops)}")
|
|
_paint(line, _edge_cells(width, "left", stops)) # only the bottom-left fades
|
|
line.append("╯", style=EDGE)
|
|
return line
|
|
|
|
|
|
def side_style(row: int, rows: int, side: str = "left", stops: tuple[str, ...] = ()) -> str:
|
|
"""Colour of a side cell: only two corners fade, so only one end glows."""
|
|
if side == "right":
|
|
return corner_fade(row + 1, stops) # fades down from the top-right corner
|
|
return corner_fade(rows - row, stops) # fades up from the bottom-left corner
|
|
|
|
|
|
def side_row(
|
|
row: int, rows: int, side: str = "left", stops: tuple[str, ...] = ()
|
|
) -> Text:
|
|
return Text("│", style=side_style(row, rows, side, stops))
|
|
|
|
|
|
def wrap(
|
|
lines: list[Text],
|
|
width: int,
|
|
height: int,
|
|
title: str = "",
|
|
stops: tuple[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, stops)]
|
|
for index, line in enumerate(lines[:inner_height]):
|
|
if cell_len(line.plain) > inner_width:
|
|
# A pane must never spill over its neighbour: trim, whatever the
|
|
# caller asked for.
|
|
line = line.copy()
|
|
line.truncate(inner_width, overflow="ellipsis")
|
|
row = Text(no_wrap=True)
|
|
row.append_text(side_row(index, rows, "left", stops))
|
|
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", stops))
|
|
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", stops))
|
|
row.append(" " * inner_width)
|
|
row.append_text(side_row(index, inner_height, "right", stops))
|
|
framed.append(row)
|
|
framed.append(bottom(width, stops))
|
|
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, active_stops(self.app))
|
|
|
|
|
|
class FrameBottom(Widget):
|
|
can_focus = False
|
|
|
|
def render(self) -> RenderableType:
|
|
return bottom(max(2, self.size.width), active_stops(self.app))
|
|
|
|
|
|
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)
|
|
stops = active_stops(self.app)
|
|
return Group(
|
|
*[side_row(row, rows, self.side, stops) for row in range(rows)]
|
|
)
|