"""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, brightest colour at the corner; 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 functools import cache 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, brightness 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 @cache def fade_ramp(stops: tuple[str, ...]) -> tuple[str, ...]: """The corner ramp: brightest cell first, border grey past its end. The colours come from the palette's hot end, but a ladder is a hue walk, not a brightness walk: ``spectrum`` ends on ``red``, which is dimmer than the ``yellow`` just before it, so painting the ladder backwards put the dim colour at the corner and the glow out on the edge. Sorting the hot end by brightness keeps the corner the brightest cell and dims outwards, whatever theme sits behind the names. """ hot = [name for name in stops if name != EDGE][-FADE_CELLS:] return tuple(sorted(hot, key=brightness, reverse=True)) 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 runs from the brightest colour the palette's hot end offers back into the plain border grey. """ if not stops: stops = PALETTES[DEFAULT_PALETTE] ramp = fade_ramp(tuple(stops)) distance = max(0, distance) return ramp[distance] if distance < len(ramp) 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) 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, inner_height, "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, inner_height, "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)] )