Grow-only dialogs, full raw labels, node rhythm in 15-min slices
Carried over from the last punch-list pass: the packet viewer opts into a grow-only floating dialog (the box ratchets to its tallest body and blank-pads shorter ones, so paging never re-centres it); raw payload field names are shown whole by widening the label lane instead of clipping at eight chars; the Time Machine node rhythm matches the mesh page — 96 fifteen-minute slices sharing the Volume gutter — and its time axis shortens marks to bare dates on multi-day windows; Courier and Watchtower gain a visible Back exit beside Esc. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
dfc4cb9845
commit
70efb7c694
10 files changed
+200
-56
No files matched your search
@@ -146,6 +146,7 @@ def _menu_items(ctx: "AppContext", entries: list[QueuedMessage]) -> list:
|
||||
items.append(Separator(" empty — queued messages wait here for their moment"))
|
||||
for message in waiting:
|
||||
items.append(Choice(_waiting_row(ctx, message), ("msg", message.ident)))
|
||||
items.append(Separator("")) # space the action off the outbox rows above it
|
||||
items.append(Choice("✉ Queue a message…", _QUEUE))
|
||||
|
||||
if done:
|
||||
@@ -154,6 +155,9 @@ def _menu_items(ctx: "AppContext", entries: list[QueuedMessage]) -> list:
|
||||
for message in done[:15]:
|
||||
items.append(Choice(_done_row(message), ("msg", message.ident)))
|
||||
items.append(Choice("🗑 Clear finished", _CLEAR))
|
||||
|
||||
items.append(Separator(""))
|
||||
items.append(Choice("← Back", None)) # a visible exit beside Esc
|
||||
return items
|
||||
|
||||
|
||||
|
||||
@@ -93,14 +93,6 @@ KIND_ICONS = {
|
||||
}
|
||||
DEFAULT_ICON = "❔"
|
||||
|
||||
#: The floating dialog is sized to its content, which otherwise shrinks and grows as you
|
||||
#: page between a five-line advert and a telemetry frame's twenty — a jarring resize on
|
||||
#: every keystroke. Padding a short packet's body up to this floor holds the box at a
|
||||
#: steady minimum height so navigation stays put; a genuinely tall packet still grows past
|
||||
#: it. Sized for the common overheard-packet layout (heard/from/reception/class/route/via)
|
||||
#: so paging a burst of similar frames never resizes at all.
|
||||
_MIN_BODY_ROWS = 10
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PacketEntry:
|
||||
@@ -223,8 +215,16 @@ class PacketViewer(Screen):
|
||||
packets — the same keys the opening list itself uses — Home/End jump to the
|
||||
ends, PgUp/PgDn scroll a tall body, Esc closes. Opened over a single packet (a
|
||||
one-entry list) the paging keys simply do nothing.
|
||||
|
||||
The dialog only ever grows (see
|
||||
:attr:`~meshterm.ui.tui.screen.Screen.grow_only`): paging from a short packet to a
|
||||
tall one enlarges the box, but paging back keeps it at that size — blank-padded
|
||||
below the shorter body — rather than re-centering smaller, so the header the reader
|
||||
is looking at never hops around as they page.
|
||||
"""
|
||||
|
||||
grow_only = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
entries: list[PacketEntry],
|
||||
@@ -360,23 +360,25 @@ class PacketViewer(Screen):
|
||||
# same packet, so the title's ``n/total`` and the reachable range stay current.
|
||||
self._sync()
|
||||
entry = self._entries[self._index]
|
||||
rows = self._rows(entry)
|
||||
# The label lane is one fixed width for the whole grid so wrapped values align with
|
||||
# their own block (the app-wide hanging-indent rule), never with column zero. Size it
|
||||
# to the widest label actually present — a raw payload field's full name included, so
|
||||
# none is clipped — plus a one-cell gutter, but never so wide it leaves the value
|
||||
# column under 20 cells (a pathological key then ellipsises instead of crushing it).
|
||||
widest = max((len(label) for label, _ in rows), default=0)
|
||||
label_w = max(10, min(widest + 1, width - 20))
|
||||
grid = Table(
|
||||
box=None, show_header=False, show_edge=False, pad_edge=False,
|
||||
padding=(0, 0), expand=False,
|
||||
)
|
||||
# The label lane is fixed so wrapped values align with their own block
|
||||
# (the app-wide hanging-indent rule), never with column zero.
|
||||
grid.add_column(width=10, no_wrap=True)
|
||||
grid.add_column(overflow="fold", max_width=max(20, width - 10))
|
||||
grid.add_column(width=label_w, no_wrap=True)
|
||||
grid.add_column(overflow="fold", max_width=max(20, width - label_w))
|
||||
|
||||
for label, value in self._rows(entry):
|
||||
for label, value in rows:
|
||||
grid.add_row(Text(label, style="muted"), value)
|
||||
lines = render_lines(grid, width)
|
||||
self._scroll_total = max(1, len(lines))
|
||||
# Hold the dialog at a steady minimum height so paging between a short packet and
|
||||
# a tall one doesn't resize the box out from under the reader (see _MIN_BODY_ROWS).
|
||||
if len(lines) < _MIN_BODY_ROWS:
|
||||
lines += [""] * (_MIN_BODY_ROWS - len(lines))
|
||||
return lines
|
||||
|
||||
def _rows(self, entry: PacketEntry) -> list[tuple[str, RenderableType]]:
|
||||
@@ -520,5 +522,8 @@ class PacketViewer(Screen):
|
||||
body = str(value)
|
||||
if len(body) > 200:
|
||||
body = body[:199] + "…"
|
||||
rows.append((f" {key[:8]}", Text(body, style="muted")))
|
||||
# The label lane widens to fit the full key (see :meth:`render_body`), so the
|
||||
# field name is shown whole rather than clipped; the two-space indent nests it
|
||||
# visually under the "payload" heading above.
|
||||
rows.append((f" {key}", Text(body, style="muted")))
|
||||
return rows
|
||||
@@ -96,19 +96,24 @@ def _snr_cell_style(values: list[float]) -> str:
|
||||
|
||||
|
||||
def _time_axis(start: datetime, end: datetime) -> Callable[[float], str]:
|
||||
"""An axis labeller over a real time span: timestamps left of the closing ``now``."""
|
||||
"""A compact axis labeller over a real time span, closing on ``now``.
|
||||
|
||||
A window wider than a couple of days reads its marks as bare dates (``Jul 4``); a
|
||||
tighter one, where the calendar day barely changes, reads them as times (``18:30``)
|
||||
— the same "shorten what you can" the day charts' dates use, so the volume and SNR
|
||||
axes stay legible instead of repeating a full ``Jul 04 18:30`` stamp at every tick.
|
||||
"""
|
||||
wide = (end - start).total_seconds() > 2 * 86400
|
||||
|
||||
def label_at(frac: float) -> str:
|
||||
if frac >= 1.0:
|
||||
return "now"
|
||||
return _when_label(start + (end - start) * frac)
|
||||
when = (start + (end - start) * frac).astimezone()
|
||||
return f"{when:%b} {when.day}" if wide else f"{when:%H:%M}"
|
||||
|
||||
return label_at
|
||||
|
||||
|
||||
def _hour_axis(frac: float) -> str:
|
||||
"""The node rhythm's labeller: the local hour of day at ``frac`` of the 24-slot sweep."""
|
||||
return f"{round(frac * 23)} h"
|
||||
|
||||
|
||||
def _quarter_axis(frac: float) -> str:
|
||||
"""The mesh rhythm's labeller: the local hour at ``frac`` of a full-day quarter-hour sweep.
|
||||
|
||||
@@ -221,10 +226,16 @@ def _node_sections(
|
||||
(o.observed_at, float(o.snr)) for o in observations if o.snr is not None
|
||||
]
|
||||
|
||||
# Volume and SNR share one y-axis gutter width (like the mesh page's day pair) so
|
||||
# their axes line up; a provisional width finds the peaks that size the gutter, then
|
||||
# the real width re-buckets the bars flush with it. The rhythm below is its own
|
||||
# narrow chart, so it keeps its own gutter.
|
||||
# Volume, SNR, and the rhythm share one y-axis gutter width (like the mesh page's
|
||||
# charts) so their left edges line up. A provisional width finds the peaks that size
|
||||
# the gutter, then the real width re-buckets Volume/SNR flush with it. The rhythm folds
|
||||
# every reception into 96 fifteen-minute local-time slices — a slice's tally can top a
|
||||
# single volume bucket's — so its peak joins the sizing too.
|
||||
slots = [0] * 96
|
||||
for stamp in stamps:
|
||||
local = stamp.astimezone()
|
||||
slots[local.hour * 4 + local.minute // 15] += 1
|
||||
|
||||
def _layout(label_w: int) -> tuple[int, int]:
|
||||
chars = max(20, width - 2 * (label_w + 2))
|
||||
return chars, chars * 2
|
||||
@@ -232,7 +243,9 @@ def _node_sections(
|
||||
chars, buckets = _layout(1)
|
||||
volume = bucketize(stamps, start, now, buckets)
|
||||
lo, hi = chart_span(bucket_medians(snr_pairs, start, now, buckets)) if snr_pairs else (0.0, 0.0)
|
||||
label_w = max(1, len(str(max(volume))), len(str(round(hi))), len(str(round(lo))))
|
||||
label_w = max(
|
||||
1, len(str(max(volume))), len(str(round(hi))), len(str(round(lo))), len(str(max(slots)))
|
||||
)
|
||||
chars, buckets = _layout(label_w)
|
||||
volume = bucketize(stamps, start, now, buckets)
|
||||
|
||||
@@ -257,12 +270,14 @@ def _node_sections(
|
||||
)
|
||||
)
|
||||
|
||||
hours = [0] * 24
|
||||
for stamp in stamps:
|
||||
hours[stamp.astimezone().hour] += 1
|
||||
out.append(Text())
|
||||
out.append(_heading("Rhythm", "receptions by local hour of day"))
|
||||
out.extend(axis_chart(timeline_rows(hours, rows=_CHART_ROWS), max(hours), 12, _hour_axis))
|
||||
out.append(_heading("Rhythm", "receptions by local time of day · 15-min slices"))
|
||||
out.extend(
|
||||
axis_chart(
|
||||
timeline_rows(slots, rows=_CHART_ROWS), max(slots), 48,
|
||||
_quarter_axis, label_w=label_w,
|
||||
)
|
||||
)
|
||||
|
||||
out.append(Text())
|
||||
out.append(_heading("Record", "this window"))
|
||||
|
||||
+21
-10
@@ -272,6 +272,26 @@ def compose_startup(screen: Screen, cols: int, rows: int) -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _dialog_layout(screen: Screen, cols: int, rows: int) -> tuple[int, int, int, list[str]]:
|
||||
"""Size a floating dialog: ``(outer_width, vpad, viewport, body_lines)``."""
|
||||
# Most dialogs stretch to a generous cap; a screen may instead request a natural width
|
||||
# (a short confirm sized to its content), still bounded to the terminal.
|
||||
cap = min(cols - 6, 100)
|
||||
natural = getattr(screen, "dialog_width", None)
|
||||
max_w = cap if natural is None else max(24, min(cap, natural))
|
||||
# Rows the box may spend between its borders — on body lines and breathing room alike.
|
||||
budget = max(3, rows - 6)
|
||||
body_lines = screen.render_body(max_w - 4)
|
||||
# A grow-only screen (the packet viewer paging between packets) sizes to the tallest
|
||||
# body it has shown, not this one, so a shorter body keeps the larger box instead of
|
||||
# re-centring smaller — the frame blank-pads the slack. The breathing room is taken
|
||||
# from that ratcheted height, so the box stays put as the body shrinks below it.
|
||||
body_h = screen.ratchet_viewport(max(1, len(body_lines)))
|
||||
vpad = _breathing_room(body_h, budget)
|
||||
viewport = min(budget - 2 * vpad, body_h)
|
||||
return max_w, vpad, viewport, body_lines
|
||||
|
||||
|
||||
def compose_dialog(screen: Screen, cols: int, rows: int) -> str:
|
||||
"""Compose a centered dialog panel for a floating screen, bounded to the terminal.
|
||||
|
||||
@@ -283,16 +303,7 @@ def compose_dialog(screen: Screen, cols: int, rows: int) -> str:
|
||||
Returns:
|
||||
An ANSI string sized to the dialog's content (never larger than the terminal).
|
||||
"""
|
||||
# Most dialogs stretch to a generous cap; a screen may instead request a natural width
|
||||
# (a short confirm sized to its content), still bounded to the terminal.
|
||||
cap = min(cols - 6, 100)
|
||||
natural = getattr(screen, "dialog_width", None)
|
||||
max_w = cap if natural is None else max(24, min(cap, natural))
|
||||
# Rows the box may spend between its borders — on body lines and breathing room alike.
|
||||
budget = max(3, rows - 6)
|
||||
body_lines = screen.render_body(max_w - 4)
|
||||
vpad = _breathing_room(len(body_lines), budget)
|
||||
viewport = min(budget - 2 * vpad, max(1, len(body_lines)))
|
||||
max_w, vpad, viewport, body_lines = _dialog_layout(screen, cols, rows)
|
||||
visible, more_above, more_below = _visible_slice(screen, body_lines, viewport)
|
||||
body = Text.from_ansi("\n".join(visible))
|
||||
border = getattr(screen, "border_style", "accent")
|
||||
|
||||
@@ -36,6 +36,11 @@ class Screen:
|
||||
future: Resolved with the screen's result (or :data:`CANCEL`) when it commits.
|
||||
floating: Whether the session should draw this screen as a centered dialog over
|
||||
the dimmed screen beneath it (deeper layers float; the base does not).
|
||||
grow_only: Whether a floating dialog may only ever grow. Its box is sized to the
|
||||
tallest body it has shown, not the current one, so a screen whose body height
|
||||
swings as its content changes (the packet viewer paging between packets) keeps
|
||||
a steady, centred box — blank-padded when the current body is shorter — instead
|
||||
of resizing on every page.
|
||||
chrome: Whether, as the base screen, this layer is wrapped in the session's
|
||||
persistent header/footer frame. A startup splash sets this ``False`` so the
|
||||
session instead centers it under the :attr:`banner` with no status bars.
|
||||
@@ -48,6 +53,7 @@ class Screen:
|
||||
title: str = ""
|
||||
footer_hint: str = "Esc back"
|
||||
floating: bool = True
|
||||
grow_only: bool = False
|
||||
chrome: bool = True
|
||||
banner: Optional[Sequence[str]] = None
|
||||
footnote: Optional[str] = None
|
||||
@@ -65,6 +71,11 @@ class Screen:
|
||||
# clamp to the content without every caller threading the sizes through.
|
||||
self._scroll_total = 1
|
||||
self._scroll_viewport = 1
|
||||
# A grow-only screen's high-water body height: the tallest body it has rendered
|
||||
# into a dialog, so its box can be held at that size once reached (see
|
||||
# :meth:`ratchet_viewport`). Zero until the first paint; irrelevant while not
|
||||
# :attr:`grow_only`.
|
||||
self._viewport_floor = 0
|
||||
|
||||
# --- rendering -----------------------------------------------------------
|
||||
|
||||
@@ -79,6 +90,19 @@ class Screen:
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def ratchet_viewport(self, body_h: int) -> int:
|
||||
"""The body height a :attr:`grow_only` dialog is sized to: its running maximum.
|
||||
|
||||
The frame calls this each paint with the current body's height. A grow-only
|
||||
screen returns the tallest height it has yet shown — so its box never shrinks,
|
||||
and the frame blank-pads a now-shorter body to fill it — while an ordinary
|
||||
screen returns the height unchanged and is sized to each body as it comes.
|
||||
"""
|
||||
if not self.grow_only:
|
||||
return body_h
|
||||
self._viewport_floor = max(self._viewport_floor, body_h)
|
||||
return self._viewport_floor
|
||||
|
||||
def cursor_line(self) -> Optional[int]:
|
||||
"""Return a body line that must stay visible, or ``None`` for free scrolling.
|
||||
|
||||
|
||||
@@ -677,9 +677,7 @@ class TuiSession:
|
||||
content=base_window,
|
||||
floats=[
|
||||
Float(
|
||||
ConditionalContainer(
|
||||
float_window, filter=Condition(self._has_float)
|
||||
)
|
||||
ConditionalContainer(float_window, filter=Condition(self._has_float))
|
||||
),
|
||||
Float(
|
||||
ConditionalContainer(
|
||||
|
||||
@@ -152,6 +152,9 @@ def _menu_items(
|
||||
_TOGGLE_NEW,
|
||||
)
|
||||
)
|
||||
|
||||
items.append(Separator(""))
|
||||
items.append(Choice("← Back", None)) # a visible exit beside Esc
|
||||
return items
|
||||
|
||||
|
||||
|
||||
@@ -98,13 +98,17 @@ def test_packet_viewer_without_a_source_stays_a_snapshot() -> None:
|
||||
assert viewer.footer_hint == "Esc close"
|
||||
|
||||
|
||||
def test_packet_viewer_holds_a_minimum_height() -> None:
|
||||
"""A short packet's body is padded to a floor, so paging never shrinks the dialog."""
|
||||
from meshterm.ui.packet_viewer import _MIN_BODY_ROWS
|
||||
|
||||
tiny = PacketEntry(when=utcnow(), kind="ack", where="01c3") # a two-row packet
|
||||
def test_packet_viewer_grows_its_dialog_only() -> None:
|
||||
"""The viewer opts into a grow-only dialog so paging enlarges but never shrinks it."""
|
||||
tiny = PacketEntry(when=utcnow(), kind="ack", where="01c3")
|
||||
viewer = PacketViewer([tiny], 0, resolve=lambda h: "")
|
||||
assert len(viewer.render_body(80)) >= _MIN_BODY_ROWS
|
||||
assert viewer.grow_only is True
|
||||
# The body is left at its natural (unpadded) height; the frame, not a floor, pads it.
|
||||
assert len(viewer.render_body(80)) < 10
|
||||
# ratchet_viewport is the grow-only contract: it rises to a taller body and never drops.
|
||||
assert viewer.ratchet_viewport(6) == 6
|
||||
assert viewer.ratchet_viewport(14) == 14 # a taller packet enlarges the box
|
||||
assert viewer.ratchet_viewport(4) == 14 # a shorter one after keeps the larger box
|
||||
|
||||
|
||||
def test_packet_viewer_raw_dump_skips_fields_folded_into_flavoured_rows() -> None:
|
||||
@@ -116,4 +120,14 @@ def test_packet_viewer_raw_dump_skips_fields_folded_into_flavoured_rows() -> Non
|
||||
body = _plain(_viewer(entry).render_body(80))
|
||||
assert "path_len" not in body
|
||||
assert "header" not in body
|
||||
assert "novel_fi" in body # an unrecognized field still surfaces (label truncates to 8)
|
||||
assert "novel_field" in body # an unrecognized field surfaces with its full name
|
||||
|
||||
|
||||
def test_packet_viewer_shows_full_raw_field_labels() -> None:
|
||||
"""A long raw-field name is shown whole — the label lane widens rather than clipping it."""
|
||||
entry = _grp_txt_entry({
|
||||
"chan_hash": "ab", "cipher_mac": "0000", "crypted": "00" * 16,
|
||||
"battery_millivolts": 4102,
|
||||
})
|
||||
body = _plain(_viewer(entry).render_body(80))
|
||||
assert "battery_millivolts" in body # the 18-char key is not clipped to 8
|
||||
@@ -295,12 +295,43 @@ def test_node_page_renders_all_sections(tmp_path: Path) -> None:
|
||||
body = _plain(_node_sections(ctx, NODE, "YUL", None, 90))
|
||||
assert "Volume" in body and "24 receptions" in body
|
||||
assert "SNR" in body and "-2.0" in body and "+8.0" in body
|
||||
assert "Rhythm" in body and "local hour" in body
|
||||
assert "Rhythm" in body and "15-min" in body # 15-minute slices, like the mesh page
|
||||
assert "Record" in body and "median" in body
|
||||
assert "advert 24" in body # kinds breakdown, packet rows absent
|
||||
repo.close()
|
||||
|
||||
|
||||
def test_node_page_rhythm_is_fifteen_minute_and_shares_the_volume_gutter(tmp_path: Path) -> None:
|
||||
"""The node rhythm matches the mesh's: a full-day 15-min sweep, gutter-aligned to Volume."""
|
||||
repo = _seeded_repo(tmp_path)
|
||||
ctx = SimpleNamespace(repo=repo)
|
||||
lines = _plain(_node_sections(ctx, NODE, "YUL", None, 90), width=90).split("\n")
|
||||
|
||||
def border_col(heading: str) -> int:
|
||||
start = next(i for i, line in enumerate(lines) if heading in line)
|
||||
border = next(line for line in lines[start:] if "└" in line)
|
||||
return border.index("└")
|
||||
|
||||
assert border_col("Volume") == border_col("Rhythm") # shared gutter → aligned left edge
|
||||
rhythm = next(i for i, line in enumerate(lines) if "Rhythm" in line)
|
||||
caption = next(lines[i + 1] for i in range(rhythm, len(lines)) if "└" in lines[i])
|
||||
assert "24 h" in caption # the full-day sweep closes on 24 h, like the mesh rhythm
|
||||
|
||||
|
||||
def test_time_axis_shortens_to_dates_on_wide_windows_and_times_on_narrow() -> None:
|
||||
"""A multi-day window labels marks as bare dates; a same-day one, as times."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from meshterm.ui.timemachine_screen import _time_axis
|
||||
|
||||
start = datetime(2026, 7, 1, 8, 0, tzinfo=timezone.utc)
|
||||
wide = _time_axis(start, start + timedelta(days=10))
|
||||
narrow = _time_axis(start, start + timedelta(hours=12))
|
||||
assert wide(1.0) == "now" and narrow(1.0) == "now"
|
||||
assert any(ch.isalpha() for ch in wide(0.0)) # a month name, e.g. "Jul 1"
|
||||
assert ":" in narrow(0.0) # a clock time, e.g. "08:00"
|
||||
|
||||
|
||||
def test_node_page_empty_window_offers_widening(tmp_path: Path) -> None:
|
||||
"""A window with nothing recorded says so instead of drawing empty charts."""
|
||||
repo = _seeded_repo(tmp_path)
|
||||
|
||||
@@ -417,6 +417,45 @@ def test_compose_dialog_is_bounded() -> None:
|
||||
assert out.count("\n") + 1 <= 20
|
||||
|
||||
|
||||
def _box_height(screen: Screen) -> int:
|
||||
"""The row height of the dialog box ``compose_dialog`` draws for ``screen``."""
|
||||
return frame.compose_dialog(screen, 80, 40).count("\n") + 1
|
||||
|
||||
|
||||
def test_ordinary_dialog_box_resizes_to_each_body() -> None:
|
||||
"""A plain (non grow-only) dialog sizes to whatever body it is currently showing."""
|
||||
short = _box_height(ScrollScreen(Text("one line"), title="d"))
|
||||
tall = _box_height(ScrollScreen(Text("\n".join(f"row {i}" for i in range(15))), title="d"))
|
||||
assert tall > short
|
||||
|
||||
|
||||
class _GrowScreen(Screen):
|
||||
"""A grow-only dialog whose body height is set per paint, for the ratchet test."""
|
||||
|
||||
grow_only = True
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.title = "d"
|
||||
self.footer_hint = ""
|
||||
self.n = 1
|
||||
|
||||
def render_body(self, width: int) -> list[str]:
|
||||
return [f"row {i}" for i in range(self.n)]
|
||||
|
||||
|
||||
def test_grow_only_dialog_box_holds_its_tallest_size() -> None:
|
||||
"""A grow-only dialog grows its box for a taller body and never shrinks for a shorter one."""
|
||||
screen = _GrowScreen()
|
||||
screen.n = 2
|
||||
small = _box_height(screen)
|
||||
screen.n = 16
|
||||
grown = _box_height(screen)
|
||||
assert grown > small # a taller body enlarges the box
|
||||
screen.n = 2
|
||||
assert _box_height(screen) == grown # a shorter body after keeps the larger box
|
||||
|
||||
|
||||
def test_compose_startup_is_chromeless_and_shows_banner() -> None:
|
||||
"""The startup splash fills the height, draws the banner, and omits header/footer bars."""
|
||||
screen = SelectScreen("pick", [Choice("alpha", 1), Choice("beta", 2)])
|
||||
|
||||
Reference in new issue
Block a user