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.
This commit is contained in:
1 parent
81116282cb
commit
ec2ca72efb
4 files changed
+117
-39
No files matched your search
@@ -75,6 +75,32 @@ PALETTES: dict[str, tuple[str, ...]] = {
|
|||||||
"mono": ("bright_black", "white", "bright_white"),
|
"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.
|
#: Cycle order for the palette key.
|
||||||
PALETTE_ORDER: tuple[str, ...] = tuple(PALETTES)
|
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."""
|
"""The ladder for ``name``, falling back to the default palette."""
|
||||||
key = (name or "").strip().lower()
|
key = (name or "").strip().lower()
|
||||||
return PALETTES.get(key, PALETTES[DEFAULT_PALETTE])
|
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)
|
||||||
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
The panes draw their own frames instead of using Textual's ``border`` so the
|
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 —
|
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;
|
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.
|
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
|
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.
|
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 __future__ import annotations
|
||||||
|
|
||||||
|
from functools import cache
|
||||||
|
|
||||||
from rich.cells import cell_len
|
from rich.cells import cell_len
|
||||||
from rich.console import Group, RenderableType
|
from rich.console import Group, RenderableType
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
from textual.widget import Widget
|
from textual.widget import Widget
|
||||||
|
|
||||||
from ..palettes import DEFAULT_PALETTE, PALETTES
|
from ..palettes import DEFAULT_PALETTE, PALETTES, brightness
|
||||||
from .common import active_stops
|
from .common import active_stops
|
||||||
|
|
||||||
#: The frame itself: one grey, the same grey as the scrollbar thumb.
|
#: The frame itself: one grey, the same grey as the scrollbar thumb.
|
||||||
@@ -26,20 +29,32 @@ EDGE = "bright_black"
|
|||||||
FADE_CELLS = 4
|
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:
|
def corner_fade(distance: int, stops: tuple[str, ...] = ()) -> str:
|
||||||
"""Colour ``distance`` cells out from a corner; 0 is the corner glyph.
|
"""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
|
The fade is short — four cells — and runs from the brightest colour the
|
||||||
its hot end, so the corner glows in the palette's brightest colour and then
|
palette's hot end offers back into the plain border grey.
|
||||||
hands over to the plain border grey.
|
|
||||||
"""
|
"""
|
||||||
if not stops:
|
if not stops:
|
||||||
stops = PALETTES[DEFAULT_PALETTE]
|
stops = PALETTES[DEFAULT_PALETTE]
|
||||||
|
ramp = fade_ramp(tuple(stops))
|
||||||
distance = max(0, distance)
|
distance = max(0, distance)
|
||||||
if distance >= FADE_CELLS:
|
return ramp[distance] if distance < len(ramp) else EDGE
|
||||||
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]:
|
def inner_size(width: int, height: int) -> tuple[int, int]:
|
||||||
@@ -119,7 +134,6 @@ def wrap(
|
|||||||
width = max(4, width)
|
width = max(4, width)
|
||||||
height = max(2, height)
|
height = max(2, height)
|
||||||
inner_width, inner_height = inner_size(width, height)
|
inner_width, inner_height = inner_size(width, height)
|
||||||
rows = len(lines[:inner_height])
|
|
||||||
|
|
||||||
framed: list[Text] = [top(width, title, stops)]
|
framed: list[Text] = [top(width, title, stops)]
|
||||||
for index, line in enumerate(lines[:inner_height]):
|
for index, line in enumerate(lines[:inner_height]):
|
||||||
@@ -129,12 +143,12 @@ def wrap(
|
|||||||
line = line.copy()
|
line = line.copy()
|
||||||
line.truncate(inner_width, overflow="ellipsis")
|
line.truncate(inner_width, overflow="ellipsis")
|
||||||
row = Text(no_wrap=True)
|
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)
|
row.append_text(line)
|
||||||
pad = inner_width - cell_len(line.plain)
|
pad = inner_width - cell_len(line.plain)
|
||||||
if pad > 0:
|
if pad > 0:
|
||||||
row.append(" " * pad)
|
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)
|
framed.append(row)
|
||||||
while len(framed) < height - 1:
|
while len(framed) < height - 1:
|
||||||
index = len(framed) - 1
|
index = len(framed) - 1
|
||||||
|
|||||||
+38
-20
@@ -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 __future__ import annotations
|
||||||
|
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
|
|
||||||
from beefy_player.palettes import PALETTES
|
from beefy_player.palettes import PALETTES, brightness
|
||||||
from beefy_player.widgets import frame
|
from beefy_player.widgets import frame
|
||||||
|
|
||||||
#: The original ladder, used as a fixed reference in these tests.
|
#: The original ladder, used as a fixed reference in these tests.
|
||||||
STOPS = PALETTES["spectrum"]
|
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]]:
|
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:
|
def test_only_the_top_right_corner_fades_on_the_top_edge() -> None:
|
||||||
spans = styles_of(frame.top(30, stops=STOPS))
|
spans = styles_of(frame.top(30, stops=STOPS))
|
||||||
assert spans[0] == ("╭", frame.EDGE) # plain grey corner
|
assert spans[0] == ("╭", frame.EDGE) # plain grey corner
|
||||||
# the fade runs into the top-right corner, hot end last
|
# the fade runs into the top-right corner, brightest cell last
|
||||||
assert [style for _text, style in spans[-4:-1]] == [
|
assert [style for _text, style in spans[-4:-1]] == [RAMP[3], RAMP[2], RAMP[1]]
|
||||||
STOPS[-4],
|
|
||||||
STOPS[-3],
|
|
||||||
STOPS[-2],
|
|
||||||
]
|
|
||||||
assert spans[-1] == ("╮", f"bold {HOT}")
|
assert spans[-1] == ("╮", f"bold {HOT}")
|
||||||
|
|
||||||
|
|
||||||
def test_only_the_bottom_left_corner_fades_on_the_bottom_edge() -> None:
|
def test_only_the_bottom_left_corner_fades_on_the_bottom_edge() -> None:
|
||||||
spans = styles_of(frame.bottom(30, STOPS))
|
spans = styles_of(frame.bottom(30, STOPS))
|
||||||
assert spans[0] == ("╰", f"bold {HOT}")
|
assert spans[0] == ("╰", f"bold {HOT}")
|
||||||
assert [style for _text, style in spans[1:4]] == [
|
assert [style for _text, style in spans[1:4]] == [RAMP[1], RAMP[2], RAMP[3]]
|
||||||
STOPS[-2],
|
|
||||||
STOPS[-3],
|
|
||||||
STOPS[-4],
|
|
||||||
]
|
|
||||||
assert spans[-1] == ("╯", frame.EDGE) # plain grey corner
|
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,
|
frame.EDGE,
|
||||||
frame.EDGE,
|
frame.EDGE,
|
||||||
STOPS[-4],
|
RAMP[3],
|
||||||
STOPS[-3],
|
RAMP[2],
|
||||||
STOPS[-2],
|
RAMP[1],
|
||||||
]
|
]
|
||||||
# right column fades down from the top-right corner
|
# right column fades down from the top-right corner
|
||||||
assert [frame.side_style(row, 10, "right", STOPS) for row in range(10)] == [
|
assert [frame.side_style(row, 10, "right", STOPS) for row in range(10)] == [
|
||||||
STOPS[-2],
|
RAMP[1],
|
||||||
STOPS[-3],
|
RAMP[2],
|
||||||
STOPS[-4],
|
RAMP[3],
|
||||||
frame.EDGE,
|
frame.EDGE,
|
||||||
frame.EDGE,
|
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("│")
|
assert lines[1].endswith("│")
|
||||||
# the original renderable is left untouched
|
# the original renderable is left untouched
|
||||||
assert long_line.plain == "x" * 40
|
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],
|
||||||
|
]
|
||||||
+21
-6
@@ -13,6 +13,7 @@ from beefy_player.palettes import (
|
|||||||
DEFAULT_PALETTE,
|
DEFAULT_PALETTE,
|
||||||
PALETTE_ORDER,
|
PALETTE_ORDER,
|
||||||
PALETTES,
|
PALETTES,
|
||||||
|
brightness,
|
||||||
palette_stops,
|
palette_stops,
|
||||||
)
|
)
|
||||||
from beefy_player.widgets import frame
|
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"])
|
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"]
|
stops = PALETTES["spectrum"]
|
||||||
assert frame.corner_fade(0, stops) == stops[-1]
|
ramp = frame.fade_ramp(stops)
|
||||||
assert frame.corner_fade(1, stops) == stops[-2]
|
assert frame.corner_fade(0, stops) == ramp[0]
|
||||||
assert frame.corner_fade(3, stops) == stops[-4]
|
assert frame.corner_fade(1, stops) == ramp[1]
|
||||||
# the fade is short: past four cells it is just the border grey
|
# the fade is short: past the ramp it is just the border grey
|
||||||
assert frame.corner_fade(4, stops) == frame.EDGE
|
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
|
@pytest.mark.asyncio
|
||||||
|
|||||||
Reference in new issue
Block a user