"""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)] )