Frames: the border is one colour again, so drop the corner light
Every framed box was post-processed on the way to the screen: the top-left corner blended toward white and the highlight decayed along the top and left edges, so a box read as lit from that corner. It reads as a gradient laid over the chrome rather than as the box being a box. The borders draw flat now. The pass goes with it — ui/tui/glow.py, and the three composers that ran it (base, startup, dialog), each of which also carried the platform gate that kept it off the 16-slot console where it could only ever hand back the same lines. Nothing else called it, and its memo went with the module. Text colour is untouched: the pass only ever recoloured box-drawing glyphs, and titles and subtitles were already skipped. The four glow tests become one that pins the outcome instead of the mechanism — every box glyph in a composed frame, nested panels and dialogs included, carries the border's own colour and no other. Verified against the old pass: with it back, that set holds sixty-five. Platform.effects keeps its other two claims (the header blink, the braille spinner); it just no longer mentions a pass that no longer exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
ad2b852018
commit
c66e2c86b6
5 files changed
+35
-448
No files matched your search
@@ -85,10 +85,10 @@ class Platform:
|
||||
and quantizes the two scales that would otherwise spend a gradient — the
|
||||
per-node key hue (:func:`~meshterm.ui.theme.node_style`) and the heard-age heat
|
||||
(:func:`~meshterm.ui.widgets._recency_style`). Wired in P3.
|
||||
effects: Whether animated/decorative rendering runs at all: corner glow, the
|
||||
header battery blink, the braille spinner. ``False`` drops to static chrome
|
||||
and a ``LINE`` spinner fallback — cheaper, and the glow already no-ops on a
|
||||
non-truecolor theme, so this just makes that explicit. Wired in P2/P3.
|
||||
effects: Whether animated/decorative rendering runs at all: the header battery
|
||||
blink, the braille spinner. ``False`` drops to static chrome and a ``LINE``
|
||||
spinner fallback, which is cheaper on a console where a repaint is dear.
|
||||
Wired in P2/P3.
|
||||
tick_s: The :class:`~prompt_toolkit.application.Application` refresh interval — the
|
||||
*idle* repaint cadence only, since every screen pushes its own repaint through
|
||||
``TuiSession.invalidate`` when its data actually moves. Composing one frame was
|
||||
|
||||
@@ -18,7 +18,6 @@ from rich.text import Text
|
||||
from ...platforms import Platform, get_platform, on_platform
|
||||
from ..logo import load_logo, logo_width
|
||||
from ..theme import fold_text, hint_style, title_style
|
||||
from .glow import apply_corner_glow
|
||||
from .render import crop_cells, render_lines, render_to_ansi
|
||||
from .screen import Screen
|
||||
|
||||
@@ -194,10 +193,7 @@ def _title_bar(screen: Screen, cols: int, more_above: bool, more_below: bool) ->
|
||||
real border draws in — rather than :func:`~meshterm.ui.theme.hint_style`'s muted
|
||||
variant, which is for auxiliary text riding *alongside* a border (a footer hint, the
|
||||
subtitle's own "more" label below), not the border's own glyphs. The ``↑↓`` clip
|
||||
arrows keep that muted hint style, matching a bordered panel's own subtitle. There
|
||||
is no corner to light the way :func:`~meshterm.ui.tui.glow.apply_corner_glow` lights
|
||||
a real frame's top-left — that pass needs a truecolor blend this platform's 16-slot
|
||||
palette can't render, so the rule is uniformly bold rather than fading from one.
|
||||
arrows keep that muted hint style, matching a bordered panel's own subtitle.
|
||||
"""
|
||||
border = "accent"
|
||||
tail = ""
|
||||
@@ -296,13 +292,7 @@ def compose_base(
|
||||
+ visible
|
||||
+ render_lines(footer_row(), cols, no_wrap=True)
|
||||
)
|
||||
# The glow pass lights the outer frame *and* any tool panels nested in the body. It only
|
||||
# ever recolours truecolor foregrounds (see glow._advance_fg), so on a platform without
|
||||
# effects — which is also a platform without truecolor — it would scan every line of
|
||||
# every frame and hand back the identical list. Skipping it outright is the same picture
|
||||
# for none of the work. Read live rather than bound at import: set_platform() runs in the
|
||||
# CLI callback, long after this module is imported (see meshterm.platforms).
|
||||
lines = header_lines + (apply_corner_glow(body) if platform.effects else body)
|
||||
lines = header_lines + body
|
||||
# Guarantee we never exceed the terminal height (pt would otherwise clip unpredictably).
|
||||
if len(lines) > rows:
|
||||
lines = lines[:rows]
|
||||
@@ -435,14 +425,7 @@ def compose_startup(screen: Screen, cols: int, rows: int) -> str:
|
||||
padding=(vpad, 1),
|
||||
width=inner_w + 4,
|
||||
)
|
||||
# Glow before centering, while the box still starts at column 0 of its own lines. Gated
|
||||
# on the platform exactly as compose_base gates it: the pass only ever recolours
|
||||
# truecolor foregrounds, so on the 16-slot console it walked every line of the splash
|
||||
# to hand back the identical list, on every tick.
|
||||
panel_lines = render_lines(panel, inner_w + 4)
|
||||
if get_platform().effects:
|
||||
panel_lines = apply_corner_glow(panel_lines)
|
||||
panel_lines = _center(panel_lines, cols)
|
||||
panel_lines = _center(render_lines(panel, inner_w + 4), cols)
|
||||
|
||||
# A small muted line (e.g. a copyright notice) sits immediately under the logo, its right
|
||||
# edge hung off the logo's right edge so the two read as one signed block.
|
||||
@@ -535,10 +518,7 @@ def compose_dialog(screen: Screen, cols: int, rows: int) -> str:
|
||||
padding=(vpad, 1),
|
||||
width=max_w,
|
||||
)
|
||||
dialog_lines = render_lines(panel, max_w)
|
||||
if get_platform().effects:
|
||||
dialog_lines = apply_corner_glow(dialog_lines)
|
||||
out = "\n".join(dialog_lines)
|
||||
out = "\n".join(render_lines(panel, max_w))
|
||||
_DIALOG_CACHE[key] = out
|
||||
if len(_DIALOG_CACHE) > _DIALOG_CACHE_MAX:
|
||||
_DIALOG_CACHE.popitem(last=False)
|
||||
|
||||
@@ -1,331 +0,0 @@
|
||||
"""A corner-light pass for framed boxes: brighten each frame's top-left, fading to normal.
|
||||
|
||||
Every panel the TUI draws — the full-screen base frame, floating dialogs, the startup
|
||||
splash box, and the content-sized tool panels nested *inside* screen bodies (Status, Link
|
||||
budget, Mesh, ...) — is bordered with Rich's box glyphs. This module post-processes
|
||||
composed ANSI lines and gives each such frame a subtle lighting treatment: the top-left
|
||||
corner is lifted toward white, and the highlight decays along the top edge (rightward) and
|
||||
the left edge (downward) until it meets the border's own, untouched colour. The right and
|
||||
bottom edges stay as drawn, so the box reads as lit from the top-left.
|
||||
|
||||
The pass is generic on purpose. It does not know which panels exist or what colour each
|
||||
border uses: it *finds* frames by their corner glyphs and reads every border cell's current
|
||||
colour from the ANSI stream itself, blending that toward white. A muted box therefore gets
|
||||
a muted glow, a warn box a warm one — and nested panels are lit individually, each from its
|
||||
own corner — with no per-callsite wiring. Composers apply it exactly once, after layout
|
||||
(see :func:`~meshterm.ui.tui.frame.compose_base` and friends), so the effect never
|
||||
compounds.
|
||||
|
||||
Because it runs on every repaint, the pass works on the ANSI strings directly rather than
|
||||
round-tripping through Rich (parse → restyle → re-render costs ~10ms a frame; string
|
||||
surgery is well under 1ms). The render console emits truecolor SGR sequences for every
|
||||
themed colour, so a tiny state machine tracking ``38;2;r;g;b`` foregrounds is enough to
|
||||
know each border cell's colour; the recolour splices in an override before the glyph and
|
||||
restores the tracked colour after it, leaving bold/dim attributes and everything else in
|
||||
the stream untouched. Title and subtitle text embedded in a border row is skipped (only
|
||||
box-drawing glyphs are recoloured).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections import OrderedDict
|
||||
from itertools import accumulate
|
||||
|
||||
from rich.cells import cell_len
|
||||
|
||||
#: Glyphs that open a frame's top edge. Rich's panels use the ROUNDED box (``╭``) — or its
|
||||
#: SQUARE substitute (``┌``) on legacy Windows consoles; the heavy/double corners are
|
||||
#: accepted too so a restyled box would still glow.
|
||||
_TOP_LEFT = frozenset("╭┌┏╔")
|
||||
|
||||
#: Glyphs that close a top edge. The nearest one right of a top-left corner on the same row
|
||||
#: marks the frame's width, which normalises the horizontal fade.
|
||||
_TOP_RIGHT = frozenset("╮┐┓╗")
|
||||
|
||||
#: Glyphs that close a left edge. Finding one directly below a top-left corner marks the
|
||||
#: frame's height, which normalises the vertical fade.
|
||||
_BOTTOM_LEFT = frozenset("╰└┗╚")
|
||||
|
||||
#: Border glyphs a top edge is drawn with. Anything else on the row (a title, its
|
||||
#: surrounding spaces) is left untouched, so only the frame itself is recoloured.
|
||||
_HORIZONTAL = frozenset("─━═")
|
||||
|
||||
#: Border glyphs a left edge is drawn with, walked downward from each top-left corner.
|
||||
_VERTICAL = frozenset("│┃║")
|
||||
|
||||
#: How far the corner cell itself is lifted toward pure white (0 = untouched, 1 = white).
|
||||
#: The fade multiplies this by a per-cell weight, so every other border cell gets less.
|
||||
_CORNER_LIFT = 0.8
|
||||
|
||||
#: Weights below this leave the cell alone — the blend would be invisible, and skipping it
|
||||
#: keeps far border cells byte-identical to their un-glowed rendering.
|
||||
_MIN_WEIGHT = 0.02
|
||||
|
||||
#: One SGR escape sequence (the only kind the render console emits); group 1 is its
|
||||
#: semicolon-separated parameter list.
|
||||
_SGR = re.compile(r"\x1b\[([0-9;]*)m")
|
||||
|
||||
#: Finds top-left corner glyphs in a plain (escape-free) row at C speed.
|
||||
_CORNER = re.compile(f"[{''.join(_TOP_LEFT)}]")
|
||||
|
||||
|
||||
def _strip(line: str) -> str:
|
||||
"""Return the line's plain text with all SGR escape sequences removed."""
|
||||
return _SGR.sub("", line)
|
||||
|
||||
|
||||
def _weight(distance: int, extent: int) -> float:
|
||||
"""Return the highlight weight for a border cell ``distance`` cells from the corner.
|
||||
|
||||
The fade is quadratic — ``(1 - t)²`` over the edge's ``extent`` — so the glow
|
||||
concentrates near the corner and melts into the border's normal colour well before the
|
||||
far end, rather than tinting the whole edge half-bright.
|
||||
|
||||
Args:
|
||||
distance: Cells travelled along the edge from the top-left corner (0 = the corner).
|
||||
extent: The edge's full length in cells; the weight reaches 0 here.
|
||||
|
||||
Returns:
|
||||
A weight in [0, 1] to scale :data:`_CORNER_LIFT` by.
|
||||
"""
|
||||
if extent <= 0:
|
||||
return 0.0
|
||||
t = min(1.0, distance / extent)
|
||||
return (1.0 - t) ** 2
|
||||
|
||||
|
||||
def _lift(rgb: tuple[int, int, int], amount: float) -> tuple[int, int, int]:
|
||||
"""Blend an RGB colour toward white by ``amount`` (0 = unchanged, 1 = pure white)."""
|
||||
r, g, b = rgb
|
||||
return (
|
||||
round(r + (255 - r) * amount),
|
||||
round(g + (255 - g) * amount),
|
||||
round(b + (255 - b) * amount),
|
||||
)
|
||||
|
||||
|
||||
def _columns(plain: str) -> list[int]:
|
||||
"""Return each character's display column (a cumulative cell-width prefix sum).
|
||||
|
||||
Border glyphs are single-cell, but body text earlier in a row may hold double-width
|
||||
characters; walking columns rather than indices keeps the vertical edge walk aligned
|
||||
under its corner regardless.
|
||||
"""
|
||||
return [0, *accumulate(cell_len(ch) for ch in plain)]
|
||||
|
||||
|
||||
def _index_at_column(columns: list[int], column: int) -> int | None:
|
||||
"""Return the index of the character at display ``column``, or ``None`` if none lands there.
|
||||
|
||||
A wide character straddling the column (so nothing *starts* there) returns ``None``,
|
||||
which simply ends the edge walk — a frame's border cells always start on their column.
|
||||
"""
|
||||
for idx in range(len(columns) - 1):
|
||||
if columns[idx] == column:
|
||||
return idx
|
||||
if columns[idx] > column:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _advance_fg(fg: tuple[int, int, int] | None, params: str) -> tuple[int, int, int] | None:
|
||||
"""Track the foreground colour across one SGR sequence's parameters.
|
||||
|
||||
Only truecolor foregrounds (``38;2;r;g;b``) are resolved — the render console emits
|
||||
every themed colour that way. A reset (``0`` or empty), a default (``39``), or any
|
||||
other foreground form yields ``None``, and cells whose colour is unknown are simply not
|
||||
glowed rather than guessed at.
|
||||
|
||||
Args:
|
||||
fg: The foreground in effect before this sequence, or ``None`` if unknown/default.
|
||||
params: The sequence's raw parameter list (the text between ``ESC[`` and ``m``).
|
||||
|
||||
Returns:
|
||||
The foreground in effect after the sequence.
|
||||
"""
|
||||
parts = params.split(";") if params else [""]
|
||||
i = 0
|
||||
while i < len(parts):
|
||||
code = parts[i] or "0"
|
||||
if code == "0" or code == "39":
|
||||
fg = None
|
||||
elif code == "38":
|
||||
if parts[i + 1 : i + 2] == ["2"] and i + 4 < len(parts):
|
||||
try:
|
||||
fg = (int(parts[i + 2]), int(parts[i + 3]), int(parts[i + 4]))
|
||||
except ValueError: # pragma: no cover - malformed sequence
|
||||
fg = None
|
||||
i += 5
|
||||
continue
|
||||
fg = None # an 8-bit (38;5;n) or malformed foreground: unknown, skip its cells
|
||||
i += 2 if parts[i + 1 : i + 2] == ["5"] else 1
|
||||
elif code == "48":
|
||||
# A background colour: skip its components so they aren't misread as codes.
|
||||
if parts[i + 1 : i + 2] == ["2"]:
|
||||
i += 5
|
||||
elif parts[i + 1 : i + 2] == ["5"]:
|
||||
i += 2
|
||||
elif code.isdigit() and (30 <= int(code) <= 37 or 90 <= int(code) <= 97):
|
||||
fg = None # a named 16-colour foreground; the themed borders never use these
|
||||
i += 1
|
||||
return fg
|
||||
|
||||
|
||||
def _recolor_line(line: str, targets: list[tuple[int, float]]) -> str:
|
||||
"""Splice colour overrides into one ANSI line at the given plain-text indices.
|
||||
|
||||
Walks the line's SGR sequences to track the active foreground, and wraps each target
|
||||
character in a lifted-colour override followed by a restore of the tracked colour.
|
||||
Targets falling where the foreground is unknown are left untouched.
|
||||
|
||||
Args:
|
||||
line: The original ANSI line.
|
||||
targets: ``(plain_index, weight)`` pairs, the border cells to brighten.
|
||||
|
||||
Returns:
|
||||
The line with the highlight spliced in.
|
||||
"""
|
||||
wanted = dict(sorted(targets))
|
||||
out: list[str] = []
|
||||
fg: tuple[int, int, int] | None = None
|
||||
plain_pos = 0
|
||||
cursor = 0
|
||||
|
||||
def emit(text: str) -> None:
|
||||
"""Append a raw text run, recolouring any target characters inside it."""
|
||||
nonlocal plain_pos
|
||||
start = plain_pos
|
||||
plain_pos += len(text)
|
||||
if fg is None:
|
||||
out.append(text)
|
||||
return
|
||||
base = 0
|
||||
for idx, weight in wanted.items():
|
||||
rel = idx - start
|
||||
if rel < base or rel >= len(text):
|
||||
continue
|
||||
r, g, b = _lift(fg, _CORNER_LIFT * weight)
|
||||
out.append(text[base:rel])
|
||||
out.append(f"\x1b[38;2;{r};{g};{b}m{text[rel]}\x1b[38;2;{fg[0]};{fg[1]};{fg[2]}m")
|
||||
base = rel + 1
|
||||
out.append(text[base:])
|
||||
|
||||
for match in _SGR.finditer(line):
|
||||
emit(line[cursor : match.start()])
|
||||
out.append(match.group(0))
|
||||
fg = _advance_fg(fg, match.group(1))
|
||||
cursor = match.end()
|
||||
emit(line[cursor:])
|
||||
return "".join(out)
|
||||
|
||||
|
||||
class _Glower:
|
||||
"""One glow pass over a block of composed lines (find frames → splice highlights)."""
|
||||
|
||||
def __init__(self, lines: list[str]) -> None:
|
||||
self.lines = lines
|
||||
self.plains = [_strip(line) for line in lines]
|
||||
#: Per-row display-column prefix sums, computed lazily — only walked rows need one.
|
||||
self._column_cache: dict[int, list[int]] = {}
|
||||
#: The border cells to brighten: row → ``(plain_index, weight)`` pairs.
|
||||
self.targets: dict[int, list[tuple[int, float]]] = {}
|
||||
|
||||
def _cols(self, row: int) -> list[int]:
|
||||
"""Return (and cache) the column prefix sums for ``row``."""
|
||||
cols = self._column_cache.get(row)
|
||||
if cols is None:
|
||||
cols = self._column_cache[row] = _columns(self.plains[row])
|
||||
return cols
|
||||
|
||||
def apply(self) -> list[str]:
|
||||
"""Find every top-left corner and light its frame; return the updated lines."""
|
||||
for row, plain in enumerate(self.plains):
|
||||
for match in _CORNER.finditer(plain):
|
||||
self._glow_frame(row, match.start())
|
||||
if not self.targets:
|
||||
return self.lines
|
||||
out = list(self.lines)
|
||||
for row, targets in self.targets.items():
|
||||
out[row] = _recolor_line(self.lines[row], targets)
|
||||
return out
|
||||
|
||||
def _glow_frame(self, row: int, corner: int) -> None:
|
||||
"""Light one frame from its top-left corner at ``(row, corner)``."""
|
||||
plain = self.plains[row]
|
||||
columns = self._cols(row)
|
||||
|
||||
# The matching ╮ bounds the top edge; a clipped frame falls back to the row's end.
|
||||
closing = next((j for j in range(corner + 1, len(plain)) if plain[j] in _TOP_RIGHT), None)
|
||||
end = closing if closing is not None else len(plain)
|
||||
extent = max(1, columns[end] - columns[corner])
|
||||
for idx in range(corner, end):
|
||||
ch = plain[idx]
|
||||
if idx != corner and ch not in _HORIZONTAL:
|
||||
continue # title text (and its padding) keeps its own style
|
||||
self._mark(row, idx, _weight(columns[idx] - columns[corner], extent))
|
||||
|
||||
# Walk the left edge downward until the ╰ that closes it (or the block's clipped
|
||||
# end), then fade over that height. The bottom corner lands at weight 0 by design.
|
||||
column = columns[corner]
|
||||
edge: list[tuple[int, int]] = []
|
||||
closed = False
|
||||
for below in range(row + 1, len(self.plains)):
|
||||
idx = _index_at_column(self._cols(below), column)
|
||||
if idx is None:
|
||||
break
|
||||
ch = self.plains[below][idx]
|
||||
if ch in _BOTTOM_LEFT:
|
||||
closed = True
|
||||
break
|
||||
if ch not in _VERTICAL:
|
||||
break
|
||||
edge.append((below, idx))
|
||||
height = len(edge) + 1 if closed else len(edge) + 2
|
||||
for depth, (below, idx) in enumerate(edge, start=1):
|
||||
self._mark(below, idx, _weight(depth, height))
|
||||
|
||||
def _mark(self, row: int, idx: int, weight: float) -> None:
|
||||
"""Record one border cell to brighten (skipping invisibly small weights)."""
|
||||
if weight < _MIN_WEIGHT:
|
||||
return
|
||||
self.targets.setdefault(row, []).append((idx, weight))
|
||||
|
||||
|
||||
#: Memoized glow output per input block. The pass is pure — the highlight is derived from
|
||||
#: the lines alone — and the compositors call it with byte-identical input on every idle
|
||||
#: repaint (the 1 Hz header tick recomposes an unchanged screen), so remembering a handful
|
||||
#: of recent blocks turns the ~3 ms scan-and-splice into a dict hit. A few entries cover
|
||||
#: the layers alive in one frame (the base panel and each stacked dialog).
|
||||
_GLOW_CACHE: "OrderedDict[tuple[str, ...], list[str]]" = OrderedDict()
|
||||
|
||||
#: Blocks the memo keeps: the base frame plus the deepest realistic dialog stack.
|
||||
_GLOW_CACHE_MAX = 12
|
||||
|
||||
|
||||
def apply_corner_glow(lines: list[str]) -> list[str]:
|
||||
"""Light every frame in ``lines`` from its top-left corner.
|
||||
|
||||
Scans the composed ANSI lines for box top-left corners, then blends each frame's border
|
||||
colour toward white — strongest at the corner, fading quadratically along the top and
|
||||
left edges to the border's normal colour. Body text, titles, and the right/bottom edges
|
||||
are untouched. Rows without recoloured cells are returned byte-identical.
|
||||
|
||||
Args:
|
||||
lines: Composed ANSI lines (one per terminal row, no newlines).
|
||||
|
||||
Returns:
|
||||
The lines with the corner highlight applied. The result is memoized per input
|
||||
block (see :data:`_GLOW_CACHE`) — callers treat it as read-only, which they all
|
||||
do (every compositor only joins or concatenates it onward).
|
||||
"""
|
||||
key = tuple(lines)
|
||||
cached = _GLOW_CACHE.get(key)
|
||||
if cached is not None:
|
||||
_GLOW_CACHE.move_to_end(key)
|
||||
return cached
|
||||
out = _Glower(lines).apply()
|
||||
_GLOW_CACHE[key] = out
|
||||
if len(_GLOW_CACHE) > _GLOW_CACHE_MAX:
|
||||
_GLOW_CACHE.popitem(last=False)
|
||||
return out
|
||||
@@ -3,8 +3,9 @@
|
||||
These are behaviour tests, not benchmarks — a wall-clock assertion would be flaky on CI and
|
||||
would say nothing about *why* a regression happened. Each test instead pins the structural
|
||||
property the optimisation rests on: the database opens in the mode that makes small writes
|
||||
cheap, the frame skips the passes that cannot change its pixels, and the startup import graph
|
||||
stays clear of the three subtrees that were dragging a fifth of a second each onto every run.
|
||||
cheap, the per-frame scans that cannot change a picture are skipped, and the startup import
|
||||
graph stays clear of the three subtrees that were dragging a fifth of a second each onto every
|
||||
run.
|
||||
|
||||
The numbers behind these choices were measured on the PicoCalc (Luckfox Lyra, ~1 GHz
|
||||
Cortex-A7, SD card) and are recorded in the plan's Measurements appendix.
|
||||
@@ -18,12 +19,9 @@ from pathlib import Path
|
||||
|
||||
from prompt_toolkit.data_structures import Size
|
||||
from prompt_toolkit.output import DummyOutput
|
||||
from rich.text import Text
|
||||
|
||||
from meshterm.persistence import db
|
||||
from meshterm.platforms import PICOCALC, REGULAR, set_platform
|
||||
from meshterm.ui.tui.frame import compose_base
|
||||
from meshterm.ui.tui.screen import Screen
|
||||
from meshterm.ui.tui.session import _has_wide_glyph
|
||||
from meshterm.ui.tui.spinner import Spinner
|
||||
|
||||
@@ -133,46 +131,6 @@ def test_widgets_does_not_drag_the_spatial_modules() -> None:
|
||||
# --- per-frame work the platform can skip -------------------------------------------
|
||||
|
||||
|
||||
class _Body(Screen):
|
||||
"""A minimal screen, just enough for ``compose_base`` to frame something."""
|
||||
|
||||
title = "Perf"
|
||||
|
||||
def render_body(self, width: int) -> list[str]: # noqa: D102 - inherited docstring
|
||||
return ["body"]
|
||||
|
||||
|
||||
def _glow_calls(cols: int = 60, rows: int = 20) -> int:
|
||||
"""Compose one frame, counting how many times the corner-glow pass ran."""
|
||||
import meshterm.ui.tui.frame as frame_mod
|
||||
|
||||
original = frame_mod.apply_corner_glow
|
||||
calls = 0
|
||||
|
||||
def counting(lines: list[str]) -> list[str]:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return original(lines)
|
||||
|
||||
frame_mod.apply_corner_glow = counting
|
||||
try:
|
||||
compose_base(Text("hdr"), _Body(), "Esc back", cols, rows)
|
||||
finally:
|
||||
frame_mod.apply_corner_glow = original
|
||||
return calls
|
||||
|
||||
|
||||
def test_glow_runs_on_regular() -> None:
|
||||
"""The desktop frame keeps its lit corners."""
|
||||
assert _glow_calls() == 1
|
||||
|
||||
|
||||
def test_glow_is_skipped_where_effects_are_off() -> None:
|
||||
"""The pass only recolours truecolor foregrounds, so on PicoCalc it is pure scan cost."""
|
||||
set_platform(PICOCALC)
|
||||
assert _glow_calls() == 0
|
||||
|
||||
|
||||
def test_wide_glyph_scan_short_circuits_without_emoji() -> None:
|
||||
"""A no-emoji console font has no wide glyph, so the per-frame scan can be skipped."""
|
||||
waving = "hi \U0001f44b"
|
||||
|
||||
+24
-44
@@ -22,8 +22,7 @@ from meshterm import copyright_notice
|
||||
from tests.conftest import plain as _plain
|
||||
from meshterm.ui.menus import section_heading
|
||||
from meshterm.ui.pathline import CRACK_TAIL, PathHop, PathLine
|
||||
from meshterm.ui.tui import frame, glow
|
||||
from meshterm.ui.tui.glow import apply_corner_glow
|
||||
from meshterm.ui.tui import frame
|
||||
from meshterm.ui.tui.progress import ProgressScreen
|
||||
from meshterm.ui.tui.prompt import (
|
||||
AutocompleteScreen,
|
||||
@@ -870,7 +869,7 @@ def test_compose_startup_box_is_horizontally_centered() -> None:
|
||||
assert box_lines and all(ln.startswith(" ") for ln in box_lines) # centered inset
|
||||
|
||||
|
||||
# --- glow --------------------------------------------------------------------
|
||||
# --- frames ------------------------------------------------------------------
|
||||
|
||||
|
||||
def _cell_color(line: str, idx: int) -> tuple[int, int, int]:
|
||||
@@ -892,54 +891,35 @@ def _find(plain: str, glyphs: frozenset[str]) -> int:
|
||||
return next(i for i, ch in enumerate(plain) if ch in glyphs)
|
||||
|
||||
|
||||
def test_corner_glow_brightens_top_left_and_fades_along_top_edge() -> None:
|
||||
"""The ╭ corner is lifted toward white and the glow decays rightward to the base color."""
|
||||
lines = apply_corner_glow(render_lines(Panel(Text("x"), border_style="accent", width=60), 60))
|
||||
plain = Text.from_ansi(lines[0]).plain
|
||||
corner = sum(_cell_color(lines[0], _find(plain, glow._TOP_LEFT)))
|
||||
middle = sum(_cell_color(lines[0], len(plain) // 2))
|
||||
far = sum(_cell_color(lines[0], _find(plain, glow._TOP_RIGHT) - 1))
|
||||
assert far == sum(_ACCENT) # the glow has fully melted into the border color
|
||||
assert corner > middle > far # brightest at the corner, fading rightward
|
||||
_CORNERS = frozenset("╭┌╮┐╰└╯┘")
|
||||
_EDGES = frozenset("─│")
|
||||
|
||||
|
||||
def test_corner_glow_fades_down_left_edge_only() -> None:
|
||||
"""The left border fades downward; the right edge and bottom corner stay untouched."""
|
||||
body = Text("\n".join("row" for _ in range(8)))
|
||||
lines = apply_corner_glow(render_lines(Panel(body, border_style="accent", width=30), 30))
|
||||
upper = sum(_cell_color(lines[1], 0))
|
||||
lower = sum(_cell_color(lines[7], 0))
|
||||
assert upper > lower >= sum(_ACCENT) # vertical fade toward the base color
|
||||
right = Text.from_ansi(lines[1]).plain.rindex("│")
|
||||
assert _cell_color(lines[1], right) == _ACCENT # right edge keeps the plain border
|
||||
bottom = Text.from_ansi(lines[-1]).plain
|
||||
assert _cell_color(lines[-1], _find(bottom, glow._BOTTOM_LEFT)) == _ACCENT # weight is 0 here
|
||||
def _border_colours(lines: list[str]) -> set[tuple[int, int, int]]:
|
||||
"""Every distinct colour the box-drawing glyphs in ``lines`` are painted in."""
|
||||
return {
|
||||
_cell_color(line, i)
|
||||
for line in lines
|
||||
for i, ch in enumerate(Text.from_ansi(line).plain)
|
||||
if ch in _CORNERS or ch in _EDGES
|
||||
}
|
||||
|
||||
|
||||
def test_corner_glow_leaves_titles_and_text_alone() -> None:
|
||||
"""Only border glyphs are recoloured: the title keeps its style, the text its content."""
|
||||
panel = Panel(Text("body"), title="Hello", border_style="accent", width=40)
|
||||
raw = render_lines(panel, 40)
|
||||
lines = apply_corner_glow(raw)
|
||||
assert [Text.from_ansi(ln).plain for ln in lines] == [Text.from_ansi(ln).plain for ln in raw]
|
||||
idx = Text.from_ansi(raw[0]).plain.index("H")
|
||||
assert _cell_color(lines[0], idx) == _cell_color(raw[0], idx) # title untouched
|
||||
def test_a_frame_draws_in_one_colour() -> None:
|
||||
"""A border is its own colour the whole way round — no corner lit, no edge fading.
|
||||
|
||||
|
||||
def test_corner_glow_lights_nested_panels_from_their_own_corners() -> None:
|
||||
"""A tool panel nested inside a screen body glows too, blended from its own border color."""
|
||||
inner = Panel(Text("body"), border_style="muted", width=20)
|
||||
The frames were lit from the top-left for a while: the corner blended toward white and
|
||||
the highlight decayed along the top and left edges. It read as a gradient laid over the
|
||||
chrome rather than as the box being a box, so the pass is gone and the border draws flat.
|
||||
Nested panels are checked with the outer frame, because the pass lit those too.
|
||||
"""
|
||||
inner = Panel(Text("body"), border_style="accent", width=20)
|
||||
screen = ScrollScreen(Group(Text("above"), inner), title="outer")
|
||||
lines = frame.compose_base(Text("h"), screen, "hint", 60, 20).split("\n")
|
||||
row, plain = next(
|
||||
(i, p)
|
||||
for i, p in ((i, Text.from_ansi(ln).plain) for i, ln in enumerate(lines))
|
||||
# The inner box's top edge: a corner glyph on a row already inside the outer border.
|
||||
if any(ch in glow._TOP_LEFT for ch in p) and p.lstrip()[0] in glow._VERTICAL
|
||||
)
|
||||
muted = (148, 163, 184) # #94a3b8
|
||||
corner = _cell_color(lines[row], _find(plain, glow._TOP_LEFT))
|
||||
assert corner != muted and all(c > b for c, b in zip(corner, muted)) # lifted toward white
|
||||
assert _border_colours(lines) == {_ACCENT}, "the base frame draws in more than one colour"
|
||||
|
||||
dialog = frame.compose_dialog(ScrollScreen(Text("body"), title="d"), 80, 20)
|
||||
assert _border_colours(dialog.split("\n")) == {_ACCENT}, "so does a dialog's"
|
||||
|
||||
|
||||
# --- prompts -----------------------------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user