feat(ui): draw pane frames, page keys and a scroll thumb
This commit is contained in:
1 parent
49f7efe0d2
commit
0219df454c
14 files changed
+571
-64
No files matched your search
@@ -0,0 +1,163 @@
|
||||
"""Pane frames: one grey box, with two corners fading out of it.
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rich.cells import cell_len
|
||||
from rich.console import Group, RenderableType
|
||||
from rich.text import Text
|
||||
from textual.widget import Widget
|
||||
|
||||
#: 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")
|
||||
|
||||
|
||||
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 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]:
|
||||
"""The content box inside the frame."""
|
||||
return max(1, width - 2), max(1, height - 2)
|
||||
|
||||
|
||||
def _edge_cells(width: int, faded_end: str = "right") -> 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)]
|
||||
|
||||
|
||||
def _paint(line: Text, cells: list[tuple[str, str]]) -> None:
|
||||
"""Append cells, grouping neighbours that share a style into one span."""
|
||||
style: str | None = None
|
||||
run: list[str] = []
|
||||
for char, cell_style in cells:
|
||||
if cell_style != style:
|
||||
if run:
|
||||
line.append("".join(run), style=style)
|
||||
run, style = [], cell_style
|
||||
run.append(char)
|
||||
if run:
|
||||
line.append("".join(run), style=style)
|
||||
|
||||
|
||||
def top(width: int, title: str = "") -> Text:
|
||||
width = max(2, width)
|
||||
cells = _edge_cells(width, "right") # only the top-right corner fades
|
||||
if title:
|
||||
label = f" {title} "
|
||||
start = 1
|
||||
if start + cell_len(label) <= len(cells):
|
||||
for offset, char in enumerate(label):
|
||||
cells[start + offset] = (char, "bold")
|
||||
|
||||
line = Text(no_wrap=True)
|
||||
line.append("╭", style=EDGE)
|
||||
_paint(line, cells)
|
||||
line.append("╮", style=f"bold {fade(0)}")
|
||||
return line
|
||||
|
||||
|
||||
def bottom(width: int) -> 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=EDGE)
|
||||
return line
|
||||
|
||||
|
||||
def side() -> Text:
|
||||
return Text("│", style=EDGE)
|
||||
|
||||
|
||||
def side_row(row: int, rows: int, side: str = "left") -> Text:
|
||||
return Text("│", style=side_style(row, rows, side))
|
||||
|
||||
|
||||
def wrap(
|
||||
lines: list[Text], width: int, height: int, title: str = ""
|
||||
) -> RenderableType:
|
||||
"""Frame ``lines`` (already sized for the content box) in a pane."""
|
||||
width = max(4, width)
|
||||
height = max(2, height)
|
||||
inner_width, inner_height = inner_size(width, height)
|
||||
rows = len(lines[:inner_height])
|
||||
|
||||
framed: list[Text] = [top(width, title)]
|
||||
for index, line in enumerate(lines[:inner_height]):
|
||||
row = Text(no_wrap=True)
|
||||
row.append_text(side_row(index, rows, "left"))
|
||||
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"))
|
||||
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(" " * inner_width)
|
||||
row.append_text(side_row(index, inner_height, "right"))
|
||||
framed.append(row)
|
||||
framed.append(bottom(width))
|
||||
return Group(*framed[:height])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# frame pieces, for containers that cannot draw their own content
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class FrameTop(Widget):
|
||||
can_focus = False
|
||||
|
||||
def __init__(self, title: str = "", *, id: str | None = None) -> None:
|
||||
super().__init__(id=id)
|
||||
self.title = title
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
return top(max(2, self.size.width), self.title)
|
||||
|
||||
|
||||
class FrameBottom(Widget):
|
||||
can_focus = False
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
return bottom(max(2, self.size.width))
|
||||
|
||||
|
||||
class FrameSide(Widget):
|
||||
can_focus = False
|
||||
|
||||
def __init__(self, side: str = "left", *, id: str | None = None) -> None:
|
||||
super().__init__(id=id)
|
||||
self.side = side
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
rows = max(1, self.size.height)
|
||||
return Group(
|
||||
*[side_row(row, rows, self.side) for row in range(rows)]
|
||||
)
|
||||
@@ -74,6 +74,11 @@ class HeaderBar(Widget):
|
||||
lines: list[Text] = []
|
||||
for index, row in enumerate(hero):
|
||||
line = _hero_line(row, index, hero_width, gradient_fill)
|
||||
# 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)
|
||||
if short > 0:
|
||||
line.append(" " * short)
|
||||
if show_middle and text_offset <= index < text_offset + len(hero_lines):
|
||||
middle = hero_lines[index - text_offset]
|
||||
line.append(" " * 4)
|
||||
|
||||
@@ -21,6 +21,7 @@ if TYPE_CHECKING:
|
||||
|
||||
HELP_ROWS: tuple[tuple[str, str], ...] = (
|
||||
("↑ / ↓ j / k", "move through the station list"),
|
||||
("pgup / pgdn", "page through the station list"),
|
||||
("enter", "tune in to the selected station"),
|
||||
("space", "play / pause the current station"),
|
||||
("s", "stop playback"),
|
||||
|
||||
@@ -30,24 +30,43 @@ class ProviderScreen(ModalScreen[str | None]):
|
||||
header.append("CATALOGUES", style=f"bold {gradient(SPECTRUM_STOPS, 0.55)}")
|
||||
header.append(" · pick a station catalogue", style="dim")
|
||||
|
||||
name_width = (
|
||||
max(
|
||||
(
|
||||
len(PROVIDERS[slug].name)
|
||||
for slug in self.slugs
|
||||
if slug in PROVIDERS
|
||||
),
|
||||
default=12,
|
||||
)
|
||||
+ 2
|
||||
)
|
||||
|
||||
rows: list[Text] = []
|
||||
for index, slug in enumerate(self.slugs):
|
||||
provider = PROVIDERS[slug]
|
||||
selected = index == self.index
|
||||
row = Text(no_wrap=True)
|
||||
row.append("▶ " if selected else " ", style="bold magenta" if selected else "")
|
||||
row.append(f"{index + 1} ", style="dim")
|
||||
name_style = "bold underline" if selected else "bold"
|
||||
row.append(f"{provider.name:<12}", style=name_style)
|
||||
row.append(f" {provider.brand}", style="dim")
|
||||
row.append(
|
||||
"▶ " if selected else " ", style="bold magenta" if selected else ""
|
||||
)
|
||||
row.append(f"{index + 1:>2} ", style="dim")
|
||||
row.append(
|
||||
f"{truncate(provider.name, name_width - 2):<{name_width}}",
|
||||
style="bold underline" if selected else "bold",
|
||||
)
|
||||
row.append(truncate(provider.brand, 14), style="dim")
|
||||
if slug == self.current_id:
|
||||
row.append(" ●", style="bold green")
|
||||
rows.append(row)
|
||||
|
||||
detail = Text(no_wrap=True)
|
||||
detail.append(" ", style="")
|
||||
detail.append(truncate(provider.tagline, 54), style="italic dim")
|
||||
rows.append(detail)
|
||||
if selected:
|
||||
rows.append(
|
||||
Text(
|
||||
" " + truncate(provider.tagline, 54),
|
||||
style="italic dim",
|
||||
no_wrap=True,
|
||||
)
|
||||
)
|
||||
|
||||
current = PROVIDERS.get(self.current_id)
|
||||
footer = Text()
|
||||
@@ -57,7 +76,7 @@ class ProviderScreen(ModalScreen[str | None]):
|
||||
f"now: {current.name} — {current.station_source}\n", style="dim"
|
||||
)
|
||||
footer.append(
|
||||
"↑ ↓ choose · 1–9 jump · enter switch · esc cancel",
|
||||
"↑ ↓ choose · enter switch · esc cancel",
|
||||
style="dim",
|
||||
)
|
||||
return Group(header, Text(""), *rows, footer)
|
||||
|
||||
@@ -11,10 +11,11 @@ from __future__ import annotations
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from rich.console import Group, RenderableType
|
||||
from rich.console import RenderableType
|
||||
from rich.text import Text
|
||||
from textual.widget import Widget
|
||||
|
||||
from . import frame
|
||||
from .common import local_time, local_zone, state_glyph, truncate
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -92,7 +93,8 @@ class SchedulePanel(Widget):
|
||||
def render(self) -> RenderableType:
|
||||
app = cast("BeefyPlayerApp", self.app)
|
||||
width = max(12, self.size.width)
|
||||
height = max(1, self.size.height)
|
||||
height = max(3, self.size.height)
|
||||
width, height = frame.inner_size(width, height)
|
||||
|
||||
broadcasts = list(getattr(app, "schedule", ()) or ())
|
||||
loaded = bool(getattr(app, "schedule_loaded", False))
|
||||
@@ -134,4 +136,9 @@ class SchedulePanel(Widget):
|
||||
lines.append(Text(""))
|
||||
while len(lines) < height:
|
||||
lines.append(Text(""))
|
||||
return Group(*lines[:height])
|
||||
return frame.wrap(
|
||||
lines[:height],
|
||||
max(12, self.size.width),
|
||||
max(3, self.size.height),
|
||||
self.border_title,
|
||||
)
|
||||
@@ -7,11 +7,12 @@ from collections import deque
|
||||
from collections.abc import Sequence
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from rich.console import Group, RenderableType
|
||||
from rich.console import RenderableType
|
||||
from rich.text import Text
|
||||
from textual.widget import Widget
|
||||
|
||||
from ..spectrum import Frame, SpectrumEngine
|
||||
from . import frame
|
||||
from .common import LEVELS, SPECTRUM_STOPS, gradient
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -63,38 +64,40 @@ class SpectrumPanel(Widget):
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
app = cast("BeefyPlayerApp", self.app)
|
||||
frame = self.engine.snapshot()
|
||||
width = max(8, self.size.width)
|
||||
height = max(2, self.size.height)
|
||||
snapshot = self.engine.snapshot()
|
||||
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")
|
||||
phase = int(getattr(app, "tick_count", 0) // 2)
|
||||
dim = frame.status == "idle"
|
||||
self._record(frame, int(getattr(app, "tick_count", 0)))
|
||||
dim = snapshot.status == "idle"
|
||||
self._record(snapshot, int(getattr(app, "tick_count", 0)))
|
||||
|
||||
lines: list[Text]
|
||||
if mode == "wave":
|
||||
lines = self._wave(frame, width, height, phase, dim)
|
||||
lines = self._wave(snapshot, width, height, phase, dim)
|
||||
elif mode == "braille":
|
||||
lines = self._braille(frame, width, height, phase, dim)
|
||||
lines = self._braille(snapshot, width, height, phase, dim)
|
||||
elif mode == "waterfall":
|
||||
lines = self._waterfall(width, height, dim)
|
||||
elif mode == "radial":
|
||||
lines = self._radial(frame, width, height, phase, dim)
|
||||
lines = self._radial(snapshot, width, height, phase, dim)
|
||||
elif mode == "bars" or height < 8:
|
||||
lines = self._bars(frame, width, height, phase, dim)
|
||||
lines = self._bars(snapshot, width, height, phase, dim)
|
||||
else:
|
||||
# Default mode: a full-height mirrored spectrum. The top half
|
||||
# grows up from the centre line, the bottom half grows down from
|
||||
# it (same values, flipped rows, dimmed like a reflection).
|
||||
top_height = height // 2
|
||||
bottom_height = height - top_height
|
||||
lines = self._bars(frame, width, top_height, phase, dim)
|
||||
lines = self._bars(snapshot, width, top_height, phase, dim)
|
||||
mirrored = self._bars(
|
||||
frame, width, bottom_height, phase + 5, True, peaks=False
|
||||
snapshot, width, bottom_height, phase + 5, True, peaks=False
|
||||
)
|
||||
mirrored.reverse()
|
||||
lines.extend(mirrored)
|
||||
return Group(*lines)
|
||||
return frame.wrap(
|
||||
lines, outer_width, outer_height, self.border_title
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# history
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, cast
|
||||
|
||||
from rich.console import Group, RenderableType
|
||||
from rich.text import Text
|
||||
from textual.containers import Horizontal
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.css.query import NoMatches
|
||||
from textual.widget import Widget
|
||||
|
||||
@@ -21,6 +21,7 @@ from .common import (
|
||||
time_ago,
|
||||
truncate,
|
||||
)
|
||||
from .frame import FrameBottom, FrameSide, FrameTop
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..app import BeefyPlayerApp
|
||||
@@ -36,10 +37,18 @@ class StagePanel(Widget):
|
||||
self.border_title = "ON AIR"
|
||||
|
||||
def compose(self):
|
||||
with Horizontal(id="stage_top"):
|
||||
yield ArtPane()
|
||||
yield StageInfo()
|
||||
yield HistoryPane()
|
||||
# A container cannot paint its own content, so the frame is made of
|
||||
# thin widgets: top, bottom, and a column either side of the body.
|
||||
yield FrameTop("ON AIR", id="stage_frame_top")
|
||||
with Horizontal(id="stage_middle"):
|
||||
yield FrameSide("left", id="stage_frame_left")
|
||||
with Vertical(id="stage_body"):
|
||||
with Horizontal(id="stage_top"):
|
||||
yield ArtPane()
|
||||
yield StageInfo()
|
||||
yield HistoryPane()
|
||||
yield FrameSide("right", id="stage_frame_right")
|
||||
yield FrameBottom(id="stage_frame_bottom")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.refresh_art()
|
||||
@@ -150,7 +159,9 @@ class StageInfo(Widget):
|
||||
if song.art_url and album.plain.strip():
|
||||
album.append(" ▣", style="cyan")
|
||||
else:
|
||||
artist = Text("waiting for track data…", style="italic yellow")
|
||||
# No track metadata from this catalogue: keep the line, leave it
|
||||
# blank rather than filling it with an apology.
|
||||
artist = Text("")
|
||||
fallback = station.last_playing or "tuning in"
|
||||
title = Text(marquee(fallback, width), style="bold")
|
||||
album = Text(truncate(station.genre_label, width), style="dim")
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
"""Station list with favourites, live markers and a filter."""
|
||||
"""Station list with favourites, live markers, a filter and a scrollbar."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from rich.cells import cell_len
|
||||
from rich.console import Group, RenderableType
|
||||
from rich.console import RenderableType
|
||||
from rich.text import Text
|
||||
from textual.widget import Widget
|
||||
|
||||
from . import frame
|
||||
from .common import compact_count, truncate
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..app import BeefyPlayerApp
|
||||
|
||||
#: Columns the scrollbar occupies (a gap plus the bar itself).
|
||||
GUTTER = 2
|
||||
|
||||
|
||||
class StationList(Widget):
|
||||
can_focus = False
|
||||
@@ -22,34 +26,89 @@ class StationList(Widget):
|
||||
super().__init__()
|
||||
self.border_title = "STATIONS"
|
||||
|
||||
# -- title: filter + position --------------------------------------- #
|
||||
|
||||
def set_filter(self, query: str) -> None:
|
||||
self._sync_title(query)
|
||||
|
||||
def _sync_title(self, query: str | None = None) -> None:
|
||||
"""``STATIONS · /jazz · 3/12`` — the filter and where you are in it."""
|
||||
if query is None:
|
||||
query = ""
|
||||
total = 0
|
||||
index = 0
|
||||
if self.is_mounted:
|
||||
app = cast("BeefyPlayerApp", self.app)
|
||||
query = app.query
|
||||
total = len(app.view)
|
||||
index = app.index
|
||||
|
||||
safe_query = query.replace("[", "(").replace("]", ")") if query else ""
|
||||
title = f"STATIONS · /{safe_query}" if safe_query else "STATIONS"
|
||||
parts = ["STATIONS"]
|
||||
if safe_query:
|
||||
parts.append(f"/{safe_query}")
|
||||
if total > 1:
|
||||
parts.append(f"{min(index + 1, total)}/{total}")
|
||||
|
||||
title = " · ".join(parts)
|
||||
if self.border_title != title:
|
||||
self.border_title = title
|
||||
|
||||
def refresh(self, *args, **kwargs): # type: ignore[override]
|
||||
# Every cursor move ends in a refresh, so this is the one place that
|
||||
# has to keep the title's position readout honest.
|
||||
if self.is_mounted:
|
||||
self._sync_title()
|
||||
return super().refresh(*args, **kwargs)
|
||||
|
||||
def on_mouse_scroll_down(self, event) -> None:
|
||||
cast("BeefyPlayerApp", self.app).action_cursor_down()
|
||||
|
||||
def on_mouse_scroll_up(self, event) -> None:
|
||||
cast("BeefyPlayerApp", self.app).action_cursor_up()
|
||||
|
||||
# -- scrollbar geometry --------------------------------------------- #
|
||||
|
||||
@staticmethod
|
||||
def thumb(height: int, total: int, first: int) -> set[int]:
|
||||
"""Rows of the pane the scrollbar thumb covers."""
|
||||
if total <= height:
|
||||
return set(range(height))
|
||||
size = max(1, round(height * height / total))
|
||||
span = max(0, total - height)
|
||||
top = 0 if span == 0 else round(first * (height - size) / span)
|
||||
return set(range(top, min(height, top + size)))
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
app = cast("BeefyPlayerApp", self.app)
|
||||
width = max(12, self.size.width)
|
||||
height = max(1, self.size.height)
|
||||
height = max(3, self.size.height)
|
||||
inner_width, inner_height = frame.inner_size(width, height)
|
||||
view = app.view
|
||||
|
||||
if not view:
|
||||
message = "no stations match" if app.query else "loading stations…"
|
||||
return Group(Text(truncate(message, width), style="dim italic"))
|
||||
return frame.wrap(
|
||||
[Text(truncate(message, inner_width), style="dim italic")],
|
||||
width,
|
||||
height,
|
||||
self.border_title,
|
||||
)
|
||||
|
||||
total = len(view)
|
||||
first = 0
|
||||
if len(view) > height:
|
||||
first = max(0, min(app.index - height // 2, len(view) - height))
|
||||
if total > inner_height:
|
||||
first = max(0, min(app.index - inner_height // 2, total - inner_height))
|
||||
|
||||
scrollable = total > inner_height
|
||||
gutter = GUTTER if scrollable else 0
|
||||
content = max(4, inner_width - gutter)
|
||||
thumb = (
|
||||
self.thumb(inner_height, total, first) if scrollable else set()
|
||||
)
|
||||
|
||||
lines: list[Text] = []
|
||||
for offset in range(min(height, len(view))):
|
||||
for offset in range(min(inner_height, total)):
|
||||
index = first + offset
|
||||
station = view[index]
|
||||
selected = index == app.index
|
||||
@@ -64,32 +123,29 @@ class StationList(Widget):
|
||||
|
||||
count_text = ""
|
||||
count_width = 0
|
||||
if width >= 26 and station.listeners > 0:
|
||||
if content >= 26 and station.listeners > 0:
|
||||
count_text = f"{compact_count(station.listeners)} ♪"
|
||||
count_width = cell_len(count_text) + 1
|
||||
|
||||
prefix = marker + star + play + " "
|
||||
available = width - cell_len(prefix) - count_width
|
||||
available = content - cell_len(prefix) - count_width
|
||||
title = truncate(station.title, max(4, available))
|
||||
gap = max(0, width - cell_len(prefix) - cell_len(title) - count_width)
|
||||
gap = max(0, content - cell_len(prefix) - cell_len(title) - count_width)
|
||||
|
||||
if selected:
|
||||
base = ""
|
||||
marker_style = "magenta"
|
||||
title_style = "bold underline"
|
||||
count_style = "bold"
|
||||
elif current:
|
||||
base = ""
|
||||
marker_style = "cyan"
|
||||
title_style = "cyan"
|
||||
count_style = "dim"
|
||||
else:
|
||||
base = ""
|
||||
marker_style = ""
|
||||
title_style = ""
|
||||
count_style = "dim"
|
||||
|
||||
row = Text(no_wrap=True, style=base)
|
||||
row = Text(no_wrap=True)
|
||||
row.append(marker, style=marker_style)
|
||||
row.append(star, style="bold yellow" if station.favorite else "")
|
||||
row.append(play, style="cyan" if playing else "")
|
||||
@@ -99,8 +155,16 @@ class StationList(Widget):
|
||||
if count_text:
|
||||
row.append(count_text, style=count_style)
|
||||
used = cell_len(prefix) + cell_len(title) + gap + cell_len(count_text)
|
||||
if used < width:
|
||||
row.append(" " * (width - used))
|
||||
if used < content:
|
||||
row.append(" " * (content - used))
|
||||
|
||||
if scrollable:
|
||||
# Just the thumb, in the frame's own grey: no rail.
|
||||
row.append(" ")
|
||||
row.append(
|
||||
"▐" if offset in thumb else " ",
|
||||
style=frame.EDGE,
|
||||
)
|
||||
lines.append(row)
|
||||
|
||||
return Group(*lines)
|
||||
return frame.wrap(lines, width, height, self.border_title)
|
||||
Reference in new issue
Block a user