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