From c66e2c86b686adbb377038c2a975b33abbbd1197 Mon Sep 17 00:00:00 2001 From: Jean-Pierre Martineau Date: Wed, 26 Aug 2026 01:18:29 -0400 Subject: [PATCH] Frames: the border is one colour again, so drop the corner light MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- meshterm/platforms.py | 8 +- meshterm/ui/tui/frame.py | 28 +--- meshterm/ui/tui/glow.py | 331 -------------------------------------- tests/test_performance.py | 48 +----- tests/test_tui.py | 68 +++----- 5 files changed, 35 insertions(+), 448 deletions(-) delete mode 100644 meshterm/ui/tui/glow.py diff --git a/meshterm/platforms.py b/meshterm/platforms.py index 40f9da3..94588d9 100644 --- a/meshterm/platforms.py +++ b/meshterm/platforms.py @@ -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 diff --git a/meshterm/ui/tui/frame.py b/meshterm/ui/tui/frame.py index 3b3e38c..92400d3 100644 --- a/meshterm/ui/tui/frame.py +++ b/meshterm/ui/tui/frame.py @@ -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) diff --git a/meshterm/ui/tui/glow.py b/meshterm/ui/tui/glow.py deleted file mode 100644 index ab408e7..0000000 --- a/meshterm/ui/tui/glow.py +++ /dev/null @@ -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 diff --git a/tests/test_performance.py b/tests/test_performance.py index 6ca3eb6..1913018 100644 --- a/tests/test_performance.py +++ b/tests/test_performance.py @@ -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" diff --git a/tests/test_tui.py b/tests/test_tui.py index 094982b..89ebb5b 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -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 -----------------------------------------------------------------