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
@@ -22,7 +22,7 @@ from textual.strip import Strip
|
||||
from textual.widget import Widget
|
||||
|
||||
from .. import artwork
|
||||
from .common import art_lines
|
||||
from .common import active_stops, art_lines
|
||||
|
||||
_NULL_STYLE = Style()
|
||||
_NULL_CONTROL = ((ControlType.CURSOR_FORWARD, 0),)
|
||||
@@ -308,6 +308,8 @@ class ArtPane(Widget):
|
||||
self._last_data: bytes | None = None
|
||||
self._last_size: tuple[int, int] = (0, 0)
|
||||
self._mosaic_key = ""
|
||||
#: True while a modal is up: the inline image would sit over the menu.
|
||||
self._suspended = False
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# mosaic background
|
||||
@@ -320,7 +322,12 @@ class ArtPane(Widget):
|
||||
return Text("")
|
||||
width = max(1, self.size.width)
|
||||
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
|
||||
@@ -337,9 +344,27 @@ class ArtPane(Widget):
|
||||
def on_unmount(self) -> None:
|
||||
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:
|
||||
# Keep the mosaic behind the image in sync with the current track.
|
||||
self.refresh()
|
||||
if self._suspended:
|
||||
return
|
||||
|
||||
app = cast("BeefyPlayerApp", self.app)
|
||||
station = app.current
|
||||
|
||||
@@ -19,23 +19,20 @@ from zoneinfo import ZoneInfo
|
||||
from rich.cells import cell_len
|
||||
from rich.text import Text
|
||||
|
||||
from ..palettes import (
|
||||
DEFAULT_PALETTE,
|
||||
palette_stops,
|
||||
)
|
||||
|
||||
LOCALTIME_PATH = "/etc/localtime"
|
||||
_UTC_NAMES = {"UTC", "UCT", "GMT", "ETC/UTC", "ETC/UCT", "ETC/GMT", "Z"}
|
||||
|
||||
LEVELS = " ▁▂▃▄▅▆▇█"
|
||||
|
||||
# Bottom-to-top spectrum colours, picked from the terminal's stock ANSI palette.
|
||||
SPECTRUM_STOPS = (
|
||||
"cyan",
|
||||
"blue",
|
||||
"magenta",
|
||||
"bright_magenta",
|
||||
"yellow",
|
||||
"bright_yellow",
|
||||
"red",
|
||||
)
|
||||
def active_stops(app: object) -> tuple[str, ...]:
|
||||
"""The colour ladder the app is currently drawing with."""
|
||||
return palette_stops(getattr(app, "palette", DEFAULT_PALETTE))
|
||||
|
||||
ART_STOPS = ("blue", "magenta", "bright_magenta", "yellow", "cyan", "green")
|
||||
|
||||
STATE_STYLE = {
|
||||
"playing": ("●", "green"),
|
||||
@@ -182,7 +179,9 @@ def state_glyph(state: str) -> tuple[str, str]:
|
||||
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.
|
||||
|
||||
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)
|
||||
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
|
||||
colour = gradient(ART_STOPS, position)
|
||||
colour = gradient(stops, position)
|
||||
# Mostly solid blocks with a little texture, like a tiny cover.
|
||||
glyph = "█" if ((seed + column * 7 + row * 11) % 17) not in (0, 5) else "▓"
|
||||
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
|
||||
corners can be coloured individually. Two of them — the top right and the
|
||||
bottom left — run white → light grey → bright cyan → cyan along both arms;
|
||||
everything else, including the other two corners, is the border grey.
|
||||
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.
|
||||
|
||||
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
|
||||
@@ -13,26 +16,30 @@ from rich.console import Group, RenderableType
|
||||
from rich.text import Text
|
||||
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.
|
||||
EDGE = "bright_black"
|
||||
|
||||
#: A faded corner runs white → light grey → bright cyan → cyan, then settles
|
||||
#: back into the border grey.
|
||||
CORNER_GRADIENT = ("bright_white", "white", "bright_cyan", "cyan")
|
||||
#: How many cells a corner fade reaches into the edge.
|
||||
FADE_CELLS = 4
|
||||
|
||||
|
||||
def fade(distance: int) -> str:
|
||||
"""Colour ``distance`` cells away from a corner (0 is the corner itself)."""
|
||||
if 0 <= distance < len(CORNER_GRADIENT):
|
||||
return CORNER_GRADIENT[distance]
|
||||
return EDGE
|
||||
def corner_fade(distance: int, stops: tuple[str, ...] = ()) -> str:
|
||||
"""Colour ``distance`` cells out from a corner; 0 is the corner glyph.
|
||||
|
||||
|
||||
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
|
||||
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
|
||||
index = len(stops) - 1 - distance
|
||||
return stops[index] if index >= 0 else EDGE
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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 = max(0, width - 2)
|
||||
if faded_end == "left":
|
||||
return [("─", fade(column + 1)) for column in range(inner)]
|
||||
return [("─", fade(inner - column)) for column in range(inner)]
|
||||
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:
|
||||
@@ -62,9 +69,9 @@ def _paint(line: Text, cells: list[tuple[str, str]]) -> None:
|
||||
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)
|
||||
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:
|
||||
label = f" {title} "
|
||||
start = 1
|
||||
@@ -75,29 +82,38 @@ def top(width: int, title: str = "") -> Text:
|
||||
line = Text(no_wrap=True)
|
||||
line.append("╭", style=EDGE)
|
||||
_paint(line, cells)
|
||||
line.append("╮", style=f"bold {fade(0)}")
|
||||
line.append("╮", style=f"bold {corner_fade(0, stops)}")
|
||||
return line
|
||||
|
||||
|
||||
def bottom(width: int) -> Text:
|
||||
def bottom(width: int, stops: tuple[str, ...] = ()) -> Text:
|
||||
width = max(2, width)
|
||||
line = Text(no_wrap=True)
|
||||
line.append("╰", style=f"bold {fade(0)}")
|
||||
_paint(line, _edge_cells(width, "left")) # only the bottom-left fades
|
||||
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() -> Text:
|
||||
return Text("│", style=EDGE)
|
||||
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") -> Text:
|
||||
return Text("│", style=side_style(row, rows, side))
|
||||
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 = ""
|
||||
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)
|
||||
@@ -105,24 +121,29 @@ def wrap(
|
||||
inner_width, inner_height = inner_size(width, 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]):
|
||||
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, rows, "left"))
|
||||
row.append_text(side_row(index, rows, "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"))
|
||||
row.append_text(side_row(index, rows, "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"))
|
||||
row.append_text(side_row(index, inner_height, "left", stops))
|
||||
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(bottom(width))
|
||||
framed.append(bottom(width, stops))
|
||||
return Group(*framed[:height])
|
||||
|
||||
|
||||
@@ -139,14 +160,14 @@ class FrameTop(Widget):
|
||||
self.title = title
|
||||
|
||||
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):
|
||||
can_focus = False
|
||||
|
||||
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):
|
||||
@@ -158,6 +179,7 @@ class FrameSide(Widget):
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
rows = max(1, self.size.height)
|
||||
stops = active_stops(self.app)
|
||||
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 .common import (
|
||||
SPECTRUM_STOPS,
|
||||
active_stops,
|
||||
clock_time,
|
||||
date_label,
|
||||
gradient,
|
||||
@@ -22,7 +22,13 @@ if TYPE_CHECKING:
|
||||
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."""
|
||||
if not gradient_fill:
|
||||
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)
|
||||
for column, char in enumerate(markup):
|
||||
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
|
||||
|
||||
|
||||
@@ -45,6 +51,7 @@ class HeaderBar(Widget):
|
||||
hero = tuple(getattr(provider, "hero", ()) or ())
|
||||
hero_lines = tuple(getattr(provider, "hero_lines", ()) or ())
|
||||
gradient_fill = bool(getattr(provider, "hero_gradient", False))
|
||||
stops = active_stops(app)
|
||||
|
||||
width = max(24, self.size.width)
|
||||
height = max(1, self.size.height)
|
||||
@@ -73,7 +80,7 @@ class HeaderBar(Widget):
|
||||
|
||||
lines: list[Text] = []
|
||||
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
|
||||
# column short; pad it back or the caption and clock drift left.
|
||||
short = hero_width - cell_len(line.plain)
|
||||
|
||||
@@ -16,7 +16,7 @@ from rich.text import Text
|
||||
from textual.widget import Widget
|
||||
|
||||
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:
|
||||
from ..app import BeefyPlayerApp
|
||||
@@ -68,7 +68,7 @@ def _upcoming_rows(
|
||||
row.append(" ")
|
||||
row.append(local_time(item.start), style="bold")
|
||||
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))
|
||||
|
||||
if len(rows) > budget:
|
||||
@@ -94,6 +94,7 @@ class SchedulePanel(Widget):
|
||||
app = cast("BeefyPlayerApp", self.app)
|
||||
width = max(12, self.size.width)
|
||||
height = max(3, self.size.height)
|
||||
stops = active_stops(app)
|
||||
width, height = frame.inner_size(width, height)
|
||||
|
||||
broadcasts = list(getattr(app, "schedule", ()) or ())
|
||||
@@ -141,4 +142,5 @@ class SchedulePanel(Widget):
|
||||
max(12, self.size.width),
|
||||
max(3, self.size.height),
|
||||
self.border_title,
|
||||
stops,
|
||||
)
|
||||
@@ -11,9 +11,10 @@ from rich.console import RenderableType
|
||||
from rich.text import Text
|
||||
from textual.widget import Widget
|
||||
|
||||
from ..palettes import palette_stops
|
||||
from ..spectrum import Frame, SpectrumEngine
|
||||
from . import frame
|
||||
from .common import LEVELS, SPECTRUM_STOPS, gradient
|
||||
from .common import LEVELS, active_stops, gradient
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..app import BeefyPlayerApp
|
||||
@@ -48,7 +49,9 @@ class SpectrumPanel(Widget):
|
||||
super().__init__()
|
||||
self.engine = engine
|
||||
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
|
||||
#: even while another mode is showing so switching to it has history.
|
||||
self._history: deque[tuple[float, ...]] = deque(maxlen=WATERFALL_HISTORY)
|
||||
@@ -65,6 +68,7 @@ class SpectrumPanel(Widget):
|
||||
def render(self) -> RenderableType:
|
||||
app = cast("BeefyPlayerApp", self.app)
|
||||
snapshot = self.engine.snapshot()
|
||||
self._stops = active_stops(app)
|
||||
outer_width, outer_height = max(12, self.size.width), max(4, self.size.height)
|
||||
width, height = frame.inner_size(outer_width, outer_height)
|
||||
mode = getattr(app, "visualizer", "both")
|
||||
@@ -96,7 +100,7 @@ class SpectrumPanel(Widget):
|
||||
mirrored.reverse()
|
||||
lines.extend(mirrored)
|
||||
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
|
||||
|
||||
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)
|
||||
if cached is not None:
|
||||
return cached
|
||||
palette: list[str] = []
|
||||
for row in range(height):
|
||||
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
|
||||
if dim:
|
||||
style = f"dim {style}"
|
||||
@@ -331,7 +336,7 @@ class SpectrumPanel(Widget):
|
||||
level = int(value * (len(WATERFALL_LEVELS) - 1))
|
||||
if level <= 0:
|
||||
continue
|
||||
style = gradient(SPECTRUM_STOPS, value)
|
||||
style = gradient(self._stops, value)
|
||||
if faded:
|
||||
style = f"dim {style}"
|
||||
elif age == 1:
|
||||
@@ -392,11 +397,11 @@ class SpectrumPanel(Widget):
|
||||
if depth < 0.06:
|
||||
grid[row][column] = "█"
|
||||
styles[row][column] = ("dim " if dim else "") + gradient(
|
||||
SPECTRUM_STOPS, edge
|
||||
self._stops, edge
|
||||
)
|
||||
elif depth < 0.15:
|
||||
grid[row][column] = "▓"
|
||||
styles[row][column] = "dim " + gradient(SPECTRUM_STOPS, edge)
|
||||
styles[row][column] = "dim " + gradient(self._stops, edge)
|
||||
elif depth < 0.25:
|
||||
grid[row][column] = "▒"
|
||||
styles[row][column] = "dim"
|
||||
|
||||
@@ -10,7 +10,7 @@ from rich.text import Text
|
||||
from textual.widget import Widget
|
||||
|
||||
from . import frame
|
||||
from .common import compact_count, truncate
|
||||
from .common import active_stops, compact_count, truncate
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..app import BeefyPlayerApp
|
||||
@@ -83,6 +83,7 @@ class StationList(Widget):
|
||||
app = cast("BeefyPlayerApp", self.app)
|
||||
width = max(12, self.size.width)
|
||||
height = max(3, self.size.height)
|
||||
stops = active_stops(app)
|
||||
inner_width, inner_height = frame.inner_size(width, height)
|
||||
view = app.view
|
||||
|
||||
@@ -93,6 +94,7 @@ class StationList(Widget):
|
||||
width,
|
||||
height,
|
||||
self.border_title,
|
||||
stops,
|
||||
)
|
||||
|
||||
total = len(view)
|
||||
@@ -167,4 +169,4 @@ class StationList(Widget):
|
||||
)
|
||||
lines.append(row)
|
||||
|
||||
return frame.wrap(lines, width, height, self.border_title)
|
||||
return frame.wrap(lines, width, height, self.border_title, stops)
|
||||
Reference in new issue
Block a user