feat(palette): sixteen ANSI colour ladders, cycled with c
This commit is contained in:
1 parent
253b462df1
commit
43ddd8666b
14 files changed
+622
-111
No files matched your search
@@ -84,6 +84,11 @@ implemented here from public endpoints and open protocols.
|
|||||||
lock screens, `playerctl` and the media keys pick it up. On macOS the audio
|
lock screens, `playerctl` and the media keys pick it up. On macOS the audio
|
||||||
engine (mpv) publishes to the system **Now Playing** session, so Control
|
engine (mpv) publishes to the system **Now Playing** session, so Control
|
||||||
Center, the menu bar and the keyboard media keys drive the app the same way.
|
Center, the menu bar and the keyboard media keys drive the app the same way.
|
||||||
|
- **Sixteen palettes** — every colour comes from one ladder of ANSI names per
|
||||||
|
theme: the spectrum analyser, the album-art mosaic, the provider wordmarks
|
||||||
|
and the pane corners all draw from it. `c` cycles spectrum, rainbow, ocean,
|
||||||
|
ember, mint, sunset, violet, acid, neon, sand, blood, forest, ice, steel,
|
||||||
|
candy and mono; the choice is remembered.
|
||||||
- **Theme-aware** — every accent is emitted as native ANSI (not RGB), and the
|
- **Theme-aware** — every accent is emitted as native ANSI (not RGB), and the
|
||||||
background is the terminal default (`SGR 49`), so transparent terminals stay
|
background is the terminal default (`SGR 49`), so transparent terminals stay
|
||||||
transparent and the TUI adopts your shell/terminal colour scheme.
|
transparent and the TUI adopts your shell/terminal colour scheme.
|
||||||
@@ -350,6 +355,7 @@ The UI wants roughly **84×24**; it also works smaller, just less pretty.
|
|||||||
| `/` | filter by title, genre, description |
|
| `/` | filter by title, genre, description |
|
||||||
| `esc` | clear filter / close search |
|
| `esc` | clear filter / close search |
|
||||||
| `v` | visualiser: mirror → bars → wave → waterfall → radial → braille |
|
| `v` | visualiser: mirror → bars → wave → waterfall → radial → braille |
|
||||||
|
| `c` | colour palette (16 of them, remembered) |
|
||||||
| `r` | refresh stations, schedule + song history |
|
| `r` | refresh stations, schedule + song history |
|
||||||
| `p` | switch station catalogue (SomaFM / tilderadio)|
|
| `p` | switch station catalogue (SomaFM / tilderadio)|
|
||||||
| `g` / `G` | top / bottom |
|
| `g` / `G` | top / bottom |
|
||||||
|
|||||||
+32
-1
@@ -26,10 +26,12 @@ from textual.widgets import Input
|
|||||||
from . import artwork, bandcamp, config
|
from . import artwork, bandcamp, config
|
||||||
from .mediasession import create_media_session, media_actions_queue
|
from .mediasession import create_media_session, media_actions_queue
|
||||||
from .models import Broadcast, Station, parse_last_playing
|
from .models import Broadcast, Station, parse_last_playing
|
||||||
|
from .palettes import PALETTE_ORDER
|
||||||
from .player import Player
|
from .player import Player
|
||||||
from .providers import get_provider
|
from .providers import get_provider
|
||||||
from .providers.base import is_direct_stream, make_client
|
from .providers.base import is_direct_stream, make_client
|
||||||
from .spectrum import SpectrumEngine
|
from .spectrum import SpectrumEngine
|
||||||
|
from .widgets.art import ArtPane
|
||||||
from .widgets.header import HeaderBar
|
from .widgets.header import HeaderBar
|
||||||
from .widgets.help import HelpScreen
|
from .widgets.help import HelpScreen
|
||||||
from .widgets.providers import ProviderScreen
|
from .widgets.providers import ProviderScreen
|
||||||
@@ -92,6 +94,7 @@ class BeefyPlayerApp(App[None]):
|
|||||||
Binding("slash", "search", "filter"),
|
Binding("slash", "search", "filter"),
|
||||||
Binding("escape", "escape", "close", show=False),
|
Binding("escape", "escape", "close", show=False),
|
||||||
Binding("v", "visualizer", "visualiser"),
|
Binding("v", "visualizer", "visualiser"),
|
||||||
|
Binding("c", "palette", "palette"),
|
||||||
Binding("r", "refresh", "refresh"),
|
Binding("r", "refresh", "refresh"),
|
||||||
Binding("p", "provider", "catalogue"),
|
Binding("p", "provider", "catalogue"),
|
||||||
Binding("question_mark", "help", "help"),
|
Binding("question_mark", "help", "help"),
|
||||||
@@ -147,6 +150,8 @@ class BeefyPlayerApp(App[None]):
|
|||||||
self.playing_state = "stopped"
|
self.playing_state = "stopped"
|
||||||
self.volume = self.config.volume
|
self.volume = self.config.volume
|
||||||
self.visualizer = self.config.visualizer
|
self.visualizer = self.config.visualizer
|
||||||
|
#: Colour palette name (see :mod:`beefy_player.palettes`).
|
||||||
|
self.palette = self.config.palette
|
||||||
self.tick_count = 0
|
self.tick_count = 0
|
||||||
self.notice = ""
|
self.notice = ""
|
||||||
|
|
||||||
@@ -956,6 +961,20 @@ class BeefyPlayerApp(App[None]):
|
|||||||
self._set_notice(f"visualiser: {self.visualizer}", 2)
|
self._set_notice(f"visualiser: {self.visualizer}", 2)
|
||||||
self.query_one(SpectrumPanel).refresh()
|
self.query_one(SpectrumPanel).refresh()
|
||||||
|
|
||||||
|
def action_palette(self) -> None:
|
||||||
|
order = list(PALETTE_ORDER)
|
||||||
|
index = order.index(self.palette) if self.palette in order else 0
|
||||||
|
self.palette = order[(index + 1) % len(order)]
|
||||||
|
self.config.palette = self.palette
|
||||||
|
self._save_config()
|
||||||
|
self._set_notice(f"palette: {self.palette}", 2)
|
||||||
|
# Frames, spectrum, mosaic and wordmarks all read the palette live, so
|
||||||
|
# a repaint of every widget is all it takes. (``self.query`` is the
|
||||||
|
# station filter here, hence ``self.screen.query``.)
|
||||||
|
for widget in self.screen.query("*"):
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
widget.refresh()
|
||||||
|
|
||||||
def action_refresh(self) -> None:
|
def action_refresh(self) -> None:
|
||||||
self._last_history_fetch = 0.0
|
self._last_history_fetch = 0.0
|
||||||
self._bandcamp_key = ""
|
self._bandcamp_key = ""
|
||||||
@@ -972,12 +991,23 @@ class BeefyPlayerApp(App[None]):
|
|||||||
self._fetch_history(self.current.id)
|
self._fetch_history(self.current.id)
|
||||||
|
|
||||||
def action_provider(self) -> None:
|
def action_provider(self) -> None:
|
||||||
|
self._suspend_artwork()
|
||||||
self.push_screen(ProviderScreen(self.provider.id), self._provider_chosen)
|
self.push_screen(ProviderScreen(self.provider.id), self._provider_chosen)
|
||||||
|
|
||||||
def _provider_chosen(self, provider_id: str | None) -> None:
|
def _provider_chosen(self, provider_id: str | None) -> None:
|
||||||
|
self._resume_artwork()
|
||||||
if provider_id:
|
if provider_id:
|
||||||
self._switch_catalogue(provider_id)
|
self._switch_catalogue(provider_id)
|
||||||
|
|
||||||
|
def _suspend_artwork(self) -> None:
|
||||||
|
"""Hide the inline artwork: terminal images would sit over a menu."""
|
||||||
|
with contextlib.suppress(NoMatches):
|
||||||
|
self.query_one(ArtPane).suspend()
|
||||||
|
|
||||||
|
def _resume_artwork(self) -> None:
|
||||||
|
with contextlib.suppress(NoMatches):
|
||||||
|
self.query_one(ArtPane).resume()
|
||||||
|
|
||||||
def _cancel_workers(self) -> None:
|
def _cancel_workers(self) -> None:
|
||||||
for group in ("stream", "history", "stations", "schedule", "bandcamp", "artwork"):
|
for group in ("stream", "history", "stations", "schedule", "bandcamp", "artwork"):
|
||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
@@ -1051,7 +1081,8 @@ class BeefyPlayerApp(App[None]):
|
|||||||
self._refresh_schedule()
|
self._refresh_schedule()
|
||||||
|
|
||||||
def action_help(self) -> None:
|
def action_help(self) -> None:
|
||||||
self.push_screen(HelpScreen())
|
self._suspend_artwork()
|
||||||
|
self.push_screen(HelpScreen(), lambda _result: self._resume_artwork())
|
||||||
|
|
||||||
def action_quit(self) -> None:
|
def action_quit(self) -> None:
|
||||||
self._cleanup()
|
self._cleanup()
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from . import __version__
|
from . import __version__
|
||||||
|
from .palettes import DEFAULT_PALETTE, PALETTES
|
||||||
|
|
||||||
#: Directory name for config, cache and runtime files.
|
#: Directory name for config, cache and runtime files.
|
||||||
APP_NAME = "beefy-player"
|
APP_NAME = "beefy-player"
|
||||||
@@ -72,6 +73,9 @@ LEGACY_CONFIG_PATH = _xdg("XDG_CONFIG_HOME", ".config") / LEGACY_APP_NAME / "con
|
|||||||
|
|
||||||
VISUALIZERS = ("both", "bars", "wave", "waterfall", "radial", "braille")
|
VISUALIZERS = ("both", "bars", "wave", "waterfall", "radial", "braille")
|
||||||
|
|
||||||
|
#: Colour palettes the UI can draw with (see :mod:`beefy_player.palettes`).
|
||||||
|
PALETTE_NAMES = tuple(PALETTES)
|
||||||
|
|
||||||
|
|
||||||
def channels_cache_path(provider_id: str) -> Path:
|
def channels_cache_path(provider_id: str) -> Path:
|
||||||
"""Where a catalogue caches its last good station list.
|
"""Where a catalogue caches its last good station list.
|
||||||
@@ -141,6 +145,8 @@ class Config:
|
|||||||
last_stations: dict[str, str] = field(default_factory=dict)
|
last_stations: dict[str, str] = field(default_factory=dict)
|
||||||
volume: int = 70
|
volume: int = 70
|
||||||
visualizer: str = "both"
|
visualizer: str = "both"
|
||||||
|
#: Colour palette name — spectrum, ocean, ember, …
|
||||||
|
palette: str = DEFAULT_PALETTE
|
||||||
autoplay: bool = False
|
autoplay: bool = False
|
||||||
#: Which catalogue to use by default (see :mod:`beefy_player.providers`).
|
#: Which catalogue to use by default (see :mod:`beefy_player.providers`).
|
||||||
provider: str = "somafm"
|
provider: str = "somafm"
|
||||||
@@ -181,6 +187,10 @@ class Config:
|
|||||||
if visualizer in VISUALIZERS:
|
if visualizer in VISUALIZERS:
|
||||||
config.visualizer = visualizer
|
config.visualizer = visualizer
|
||||||
|
|
||||||
|
palette = raw.get("palette")
|
||||||
|
if isinstance(palette, str) and palette in PALETTE_NAMES:
|
||||||
|
config.palette = palette
|
||||||
|
|
||||||
provider = raw.get("provider")
|
provider = raw.get("provider")
|
||||||
if isinstance(provider, str) and provider.strip():
|
if isinstance(provider, str) and provider.strip():
|
||||||
config.provider = provider.strip().lower()
|
config.provider = provider.strip().lower()
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""The colour palettes everything in the UI draws from.
|
||||||
|
|
||||||
|
Every colour in Beefy Player is a stock ANSI palette *name*, never RGB, so the
|
||||||
|
TUI adopts whatever colours the terminal is configured with. A palette here is
|
||||||
|
therefore a short ladder of those names — the "gradient" is a quantised walk
|
||||||
|
from the quiet end to the hot end, not an interpolation.
|
||||||
|
|
||||||
|
The active palette drives the spectrum analyser, the album-art mosaic, the
|
||||||
|
provider wordmarks and the pane corners; `c` cycles through them and the
|
||||||
|
choice is remembered in the config file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
#: Dark → hot ladders of ANSI names. Sixteen of them: seven hue gradients, a
|
||||||
|
#: few near-monochromes, and the original spectrum.
|
||||||
|
PALETTES: dict[str, tuple[str, ...]] = {
|
||||||
|
"spectrum": (
|
||||||
|
"cyan",
|
||||||
|
"blue",
|
||||||
|
"magenta",
|
||||||
|
"bright_magenta",
|
||||||
|
"yellow",
|
||||||
|
"bright_yellow",
|
||||||
|
"red",
|
||||||
|
),
|
||||||
|
"rainbow": (
|
||||||
|
"red",
|
||||||
|
"bright_yellow",
|
||||||
|
"bright_green",
|
||||||
|
"bright_cyan",
|
||||||
|
"bright_blue",
|
||||||
|
"bright_magenta",
|
||||||
|
),
|
||||||
|
"ocean": (
|
||||||
|
"bright_black",
|
||||||
|
"blue",
|
||||||
|
"bright_blue",
|
||||||
|
"cyan",
|
||||||
|
"bright_cyan",
|
||||||
|
"bright_white",
|
||||||
|
),
|
||||||
|
"ember": (
|
||||||
|
"bright_black",
|
||||||
|
"red",
|
||||||
|
"bright_red",
|
||||||
|
"yellow",
|
||||||
|
"bright_yellow",
|
||||||
|
"bright_white",
|
||||||
|
),
|
||||||
|
"mint": (
|
||||||
|
"bright_black",
|
||||||
|
"green",
|
||||||
|
"bright_green",
|
||||||
|
"cyan",
|
||||||
|
"bright_cyan",
|
||||||
|
"bright_white",
|
||||||
|
),
|
||||||
|
"sunset": ("magenta", "bright_magenta", "red", "bright_red", "yellow"),
|
||||||
|
"violet": ("blue", "bright_blue", "magenta", "bright_magenta", "bright_white"),
|
||||||
|
"acid": ("bright_black", "green", "bright_green", "yellow", "bright_yellow"),
|
||||||
|
"neon": (
|
||||||
|
"bright_magenta",
|
||||||
|
"bright_red",
|
||||||
|
"bright_yellow",
|
||||||
|
"bright_green",
|
||||||
|
"bright_cyan",
|
||||||
|
),
|
||||||
|
"sand": ("bright_black", "yellow", "bright_yellow", "white", "bright_white"),
|
||||||
|
"blood": ("bright_black", "red", "bright_red", "bright_magenta"),
|
||||||
|
"forest": ("bright_black", "green", "bright_green", "yellow", "bright_white"),
|
||||||
|
"ice": ("bright_white", "bright_cyan", "cyan", "bright_blue", "blue"),
|
||||||
|
"steel": ("bright_black", "white", "bright_white", "bright_cyan"),
|
||||||
|
"candy": ("magenta", "bright_magenta", "bright_cyan", "bright_yellow"),
|
||||||
|
"mono": ("bright_black", "white", "bright_white"),
|
||||||
|
}
|
||||||
|
|
||||||
|
#: Cycle order for the palette key.
|
||||||
|
PALETTE_ORDER: tuple[str, ...] = tuple(PALETTES)
|
||||||
|
|
||||||
|
#: What you get until you press `c`.
|
||||||
|
DEFAULT_PALETTE = "spectrum"
|
||||||
|
|
||||||
|
|
||||||
|
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])
|
||||||
@@ -22,7 +22,7 @@ from textual.strip import Strip
|
|||||||
from textual.widget import Widget
|
from textual.widget import Widget
|
||||||
|
|
||||||
from .. import artwork
|
from .. import artwork
|
||||||
from .common import art_lines
|
from .common import active_stops, art_lines
|
||||||
|
|
||||||
_NULL_STYLE = Style()
|
_NULL_STYLE = Style()
|
||||||
_NULL_CONTROL = ((ControlType.CURSOR_FORWARD, 0),)
|
_NULL_CONTROL = ((ControlType.CURSOR_FORWARD, 0),)
|
||||||
@@ -308,6 +308,8 @@ class ArtPane(Widget):
|
|||||||
self._last_data: bytes | None = None
|
self._last_data: bytes | None = None
|
||||||
self._last_size: tuple[int, int] = (0, 0)
|
self._last_size: tuple[int, int] = (0, 0)
|
||||||
self._mosaic_key = ""
|
self._mosaic_key = ""
|
||||||
|
#: True while a modal is up: the inline image would sit over the menu.
|
||||||
|
self._suspended = False
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# mosaic background
|
# mosaic background
|
||||||
@@ -320,7 +322,12 @@ class ArtPane(Widget):
|
|||||||
return Text("")
|
return Text("")
|
||||||
width = max(1, self.size.width)
|
width = max(1, self.size.width)
|
||||||
height = max(1, self.size.height)
|
height = max(1, self.size.height)
|
||||||
return Group(*art_lines(_art_key(app), width, height))
|
if artwork.supported() and app.artwork_bytes is not None:
|
||||||
|
# A real image occupies this box (letterboxed, so not all of it).
|
||||||
|
# Leave the space blank: the patterned mosaic behind a scaled
|
||||||
|
# picture reads as noise around its edges.
|
||||||
|
return Group(*[Text(" " * width) for _ in range(height)])
|
||||||
|
return Group(*art_lines(_art_key(app), width, height, active_stops(app)))
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# image overlay
|
# image overlay
|
||||||
@@ -337,9 +344,27 @@ class ArtPane(Widget):
|
|||||||
def on_unmount(self) -> None:
|
def on_unmount(self) -> None:
|
||||||
self._drop_image_widget()
|
self._drop_image_widget()
|
||||||
|
|
||||||
|
def suspend(self) -> None:
|
||||||
|
"""Take the inline image down so a modal is not drawn underneath it.
|
||||||
|
|
||||||
|
Kitty and iTerm2 paint images into cells the terminal owns; Textual
|
||||||
|
repainting the menu over them does not remove them, so the artwork has
|
||||||
|
to be deleted and re-emitted rather than covered.
|
||||||
|
"""
|
||||||
|
self._suspended = True
|
||||||
|
self._drop_image_widget()
|
||||||
|
self.refresh(layout=True)
|
||||||
|
|
||||||
|
def resume(self) -> None:
|
||||||
|
"""Put the artwork back after the modal closes."""
|
||||||
|
self._suspended = False
|
||||||
|
self.sync_image(force=True)
|
||||||
|
|
||||||
def sync_image(self, *, force: bool = False) -> None:
|
def sync_image(self, *, force: bool = False) -> None:
|
||||||
# Keep the mosaic behind the image in sync with the current track.
|
# Keep the mosaic behind the image in sync with the current track.
|
||||||
self.refresh()
|
self.refresh()
|
||||||
|
if self._suspended:
|
||||||
|
return
|
||||||
|
|
||||||
app = cast("BeefyPlayerApp", self.app)
|
app = cast("BeefyPlayerApp", self.app)
|
||||||
station = app.current
|
station = app.current
|
||||||
|
|||||||
@@ -19,23 +19,20 @@ from zoneinfo import ZoneInfo
|
|||||||
from rich.cells import cell_len
|
from rich.cells import cell_len
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
|
|
||||||
|
from ..palettes import (
|
||||||
|
DEFAULT_PALETTE,
|
||||||
|
palette_stops,
|
||||||
|
)
|
||||||
|
|
||||||
LOCALTIME_PATH = "/etc/localtime"
|
LOCALTIME_PATH = "/etc/localtime"
|
||||||
_UTC_NAMES = {"UTC", "UCT", "GMT", "ETC/UTC", "ETC/UCT", "ETC/GMT", "Z"}
|
_UTC_NAMES = {"UTC", "UCT", "GMT", "ETC/UTC", "ETC/UCT", "ETC/GMT", "Z"}
|
||||||
|
|
||||||
LEVELS = " ▁▂▃▄▅▆▇█"
|
LEVELS = " ▁▂▃▄▅▆▇█"
|
||||||
|
|
||||||
# Bottom-to-top spectrum colours, picked from the terminal's stock ANSI palette.
|
def active_stops(app: object) -> tuple[str, ...]:
|
||||||
SPECTRUM_STOPS = (
|
"""The colour ladder the app is currently drawing with."""
|
||||||
"cyan",
|
return palette_stops(getattr(app, "palette", DEFAULT_PALETTE))
|
||||||
"blue",
|
|
||||||
"magenta",
|
|
||||||
"bright_magenta",
|
|
||||||
"yellow",
|
|
||||||
"bright_yellow",
|
|
||||||
"red",
|
|
||||||
)
|
|
||||||
|
|
||||||
ART_STOPS = ("blue", "magenta", "bright_magenta", "yellow", "cyan", "green")
|
|
||||||
|
|
||||||
STATE_STYLE = {
|
STATE_STYLE = {
|
||||||
"playing": ("●", "green"),
|
"playing": ("●", "green"),
|
||||||
@@ -182,7 +179,9 @@ def state_glyph(state: str) -> tuple[str, str]:
|
|||||||
return STATE_STYLE.get(state, STATE_STYLE["stopped"])
|
return STATE_STYLE.get(state, STATE_STYLE["stopped"])
|
||||||
|
|
||||||
|
|
||||||
def art_lines(key: str, width: int, height: int) -> list[Text]:
|
def art_lines(
|
||||||
|
key: str, width: int, height: int, stops: tuple[str, ...]
|
||||||
|
) -> list[Text]:
|
||||||
"""Procedural album art as a solid ANSI-colour mosaic.
|
"""Procedural album art as a solid ANSI-colour mosaic.
|
||||||
|
|
||||||
Filled block glyphs are used (never cell backgrounds), so the cover is
|
Filled block glyphs are used (never cell backgrounds), so the cover is
|
||||||
@@ -200,7 +199,7 @@ def art_lines(key: str, width: int, height: int) -> list[Text]:
|
|||||||
nx = column / max(1, width - 1)
|
nx = column / max(1, width - 1)
|
||||||
wave = 0.5 + 0.5 * math.sin(nx * 2.9 + ny * 2.1 + (seed % 11))
|
wave = 0.5 + 0.5 * math.sin(nx * 2.9 + ny * 2.1 + (seed % 11))
|
||||||
position = (nx * 0.55 + (1.0 - ny) * 0.30 + wave * 0.15) % 1.0
|
position = (nx * 0.55 + (1.0 - ny) * 0.30 + wave * 0.15) % 1.0
|
||||||
colour = gradient(ART_STOPS, position)
|
colour = gradient(stops, position)
|
||||||
# Mostly solid blocks with a little texture, like a tiny cover.
|
# Mostly solid blocks with a little texture, like a tiny cover.
|
||||||
glyph = "█" if ((seed + column * 7 + row * 11) % 17) not in (0, 5) else "▓"
|
glyph = "█" if ((seed + column * 7 + row * 11) % 17) not in (0, 5) else "▓"
|
||||||
line.append(glyph, style=colour)
|
line.append(glyph, style=colour)
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
"""Pane frames: one grey box, with two corners fading out of it.
|
"""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
|
The panes draw their own frames instead of using Textual's ``border`` so the
|
||||||
corners can be coloured individually. Two of them — the top right and the
|
corners can be coloured. Two of them — the top right and the bottom left —
|
||||||
bottom left — run white → light grey → bright cyan → cyan along both arms;
|
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 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 __future__ import annotations
|
||||||
@@ -13,26 +16,30 @@ 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 .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.
|
||||||
EDGE = "bright_black"
|
EDGE = "bright_black"
|
||||||
|
|
||||||
#: A faded corner runs white → light grey → bright cyan → cyan, then settles
|
#: How many cells a corner fade reaches into the edge.
|
||||||
#: back into the border grey.
|
FADE_CELLS = 4
|
||||||
CORNER_GRADIENT = ("bright_white", "white", "bright_cyan", "cyan")
|
|
||||||
|
|
||||||
|
|
||||||
def fade(distance: int) -> str:
|
def corner_fade(distance: int, stops: tuple[str, ...] = ()) -> str:
|
||||||
"""Colour ``distance`` cells away from a corner (0 is the corner itself)."""
|
"""Colour ``distance`` cells out from a corner; 0 is the corner glyph.
|
||||||
if 0 <= distance < len(CORNER_GRADIENT):
|
|
||||||
return CORNER_GRADIENT[distance]
|
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
|
return EDGE
|
||||||
|
index = len(stops) - 1 - distance
|
||||||
|
return stops[index] if index >= 0 else EDGE
|
||||||
def side_style(row: int, rows: int, side: str = "left") -> str:
|
|
||||||
"""Colour of a side cell: only two corners fade, so only one end glows."""
|
|
||||||
if side == "right":
|
|
||||||
return fade(row + 1) # fades down from the top-right corner
|
|
||||||
return fade(rows - row) # fades up from the bottom-left corner
|
|
||||||
|
|
||||||
|
|
||||||
def inner_size(width: int, height: int) -> tuple[int, int]:
|
def inner_size(width: int, height: int) -> tuple[int, int]:
|
||||||
@@ -40,12 +47,12 @@ def inner_size(width: int, height: int) -> tuple[int, int]:
|
|||||||
return max(1, width - 2), max(1, height - 2)
|
return max(1, width - 2), max(1, height - 2)
|
||||||
|
|
||||||
|
|
||||||
def _edge_cells(width: int, faded_end: str = "right") -> list[tuple[str, str]]:
|
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 cells of an edge; only one end of it fades into a corner."""
|
||||||
inner = max(0, width - 2)
|
inner = max(0, width - 2)
|
||||||
if faded_end == "left":
|
if faded_end == "left":
|
||||||
return [("─", fade(column + 1)) for column in range(inner)]
|
return [("─", corner_fade(column + 1, stops)) for column in range(inner)]
|
||||||
return [("─", fade(inner - column)) 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:
|
def _paint(line: Text, cells: list[tuple[str, str]]) -> None:
|
||||||
@@ -62,9 +69,9 @@ def _paint(line: Text, cells: list[tuple[str, str]]) -> None:
|
|||||||
line.append("".join(run), style=style)
|
line.append("".join(run), style=style)
|
||||||
|
|
||||||
|
|
||||||
def top(width: int, title: str = "") -> Text:
|
def top(width: int, title: str = "", stops: tuple[str, ...] = ()) -> Text:
|
||||||
width = max(2, width)
|
width = max(2, width)
|
||||||
cells = _edge_cells(width, "right") # only the top-right corner fades
|
cells = _edge_cells(width, "right", stops) # only the top-right corner fades
|
||||||
if title:
|
if title:
|
||||||
label = f" {title} "
|
label = f" {title} "
|
||||||
start = 1
|
start = 1
|
||||||
@@ -75,29 +82,38 @@ def top(width: int, title: str = "") -> Text:
|
|||||||
line = Text(no_wrap=True)
|
line = Text(no_wrap=True)
|
||||||
line.append("╭", style=EDGE)
|
line.append("╭", style=EDGE)
|
||||||
_paint(line, cells)
|
_paint(line, cells)
|
||||||
line.append("╮", style=f"bold {fade(0)}")
|
line.append("╮", style=f"bold {corner_fade(0, stops)}")
|
||||||
return line
|
return line
|
||||||
|
|
||||||
|
|
||||||
def bottom(width: int) -> Text:
|
def bottom(width: int, stops: tuple[str, ...] = ()) -> Text:
|
||||||
width = max(2, width)
|
width = max(2, width)
|
||||||
line = Text(no_wrap=True)
|
line = Text(no_wrap=True)
|
||||||
line.append("╰", style=f"bold {fade(0)}")
|
line.append("╰", style=f"bold {corner_fade(0, stops)}")
|
||||||
_paint(line, _edge_cells(width, "left")) # only the bottom-left fades
|
_paint(line, _edge_cells(width, "left", stops)) # only the bottom-left fades
|
||||||
line.append("╯", style=EDGE)
|
line.append("╯", style=EDGE)
|
||||||
return line
|
return line
|
||||||
|
|
||||||
|
|
||||||
def side() -> Text:
|
def side_style(row: int, rows: int, side: str = "left", stops: tuple[str, ...] = ()) -> str:
|
||||||
return Text("│", style=EDGE)
|
"""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") -> Text:
|
def side_row(
|
||||||
return Text("│", style=side_style(row, rows, side))
|
row: int, rows: int, side: str = "left", stops: tuple[str, ...] = ()
|
||||||
|
) -> Text:
|
||||||
|
return Text("│", style=side_style(row, rows, side, stops))
|
||||||
|
|
||||||
|
|
||||||
def wrap(
|
def wrap(
|
||||||
lines: list[Text], width: int, height: int, title: str = ""
|
lines: list[Text],
|
||||||
|
width: int,
|
||||||
|
height: int,
|
||||||
|
title: str = "",
|
||||||
|
stops: tuple[str, ...] = (),
|
||||||
) -> RenderableType:
|
) -> RenderableType:
|
||||||
"""Frame ``lines`` (already sized for the content box) in a pane."""
|
"""Frame ``lines`` (already sized for the content box) in a pane."""
|
||||||
width = max(4, width)
|
width = max(4, width)
|
||||||
@@ -105,24 +121,29 @@ def wrap(
|
|||||||
inner_width, inner_height = inner_size(width, height)
|
inner_width, inner_height = inner_size(width, height)
|
||||||
rows = len(lines[:inner_height])
|
rows = len(lines[:inner_height])
|
||||||
|
|
||||||
framed: list[Text] = [top(width, title)]
|
framed: list[Text] = [top(width, title, stops)]
|
||||||
for index, line in enumerate(lines[:inner_height]):
|
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 = Text(no_wrap=True)
|
||||||
row.append_text(side_row(index, rows, "left"))
|
row.append_text(side_row(index, rows, "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"))
|
row.append_text(side_row(index, rows, "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
|
||||||
row = Text(no_wrap=True)
|
row = Text(no_wrap=True)
|
||||||
row.append_text(side_row(index, inner_height, "left"))
|
row.append_text(side_row(index, inner_height, "left", stops))
|
||||||
row.append(" " * inner_width)
|
row.append(" " * inner_width)
|
||||||
row.append_text(side_row(index, inner_height, "right"))
|
row.append_text(side_row(index, inner_height, "right", stops))
|
||||||
framed.append(row)
|
framed.append(row)
|
||||||
framed.append(bottom(width))
|
framed.append(bottom(width, stops))
|
||||||
return Group(*framed[:height])
|
return Group(*framed[:height])
|
||||||
|
|
||||||
|
|
||||||
@@ -139,14 +160,14 @@ class FrameTop(Widget):
|
|||||||
self.title = title
|
self.title = title
|
||||||
|
|
||||||
def render(self) -> RenderableType:
|
def render(self) -> RenderableType:
|
||||||
return top(max(2, self.size.width), self.title)
|
return top(max(2, self.size.width), self.title, active_stops(self.app))
|
||||||
|
|
||||||
|
|
||||||
class FrameBottom(Widget):
|
class FrameBottom(Widget):
|
||||||
can_focus = False
|
can_focus = False
|
||||||
|
|
||||||
def render(self) -> RenderableType:
|
def render(self) -> RenderableType:
|
||||||
return bottom(max(2, self.size.width))
|
return bottom(max(2, self.size.width), active_stops(self.app))
|
||||||
|
|
||||||
|
|
||||||
class FrameSide(Widget):
|
class FrameSide(Widget):
|
||||||
@@ -158,6 +179,7 @@ class FrameSide(Widget):
|
|||||||
|
|
||||||
def render(self) -> RenderableType:
|
def render(self) -> RenderableType:
|
||||||
rows = max(1, self.size.height)
|
rows = max(1, self.size.height)
|
||||||
|
stops = active_stops(self.app)
|
||||||
return Group(
|
return Group(
|
||||||
*[side_row(row, rows, self.side) for row in range(rows)]
|
*[side_row(row, rows, self.side, stops) for row in range(rows)]
|
||||||
)
|
)
|
||||||
@@ -10,7 +10,7 @@ from rich.text import Text
|
|||||||
from textual.widget import Widget
|
from textual.widget import Widget
|
||||||
|
|
||||||
from .common import (
|
from .common import (
|
||||||
SPECTRUM_STOPS,
|
active_stops,
|
||||||
clock_time,
|
clock_time,
|
||||||
date_label,
|
date_label,
|
||||||
gradient,
|
gradient,
|
||||||
@@ -22,7 +22,13 @@ if TYPE_CHECKING:
|
|||||||
from ..app import BeefyPlayerApp
|
from ..app import BeefyPlayerApp
|
||||||
|
|
||||||
|
|
||||||
def _hero_line(markup: str, index: int, width: int, gradient_fill: bool) -> Text:
|
def _hero_line(
|
||||||
|
markup: str,
|
||||||
|
index: int,
|
||||||
|
width: int,
|
||||||
|
gradient_fill: bool,
|
||||||
|
stops: tuple[str, ...],
|
||||||
|
) -> Text:
|
||||||
"""One row of the hero: spectrum-painted glyphs, or the art's own colours."""
|
"""One row of the hero: spectrum-painted glyphs, or the art's own colours."""
|
||||||
if not gradient_fill:
|
if not gradient_fill:
|
||||||
line = Text.from_markup(markup)
|
line = Text.from_markup(markup)
|
||||||
@@ -32,7 +38,7 @@ def _hero_line(markup: str, index: int, width: int, gradient_fill: bool) -> Text
|
|||||||
line = Text(no_wrap=True)
|
line = Text(no_wrap=True)
|
||||||
for column, char in enumerate(markup):
|
for column, char in enumerate(markup):
|
||||||
position = (column / max(1, width - 1) + index * 0.055) % 1.0
|
position = (column / max(1, width - 1) + index * 0.055) % 1.0
|
||||||
line.append(char, style=f"bold {gradient(SPECTRUM_STOPS, position)}")
|
line.append(char, style=f"bold {gradient(stops, position)}")
|
||||||
return line
|
return line
|
||||||
|
|
||||||
|
|
||||||
@@ -45,6 +51,7 @@ class HeaderBar(Widget):
|
|||||||
hero = tuple(getattr(provider, "hero", ()) or ())
|
hero = tuple(getattr(provider, "hero", ()) or ())
|
||||||
hero_lines = tuple(getattr(provider, "hero_lines", ()) or ())
|
hero_lines = tuple(getattr(provider, "hero_lines", ()) or ())
|
||||||
gradient_fill = bool(getattr(provider, "hero_gradient", False))
|
gradient_fill = bool(getattr(provider, "hero_gradient", False))
|
||||||
|
stops = active_stops(app)
|
||||||
|
|
||||||
width = max(24, self.size.width)
|
width = max(24, self.size.width)
|
||||||
height = max(1, self.size.height)
|
height = max(1, self.size.height)
|
||||||
@@ -73,7 +80,7 @@ class HeaderBar(Widget):
|
|||||||
|
|
||||||
lines: list[Text] = []
|
lines: list[Text] = []
|
||||||
for index, row in enumerate(hero):
|
for index, row in enumerate(hero):
|
||||||
line = _hero_line(row, index, hero_width, gradient_fill)
|
line = _hero_line(row, index, hero_width, gradient_fill, stops)
|
||||||
# Art rows are right-trimmed, so a row ending in a blank cell is a
|
# Art rows are right-trimmed, so a row ending in a blank cell is a
|
||||||
# column short; pad it back or the caption and clock drift left.
|
# column short; pad it back or the caption and clock drift left.
|
||||||
short = hero_width - cell_len(line.plain)
|
short = hero_width - cell_len(line.plain)
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from rich.text import Text
|
|||||||
from textual.widget import Widget
|
from textual.widget import Widget
|
||||||
|
|
||||||
from . import frame
|
from . import frame
|
||||||
from .common import local_time, local_zone, state_glyph, truncate
|
from .common import active_stops, local_time, local_zone, state_glyph, truncate
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ..app import BeefyPlayerApp
|
from ..app import BeefyPlayerApp
|
||||||
@@ -68,7 +68,7 @@ def _upcoming_rows(
|
|||||||
row.append(" ")
|
row.append(" ")
|
||||||
row.append(local_time(item.start), style="bold")
|
row.append(local_time(item.start), style="bold")
|
||||||
row.append(" ")
|
row.append(" ")
|
||||||
row.append(truncate(item.display_name, max(4, width - 7)))
|
row.append(truncate(item.display_name, max(4, width - 9)))
|
||||||
rows.append((True, row))
|
rows.append((True, row))
|
||||||
|
|
||||||
if len(rows) > budget:
|
if len(rows) > budget:
|
||||||
@@ -94,6 +94,7 @@ class SchedulePanel(Widget):
|
|||||||
app = cast("BeefyPlayerApp", self.app)
|
app = cast("BeefyPlayerApp", self.app)
|
||||||
width = max(12, self.size.width)
|
width = max(12, self.size.width)
|
||||||
height = max(3, self.size.height)
|
height = max(3, self.size.height)
|
||||||
|
stops = active_stops(app)
|
||||||
width, height = frame.inner_size(width, height)
|
width, height = frame.inner_size(width, height)
|
||||||
|
|
||||||
broadcasts = list(getattr(app, "schedule", ()) or ())
|
broadcasts = list(getattr(app, "schedule", ()) or ())
|
||||||
@@ -141,4 +142,5 @@ class SchedulePanel(Widget):
|
|||||||
max(12, self.size.width),
|
max(12, self.size.width),
|
||||||
max(3, self.size.height),
|
max(3, self.size.height),
|
||||||
self.border_title,
|
self.border_title,
|
||||||
|
stops,
|
||||||
)
|
)
|
||||||
@@ -11,9 +11,10 @@ from rich.console import RenderableType
|
|||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
from textual.widget import Widget
|
from textual.widget import Widget
|
||||||
|
|
||||||
|
from ..palettes import palette_stops
|
||||||
from ..spectrum import Frame, SpectrumEngine
|
from ..spectrum import Frame, SpectrumEngine
|
||||||
from . import frame
|
from . import frame
|
||||||
from .common import LEVELS, SPECTRUM_STOPS, gradient
|
from .common import LEVELS, active_stops, gradient
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ..app import BeefyPlayerApp
|
from ..app import BeefyPlayerApp
|
||||||
@@ -48,7 +49,9 @@ class SpectrumPanel(Widget):
|
|||||||
super().__init__()
|
super().__init__()
|
||||||
self.engine = engine
|
self.engine = engine
|
||||||
self.border_title = "SPECTRUM"
|
self.border_title = "SPECTRUM"
|
||||||
self._palettes: dict[tuple[int, int, bool], list[str]] = {}
|
#: The active colour ladder; ``render()`` refreshes it each frame.
|
||||||
|
self._stops: tuple[str, ...] = palette_stops(None)
|
||||||
|
self._palettes: dict[tuple, list[str]] = {}
|
||||||
#: Recent band frames, newest last, for the waterfall display. Filled
|
#: Recent band frames, newest last, for the waterfall display. Filled
|
||||||
#: even while another mode is showing so switching to it has history.
|
#: even while another mode is showing so switching to it has history.
|
||||||
self._history: deque[tuple[float, ...]] = deque(maxlen=WATERFALL_HISTORY)
|
self._history: deque[tuple[float, ...]] = deque(maxlen=WATERFALL_HISTORY)
|
||||||
@@ -65,6 +68,7 @@ class SpectrumPanel(Widget):
|
|||||||
def render(self) -> RenderableType:
|
def render(self) -> RenderableType:
|
||||||
app = cast("BeefyPlayerApp", self.app)
|
app = cast("BeefyPlayerApp", self.app)
|
||||||
snapshot = self.engine.snapshot()
|
snapshot = self.engine.snapshot()
|
||||||
|
self._stops = active_stops(app)
|
||||||
outer_width, outer_height = max(12, self.size.width), max(4, self.size.height)
|
outer_width, outer_height = max(12, self.size.width), max(4, self.size.height)
|
||||||
width, height = frame.inner_size(outer_width, outer_height)
|
width, height = frame.inner_size(outer_width, outer_height)
|
||||||
mode = getattr(app, "visualizer", "both")
|
mode = getattr(app, "visualizer", "both")
|
||||||
@@ -96,7 +100,7 @@ class SpectrumPanel(Widget):
|
|||||||
mirrored.reverse()
|
mirrored.reverse()
|
||||||
lines.extend(mirrored)
|
lines.extend(mirrored)
|
||||||
return frame.wrap(
|
return frame.wrap(
|
||||||
lines, outer_width, outer_height, self.border_title
|
lines, outer_width, outer_height, self.border_title, self._stops
|
||||||
)
|
)
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@@ -127,14 +131,15 @@ class SpectrumPanel(Widget):
|
|||||||
return float(values[low]) * (1.0 - fraction) + float(values[high]) * fraction
|
return float(values[low]) * (1.0 - fraction) + float(values[high]) * fraction
|
||||||
|
|
||||||
def _row_palette(self, height: int, phase: int, dim: bool) -> list[str]:
|
def _row_palette(self, height: int, phase: int, dim: bool) -> list[str]:
|
||||||
key = (height, phase % 12, dim)
|
stops = self._stops
|
||||||
|
key = (height, phase % 12, dim, stops)
|
||||||
cached = self._palettes.get(key)
|
cached = self._palettes.get(key)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
return cached
|
return cached
|
||||||
palette: list[str] = []
|
palette: list[str] = []
|
||||||
for row in range(height):
|
for row in range(height):
|
||||||
position = (height - 1 - row) / max(1, height - 1)
|
position = (height - 1 - row) / max(1, height - 1)
|
||||||
colour = gradient(SPECTRUM_STOPS, position)
|
colour = gradient(stops, position)
|
||||||
style = f"bold {colour}" if (row + phase) % 7 == 0 else colour
|
style = f"bold {colour}" if (row + phase) % 7 == 0 else colour
|
||||||
if dim:
|
if dim:
|
||||||
style = f"dim {style}"
|
style = f"dim {style}"
|
||||||
@@ -331,7 +336,7 @@ class SpectrumPanel(Widget):
|
|||||||
level = int(value * (len(WATERFALL_LEVELS) - 1))
|
level = int(value * (len(WATERFALL_LEVELS) - 1))
|
||||||
if level <= 0:
|
if level <= 0:
|
||||||
continue
|
continue
|
||||||
style = gradient(SPECTRUM_STOPS, value)
|
style = gradient(self._stops, value)
|
||||||
if faded:
|
if faded:
|
||||||
style = f"dim {style}"
|
style = f"dim {style}"
|
||||||
elif age == 1:
|
elif age == 1:
|
||||||
@@ -392,11 +397,11 @@ class SpectrumPanel(Widget):
|
|||||||
if depth < 0.06:
|
if depth < 0.06:
|
||||||
grid[row][column] = "█"
|
grid[row][column] = "█"
|
||||||
styles[row][column] = ("dim " if dim else "") + gradient(
|
styles[row][column] = ("dim " if dim else "") + gradient(
|
||||||
SPECTRUM_STOPS, edge
|
self._stops, edge
|
||||||
)
|
)
|
||||||
elif depth < 0.15:
|
elif depth < 0.15:
|
||||||
grid[row][column] = "▓"
|
grid[row][column] = "▓"
|
||||||
styles[row][column] = "dim " + gradient(SPECTRUM_STOPS, edge)
|
styles[row][column] = "dim " + gradient(self._stops, edge)
|
||||||
elif depth < 0.25:
|
elif depth < 0.25:
|
||||||
grid[row][column] = "▒"
|
grid[row][column] = "▒"
|
||||||
styles[row][column] = "dim"
|
styles[row][column] = "dim"
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from rich.text import Text
|
|||||||
from textual.widget import Widget
|
from textual.widget import Widget
|
||||||
|
|
||||||
from . import frame
|
from . import frame
|
||||||
from .common import compact_count, truncate
|
from .common import active_stops, compact_count, truncate
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ..app import BeefyPlayerApp
|
from ..app import BeefyPlayerApp
|
||||||
@@ -83,6 +83,7 @@ class StationList(Widget):
|
|||||||
app = cast("BeefyPlayerApp", self.app)
|
app = cast("BeefyPlayerApp", self.app)
|
||||||
width = max(12, self.size.width)
|
width = max(12, self.size.width)
|
||||||
height = max(3, self.size.height)
|
height = max(3, self.size.height)
|
||||||
|
stops = active_stops(app)
|
||||||
inner_width, inner_height = frame.inner_size(width, height)
|
inner_width, inner_height = frame.inner_size(width, height)
|
||||||
view = app.view
|
view = app.view
|
||||||
|
|
||||||
@@ -93,6 +94,7 @@ class StationList(Widget):
|
|||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
self.border_title,
|
self.border_title,
|
||||||
|
stops,
|
||||||
)
|
)
|
||||||
|
|
||||||
total = len(view)
|
total = len(view)
|
||||||
@@ -167,4 +169,4 @@ class StationList(Widget):
|
|||||||
)
|
)
|
||||||
lines.append(row)
|
lines.append(row)
|
||||||
|
|
||||||
return frame.wrap(lines, width, height, self.border_title)
|
return frame.wrap(lines, width, height, self.border_title, stops)
|
||||||
@@ -9,6 +9,7 @@ import pytest
|
|||||||
from beefy_player import config
|
from beefy_player import config
|
||||||
from beefy_player.app import BeefyPlayerApp
|
from beefy_player.app import BeefyPlayerApp
|
||||||
from beefy_player.models import Song
|
from beefy_player.models import Song
|
||||||
|
from beefy_player.widgets.art import ArtPane
|
||||||
from beefy_player.widgets.help import HelpScreen
|
from beefy_player.widgets.help import HelpScreen
|
||||||
from beefy_player.widgets.stage import StagePanel
|
from beefy_player.widgets.stage import StagePanel
|
||||||
from beefy_player.widgets.station_list import StationList
|
from beefy_player.widgets.station_list import StationList
|
||||||
@@ -259,3 +260,53 @@ async def test_media_session_commands_drive_the_app() -> None:
|
|||||||
app.media_actions.put("volume:25")
|
app.media_actions.put("volume:25")
|
||||||
await pilot.pause(0.2)
|
await pilot.pause(0.2)
|
||||||
assert app.volume == 25
|
assert app.volume == 25
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_menus_take_the_inline_artwork_down_and_put_it_back(monkeypatch) -> None:
|
||||||
|
"""Terminal images sit over a modal unless the image is deleted first."""
|
||||||
|
calls: list[str] = []
|
||||||
|
monkeypatch.setattr(ArtPane, "suspend", lambda self: calls.append("suspend"))
|
||||||
|
monkeypatch.setattr(ArtPane, "resume", lambda self: calls.append("resume"))
|
||||||
|
|
||||||
|
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
|
||||||
|
async with app.run_test(size=(100, 30)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
await pilot.press("question_mark")
|
||||||
|
await pilot.pause()
|
||||||
|
assert calls == ["suspend"]
|
||||||
|
await pilot.press("x")
|
||||||
|
await pilot.pause()
|
||||||
|
assert calls == ["suspend", "resume"]
|
||||||
|
|
||||||
|
await pilot.press("p")
|
||||||
|
await pilot.pause()
|
||||||
|
assert calls[-1] == "suspend"
|
||||||
|
await pilot.press("escape")
|
||||||
|
await pilot.pause()
|
||||||
|
assert calls[-1] == "resume"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_the_mosaic_gives_way_to_a_real_image(monkeypatch) -> None:
|
||||||
|
"""The pattern must not peek out around a letterboxed picture."""
|
||||||
|
from beefy_player.widgets import art as art_module
|
||||||
|
|
||||||
|
monkeypatch.setattr(art_module.artwork, "supported", lambda: True)
|
||||||
|
|
||||||
|
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
|
||||||
|
async with app.run_test(size=(110, 34)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
pane = app.query_one(ArtPane)
|
||||||
|
assert pane.display, "the artwork pane should be on screen at this size"
|
||||||
|
|
||||||
|
app.artwork_bytes = None
|
||||||
|
patterned = [line.plain for line in pane.render().renderables]
|
||||||
|
assert patterned and any(line.strip() for line in patterned)
|
||||||
|
|
||||||
|
app.artwork_bytes = b"pretend this is a png"
|
||||||
|
blank = [line.plain for line in pane.render().renderables]
|
||||||
|
assert len(blank) == len(patterned)
|
||||||
|
assert len(blank[0]) == len(patterned[0])
|
||||||
|
assert all(not line.strip() for line in blank)
|
||||||
+57
-38
@@ -4,8 +4,13 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
|
|
||||||
|
from beefy_player.palettes import PALETTES
|
||||||
from beefy_player.widgets import frame
|
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
|
||||||
|
|
||||||
|
|
||||||
def styles_of(line: Text) -> list[tuple[str, str]]:
|
def styles_of(line: Text) -> list[tuple[str, str]]:
|
||||||
return [
|
return [
|
||||||
@@ -14,68 +19,82 @@ def styles_of(line: Text) -> list[tuple[str, str]]:
|
|||||||
|
|
||||||
|
|
||||||
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))
|
spans = styles_of(frame.top(30, stops=STOPS))
|
||||||
assert spans[0] == ("╭", "bright_black") # plain grey corner
|
assert spans[0] == ("╭", frame.EDGE) # plain grey corner
|
||||||
# the fade runs into the top-right corner: cyan, bright cyan, light grey
|
# the fade runs into the top-right corner, hot end last
|
||||||
assert [style for _text, style in spans[-5:-1]] == [
|
assert [style for _text, style in spans[-4:-1]] == [
|
||||||
"bright_black",
|
STOPS[-4],
|
||||||
"cyan",
|
STOPS[-3],
|
||||||
"bright_cyan",
|
STOPS[-2],
|
||||||
"white",
|
|
||||||
]
|
]
|
||||||
assert spans[-1] == ("╮", "bold bright_white")
|
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))
|
spans = styles_of(frame.bottom(30, STOPS))
|
||||||
assert spans[0] == ("╰", "bold bright_white")
|
assert spans[0] == ("╰", f"bold {HOT}")
|
||||||
assert [style for _text, style in spans[1:4]] == ["white", "bright_cyan", "cyan"]
|
assert [style for _text, style in spans[1:4]] == [
|
||||||
assert spans[-1] == ("╯", "bright_black") # plain grey corner
|
STOPS[-2],
|
||||||
|
STOPS[-3],
|
||||||
|
STOPS[-4],
|
||||||
|
]
|
||||||
|
assert spans[-1] == ("╯", frame.EDGE) # plain grey corner
|
||||||
|
|
||||||
|
|
||||||
def test_the_sides_glow_towards_their_own_corner_only() -> None:
|
def test_the_sides_glow_towards_their_own_corner_only() -> None:
|
||||||
# left column fades up from the bottom-left corner
|
# left column fades up from the bottom-left corner
|
||||||
assert [frame.side_style(row, 10, "left") for row in range(10)] == [
|
assert [frame.side_style(row, 10, "left", STOPS) for row in range(10)] == [
|
||||||
"bright_black",
|
frame.EDGE,
|
||||||
"bright_black",
|
frame.EDGE,
|
||||||
"bright_black",
|
frame.EDGE,
|
||||||
"bright_black",
|
frame.EDGE,
|
||||||
"bright_black",
|
frame.EDGE,
|
||||||
"bright_black",
|
frame.EDGE,
|
||||||
"bright_black",
|
frame.EDGE,
|
||||||
"cyan",
|
STOPS[-4],
|
||||||
"bright_cyan",
|
STOPS[-3],
|
||||||
"white",
|
STOPS[-2],
|
||||||
]
|
]
|
||||||
# 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") for row in range(10)] == [
|
assert [frame.side_style(row, 10, "right", STOPS) for row in range(10)] == [
|
||||||
"white",
|
STOPS[-2],
|
||||||
"bright_cyan",
|
STOPS[-3],
|
||||||
"cyan",
|
STOPS[-4],
|
||||||
"bright_black",
|
frame.EDGE,
|
||||||
"bright_black",
|
frame.EDGE,
|
||||||
"bright_black",
|
frame.EDGE,
|
||||||
"bright_black",
|
frame.EDGE,
|
||||||
"bright_black",
|
frame.EDGE,
|
||||||
"bright_black",
|
frame.EDGE,
|
||||||
"bright_black",
|
frame.EDGE,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_the_title_sits_after_the_left_fade() -> None:
|
def test_the_title_sits_after_the_left_fade() -> None:
|
||||||
line = frame.top(30, "STATIONS")
|
line = frame.top(30, "STATIONS", STOPS)
|
||||||
assert line.plain.startswith("╭─ STATIONS ─")
|
assert line.plain.startswith("╭─ STATIONS ─")
|
||||||
assert "STATIONS" in line.plain
|
assert "STATIONS" in line.plain
|
||||||
# a pane too narrow for the title drops it rather than truncating
|
# a pane too narrow for the title drops it rather than truncating
|
||||||
assert "STATIONS" not in frame.top(10, "STATIONS").plain
|
assert "STATIONS" not in frame.top(10, "STATIONS", STOPS).plain
|
||||||
|
|
||||||
|
|
||||||
def test_wrap_frames_the_content_on_every_row() -> None:
|
def test_wrap_frames_the_content_on_every_row() -> None:
|
||||||
content = [Text("hello"), Text("world")]
|
content = [Text("hello"), Text("world")]
|
||||||
group = frame.wrap(content, 20, 6, "PANE")
|
group = frame.wrap(content, 20, 6, "PANE", STOPS)
|
||||||
lines = [line.plain for line in group.renderables]
|
lines = [line.plain for line in group.renderables]
|
||||||
assert len(lines) == 6
|
assert len(lines) == 6
|
||||||
assert lines[0].startswith("╭") and lines[-1].startswith("╰")
|
assert lines[0].startswith("╭") and lines[-1].startswith("╰")
|
||||||
assert all(len(line) == 20 for line in lines)
|
assert all(len(line) == 20 for line in lines)
|
||||||
assert all(line.startswith("│") and line.endswith("│") for line in lines[1:-1])
|
assert all(line.startswith("│") and line.endswith("│") for line in lines[1:-1])
|
||||||
assert lines[1].startswith("│hello")
|
assert lines[1].startswith("│hello")
|
||||||
|
|
||||||
|
|
||||||
|
def test_content_wider_than_the_pane_is_trimmed_not_spilled() -> None:
|
||||||
|
"""A pane must never draw over its neighbour."""
|
||||||
|
long_line = Text("x" * 40)
|
||||||
|
group = frame.wrap([long_line], 20, 4, "PANE", STOPS)
|
||||||
|
lines = [line.plain for line in group.renderables]
|
||||||
|
assert all(len(line) == 20 for line in lines)
|
||||||
|
assert lines[1].endswith("│")
|
||||||
|
# the original renderable is left untouched
|
||||||
|
assert long_line.plain == "x" * 40
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
"""The colour palettes: sixteen ladders of ANSI names, and the `c` key."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import html
|
||||||
|
import re
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from beefy_player import config
|
||||||
|
from beefy_player.app import BeefyPlayerApp
|
||||||
|
from beefy_player.palettes import (
|
||||||
|
DEFAULT_PALETTE,
|
||||||
|
PALETTE_ORDER,
|
||||||
|
PALETTES,
|
||||||
|
palette_stops,
|
||||||
|
)
|
||||||
|
from beefy_player.widgets import frame
|
||||||
|
from beefy_player.widgets.help import HelpScreen
|
||||||
|
from beefy_player.widgets.station_list import StationList
|
||||||
|
|
||||||
|
ANSI_NAMES = {
|
||||||
|
"black",
|
||||||
|
"red",
|
||||||
|
"green",
|
||||||
|
"yellow",
|
||||||
|
"blue",
|
||||||
|
"magenta",
|
||||||
|
"cyan",
|
||||||
|
"white",
|
||||||
|
"bright_black",
|
||||||
|
"bright_red",
|
||||||
|
"bright_green",
|
||||||
|
"bright_yellow",
|
||||||
|
"bright_blue",
|
||||||
|
"bright_magenta",
|
||||||
|
"bright_cyan",
|
||||||
|
"bright_white",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_there_are_sixteen_palettes_of_ansi_names() -> None:
|
||||||
|
assert len(PALETTES) == 16
|
||||||
|
assert len(PALETTE_ORDER) == 16
|
||||||
|
assert len(set(PALETTE_ORDER)) == 16
|
||||||
|
assert DEFAULT_PALETTE in PALETTES
|
||||||
|
|
||||||
|
for name, stops in PALETTES.items():
|
||||||
|
assert len(stops) >= 3, name
|
||||||
|
assert set(stops) <= ANSI_NAMES, (name, set(stops) - ANSI_NAMES)
|
||||||
|
# a ladder, not a list: the quiet end must differ from the hot end
|
||||||
|
assert stops[0] != stops[-1], name
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_broken_palette_name_falls_back_to_the_default() -> None:
|
||||||
|
assert palette_stops("mono") == PALETTES["mono"]
|
||||||
|
assert palette_stops(" MONO ") == PALETTES["mono"]
|
||||||
|
assert palette_stops("no-such-palette") == PALETTES[DEFAULT_PALETTE]
|
||||||
|
assert palette_stops(None) == PALETTES[DEFAULT_PALETTE]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_config_remembers_the_palette() -> None:
|
||||||
|
assert config.Config.load().palette == DEFAULT_PALETTE
|
||||||
|
config.Config(palette="ember").save()
|
||||||
|
assert config.Config.load().palette == "ember"
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unknown_palette_in_the_config_is_ignored() -> None:
|
||||||
|
config.CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
config.CONFIG_PATH.write_text('{"palette": "chartreuse"}')
|
||||||
|
assert config.Config.load().palette == DEFAULT_PALETTE
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_the_c_key_cycles_and_remembers_the_palette() -> None:
|
||||||
|
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
|
||||||
|
async with app.run_test(size=(100, 30)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
start = app.palette
|
||||||
|
assert start == DEFAULT_PALETTE
|
||||||
|
|
||||||
|
await pilot.press("c")
|
||||||
|
await pilot.pause()
|
||||||
|
expected = PALETTE_ORDER[(PALETTE_ORDER.index(start) + 1) % len(PALETTE_ORDER)]
|
||||||
|
assert app.palette == expected
|
||||||
|
assert app.config.palette == expected
|
||||||
|
assert "palette" in app.notice
|
||||||
|
|
||||||
|
# the choice survives a restart
|
||||||
|
config.Config(palette=app.palette).save()
|
||||||
|
assert config.Config.load().palette == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_the_pane_corners_follow_the_palette() -> None:
|
||||||
|
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
|
||||||
|
async with app.run_test(size=(100, 30)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
pane = app.query_one(StationList)
|
||||||
|
|
||||||
|
app.palette = "mono"
|
||||||
|
mono = [str(span.style) for span in pane.render().renderables[0].spans]
|
||||||
|
app.palette = "ember"
|
||||||
|
ember = [str(span.style) for span in pane.render().renderables[0].spans]
|
||||||
|
|
||||||
|
assert mono != ember
|
||||||
|
assert any("bright_white" in style for style in mono)
|
||||||
|
# ember's hot end is white too, but its quiet end is not
|
||||||
|
assert any(PALETTES["ember"][-2] in style for style in ember)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_the_spectrum_rows_use_the_active_palette() -> None:
|
||||||
|
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
|
||||||
|
async with app.run_test(size=(100, 30)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
spectrum = app.query_one("SpectrumPanel")
|
||||||
|
|
||||||
|
def colours(styles: list[str]) -> set[str]:
|
||||||
|
return {
|
||||||
|
style.replace("dim ", "").replace("bold ", "").strip()
|
||||||
|
for style in styles
|
||||||
|
}
|
||||||
|
|
||||||
|
app.palette = "mono"
|
||||||
|
spectrum.refresh()
|
||||||
|
await pilot.pause()
|
||||||
|
assert colours(spectrum._row_palette(12, 0, False)) <= set(PALETTES["mono"])
|
||||||
|
|
||||||
|
app.palette = "neon"
|
||||||
|
spectrum.refresh()
|
||||||
|
await pilot.pause()
|
||||||
|
assert colours(spectrum._row_palette(12, 0, False)) <= set(PALETTES["neon"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_corner_fade_walks_back_down_the_ladder() -> 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
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_the_help_scrolls_when_the_terminal_is_short() -> None:
|
||||||
|
"""The help page is ~56 rows: it must be readable on a 24-row screen."""
|
||||||
|
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
|
||||||
|
async with app.run_test(size=(80, 24)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
await pilot.press("question_mark")
|
||||||
|
await pilot.pause()
|
||||||
|
body = app.screen.query_one("#help")
|
||||||
|
assert body.size.height < body.virtual_size.height
|
||||||
|
assert body.max_scroll_y > 0
|
||||||
|
assert body.scroll_y == 0
|
||||||
|
|
||||||
|
await pilot.press("pagedown")
|
||||||
|
await pilot.pause()
|
||||||
|
assert body.scroll_y > 0
|
||||||
|
|
||||||
|
await pilot.press("end")
|
||||||
|
await pilot.pause()
|
||||||
|
assert body.scroll_y == body.max_scroll_y
|
||||||
|
|
||||||
|
await pilot.press("home")
|
||||||
|
await pilot.pause()
|
||||||
|
assert body.scroll_y == 0
|
||||||
|
|
||||||
|
await pilot.press("x")
|
||||||
|
await pilot.pause()
|
||||||
|
assert not isinstance(app.screen, HelpScreen)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_tall_terminal_needs_no_scrolling() -> None:
|
||||||
|
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
|
||||||
|
async with app.run_test(size=(120, 70)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
await pilot.press("question_mark")
|
||||||
|
await pilot.pause()
|
||||||
|
body = app.screen.query_one("#help")
|
||||||
|
assert body.max_scroll_y == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_the_help_lists_every_palette_and_marks_the_active_one() -> None:
|
||||||
|
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
|
||||||
|
async with app.run_test(size=(120, 64)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
app.palette = "ocean"
|
||||||
|
await pilot.press("question_mark")
|
||||||
|
await pilot.pause()
|
||||||
|
text = html.unescape(app.export_screenshot()).replace("\xa0", " ")
|
||||||
|
text = re.sub(r"<[^>]+>", "", text)
|
||||||
|
|
||||||
|
assert "PALETTES" in text
|
||||||
|
assert "c cycles them" in text
|
||||||
|
for name in PALETTE_ORDER:
|
||||||
|
assert name in text, name
|
||||||
|
assert "● ocean" in text
|
||||||
|
# each palette shows a swatch of eight blocks
|
||||||
|
assert "████████" in text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_the_help_footer_attributes_the_build() -> None:
|
||||||
|
"""One line: the version and the project URL — no credits essay."""
|
||||||
|
from beefy_player import __version__
|
||||||
|
|
||||||
|
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
|
||||||
|
async with app.run_test(size=(110, 70)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
await pilot.press("question_mark")
|
||||||
|
await pilot.pause()
|
||||||
|
body = app.screen.query_one("#help")
|
||||||
|
body.scroll_end(animate=False)
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
text = html.unescape(app.export_screenshot()).replace("\xa0", " ")
|
||||||
|
text = re.sub(r"<[^>]+>", "", text)
|
||||||
|
|
||||||
|
assert f"beefy player {__version__}" in text
|
||||||
|
assert "https://tildeforge.dev/beefcarver/beefy-player" in text
|
||||||
|
# the old explanatory blurb is gone
|
||||||
|
assert "playerctl" not in text
|
||||||
|
assert "parec" not in text
|
||||||
|
assert "Stations and song history come from" not in text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("slug", ["bassdrive", "somafm"])
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_the_help_has_no_pane_explainer(slug: str) -> None:
|
||||||
|
"""No catalogue gets a paragraph about its left pane."""
|
||||||
|
app = BeefyPlayerApp(provider=slug, autoplay=False, offline=True, audio=False)
|
||||||
|
async with app.run_test(size=(110, 60)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
await pilot.press("question_mark")
|
||||||
|
await pilot.pause()
|
||||||
|
text = html.unescape(app.export_screenshot()).replace("\xa0", " ")
|
||||||
|
text = re.sub(r"<[^>]+>", "", text)
|
||||||
|
assert "is a single stream" not in text
|
||||||
|
assert "multi-station catalogues" not in text
|
||||||
|
assert "CATALOGUES" in text
|
||||||
Reference in new issue
Block a user