From ec2ca72efbe09a0287e36cc34214d816137ed171 Mon Sep 17 00:00:00 2001 From: beefcarver Date: Wed, 4 Nov 2026 15:49:26 -0500 Subject: [PATCH] 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. --- beefy_player/palettes.py | 31 +++++++++++++++++++ beefy_player/widgets/frame.py | 40 ++++++++++++++++-------- tests/test_frame.py | 58 +++++++++++++++++++++++------------ tests/test_palettes.py | 27 ++++++++++++---- 4 files changed, 117 insertions(+), 39 deletions(-) diff --git a/beefy_player/palettes.py b/beefy_player/palettes.py index 1dcbfb9..142d6fa 100644 --- a/beefy_player/palettes.py +++ b/beefy_player/palettes.py @@ -75,6 +75,32 @@ PALETTES: dict[str, tuple[str, ...]] = { "mono": ("bright_black", "white", "bright_white"), } +#: The stock names from dark to bright. These are xterm's default values in +#: Rec. 601 luma order — the closest thing to a stock answer. A theme may +#: disagree about the exact numbers, but the ordering is what the frame fades +#: need: a ladder is a *hue* walk, so where it ends says nothing about which +#: end is brighter. +BRIGHTNESS: tuple[str, ...] = ( + "black", + "blue", + "red", + "bright_red", + "magenta", + "bright_magenta", + "bright_blue", + "green", + "bright_black", + "cyan", + "bright_green", + "bright_cyan", + "yellow", + "white", + "bright_yellow", + "bright_white", +) + +_RANK = {name: rank for rank, name in enumerate(BRIGHTNESS)} + #: Cycle order for the palette key. PALETTE_ORDER: tuple[str, ...] = tuple(PALETTES) @@ -86,3 +112,8 @@ def palette_stops(name: str | None) -> tuple[str, ...]: """The ladder for ``name``, falling back to the default palette.""" key = (name or "").strip().lower() return PALETTES.get(key, PALETTES[DEFAULT_PALETTE]) + + +def brightness(colour: str) -> int: + """How bright a stock name is: 0 (black) to 15 (bright white).""" + return _RANK.get(colour.strip().lower(), 0) diff --git a/beefy_player/widgets/frame.py b/beefy_player/widgets/frame.py index 9cf53ac..51daf55 100644 --- a/beefy_player/widgets/frame.py +++ b/beefy_player/widgets/frame.py @@ -2,8 +2,9 @@ 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. +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. @@ -11,12 +12,14 @@ 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 +from ..palettes import DEFAULT_PALETTE, PALETTES, brightness from .common import active_stops #: The frame itself: one grey, the same grey as the scrollbar thumb. @@ -26,20 +29,32 @@ EDGE = "bright_black" 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 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. + 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) - if distance >= FADE_CELLS: - return EDGE - index = len(stops) - 1 - distance - return stops[index] if index >= 0 else EDGE + return ramp[distance] if distance < len(ramp) else EDGE def inner_size(width: int, height: int) -> tuple[int, int]: @@ -119,7 +134,6 @@ def wrap( 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]): @@ -129,12 +143,12 @@ def wrap( 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(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, rows, "right", stops)) + row.append_text(side_row(index, inner_height, "right", stops)) framed.append(row) while len(framed) < height - 1: index = len(framed) - 1 diff --git a/tests/test_frame.py b/tests/test_frame.py index 00124a3..f4ad71f 100644 --- a/tests/test_frame.py +++ b/tests/test_frame.py @@ -1,15 +1,17 @@ -"""The BBS frames: grey edges, corners fading white → grey → cyan.""" +"""The BBS frames: grey edges, and a short palette glow in two corners.""" from __future__ import annotations from rich.text import Text -from beefy_player.palettes import PALETTES +from beefy_player.palettes import PALETTES, brightness from beefy_player.widgets import frame #: The original ladder, used as a fixed reference in these tests. STOPS = PALETTES["spectrum"] -HOT = STOPS[-1] # the corner colour +#: Its glow: the brightest cell first, dimmer cells towards the edge. +RAMP = frame.fade_ramp(STOPS) +HOT = RAMP[0] # the corner colour def styles_of(line: Text) -> list[tuple[str, str]]: @@ -18,26 +20,29 @@ def styles_of(line: Text) -> list[tuple[str, str]]: ] +def test_the_corner_gets_the_brightest_colour_of_the_fade() -> None: + ranks = [brightness(colour) for colour in RAMP] + assert ranks == sorted(ranks, reverse=True) + # spectrum's hot end is red, which is dimmer than the yellow before it: + # the corner must still be the bright one, not the ladder's last stop + assert STOPS[-1] != HOT + assert brightness(HOT) > brightness(STOPS[-1]) + assert set(RAMP) <= set(STOPS) + assert frame.corner_fade(len(RAMP), STOPS) == frame.EDGE + + def test_only_the_top_right_corner_fades_on_the_top_edge() -> None: spans = styles_of(frame.top(30, stops=STOPS)) assert spans[0] == ("╭", frame.EDGE) # plain grey corner - # the fade runs into the top-right corner, hot end last - assert [style for _text, style in spans[-4:-1]] == [ - STOPS[-4], - STOPS[-3], - STOPS[-2], - ] + # the fade runs into the top-right corner, brightest cell last + assert [style for _text, style in spans[-4:-1]] == [RAMP[3], RAMP[2], RAMP[1]] assert spans[-1] == ("╮", f"bold {HOT}") def test_only_the_bottom_left_corner_fades_on_the_bottom_edge() -> None: spans = styles_of(frame.bottom(30, STOPS)) assert spans[0] == ("╰", f"bold {HOT}") - assert [style for _text, style in spans[1:4]] == [ - STOPS[-2], - STOPS[-3], - STOPS[-4], - ] + assert [style for _text, style in spans[1:4]] == [RAMP[1], RAMP[2], RAMP[3]] assert spans[-1] == ("╯", frame.EDGE) # plain grey corner @@ -51,15 +56,15 @@ def test_the_sides_glow_towards_their_own_corner_only() -> None: frame.EDGE, frame.EDGE, frame.EDGE, - STOPS[-4], - STOPS[-3], - STOPS[-2], + RAMP[3], + RAMP[2], + RAMP[1], ] # right column fades down from the top-right corner assert [frame.side_style(row, 10, "right", STOPS) for row in range(10)] == [ - STOPS[-2], - STOPS[-3], - STOPS[-4], + RAMP[1], + RAMP[2], + RAMP[3], frame.EDGE, frame.EDGE, frame.EDGE, @@ -98,3 +103,16 @@ def test_content_wider_than_the_pane_is_trimmed_not_spilled() -> None: assert lines[1].endswith("│") # the original renderable is left untouched assert long_line.plain == "x" * 40 + + +def test_a_short_pane_still_glows_in_its_bottom_left_corner() -> None: + """The fade follows the pane's height, not the number of content rows.""" + group = frame.wrap([Text("one line")], 20, 6, stops=STOPS) + lines = list(group.renderables) + # rows 1-4 are the content box; the fade climbs its last three rows + assert str(lines[1].spans[0].style) == frame.EDGE + assert [str(line.spans[0].style) for line in lines[2:5]] == [ + RAMP[3], + RAMP[2], + RAMP[1], + ] diff --git a/tests/test_palettes.py b/tests/test_palettes.py index dad2b55..3deac90 100644 --- a/tests/test_palettes.py +++ b/tests/test_palettes.py @@ -13,6 +13,7 @@ from beefy_player.palettes import ( DEFAULT_PALETTE, PALETTE_ORDER, PALETTES, + brightness, palette_stops, ) from beefy_player.widgets import frame @@ -133,13 +134,27 @@ async def test_the_spectrum_rows_use_the_active_palette() -> None: assert colours(spectrum._row_palette(12, 0, False)) <= set(PALETTES["neon"]) -def test_the_corner_fade_walks_back_down_the_ladder() -> None: +def test_the_corner_fade_starts_at_the_brightest_stop() -> None: stops = PALETTES["spectrum"] - assert frame.corner_fade(0, stops) == stops[-1] - assert frame.corner_fade(1, stops) == stops[-2] - assert frame.corner_fade(3, stops) == stops[-4] - # the fade is short: past four cells it is just the border grey - assert frame.corner_fade(4, stops) == frame.EDGE + ramp = frame.fade_ramp(stops) + assert frame.corner_fade(0, stops) == ramp[0] + assert frame.corner_fade(1, stops) == ramp[1] + # the fade is short: past the ramp it is just the border grey + assert frame.corner_fade(len(ramp), stops) == frame.EDGE + # spectrum's hot end is red, which is dimmer than the yellow before it; + # the corner still gets the bright colour + assert ramp[0] != stops[-1] + assert ramp[-1] == stops[-1] + + +def test_every_palette_fades_from_bright_to_dim() -> None: + """No palette may put a brighter cell further from the corner.""" + for name, stops in PALETTES.items(): + ramp = frame.fade_ramp(stops) + assert ramp, name + ranks = [brightness(colour) for colour in ramp] + assert ranks == sorted(ranks, reverse=True), (name, ramp) + assert set(ramp) <= set(stops), name @pytest.mark.asyncio