Files
beefy-player/beefy_player/widgets/frame.py
T
beefcarver ec2ca72efb fix(frame): start the corner fade at the brightest palette stop
A ladder is a hue walk, not a brightness walk, so painting the hot end
backwards put the dim colour at the corner.  Sort the ramp by stock
brightness and follow the pane height for the bottom-left glow.
2026-11-04 15:49:26 -05:00

200 lines
7.0 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,
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)]
)