diff --git a/CLAUDE.md b/CLAUDE.md index d9ce17a..f1d104c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -765,8 +765,12 @@ branch on the platform per frame, and never `from meshterm.platforms import PLAT `fontset.CARDPUTER_ZERO_CODEPOINTS`, named by `Platform.font` as the PicoCalc's is; the fold derives everything from the platform's font (accents, emoji, the `?` net) and quantizes colour only where `truecolor` is off. The path line's chips-or-arrows verdict reads the - same font (`termfont`, after `MESHTERM_POWERLINE` and before any terminal probe): Terminus - has the chevrons, so the Cardputer draws chips; the PicoCalc's 512 glyphs don't, so arrows. + same font (`termfont`, after `MESHTERM_POWERLINE` and before any terminal probe), and both + handhelds have the chevrons: Terminus natively, the PicoCalc's font in two donor slots. On + the PicoCalc a chip can only be filled from the eight background slots, so a node's chip + is the dim twin of its hue with the label in the hue itself (`theme.DIM_TWIN`), every chip + span says `not bold` (bold is brightness), and our `★`, a faded hop and the composer's + slot stand on the page, because there is no dark-grey background (`pathline._slot_colours`). - **The Cardputer's exit must fit its launcher's grace.** A held Esc there is the launcher's own way out (SIGTERM at 3 s, SIGKILL 3 s later, letting go or not), which is where hold-to-quit's timing comes from (see **Navigation**). MeshTerm's whole exit was diff --git a/meshterm/ui/fontset.py b/meshterm/ui/fontset.py index cbdbf32..7578db0 100644 --- a/meshterm/ui/fontset.py +++ b/meshterm/ui/fontset.py @@ -26,7 +26,9 @@ from __future__ import annotations #: Inclusive ``(first, last)`` codepoint ranges present in the font. 519 codepoints over #: 512 glyphs — aliases (the rounded corners onto the square ones, ``⋯`` onto ``…``) and #: shared slots give a few glyphs more than one codepoint. Verified against a live table -#: dump of the installed font (2026-08-01, post-P3 build). +#: dump of the installed font (2026-08-01, post-P3 build). On 2026-10-06, the two powerline +#: chevrons of the path chips (U+E0B0, U+E0B1) took the donor slots of U+2559 and U+255B. +#: No live dump checked that build yet. # fmt: off FONT_RANGES: tuple[tuple[int, int], ...] = ( (0x0020, 0x007F), (0x00A0, 0x00A0), (0x00A7, 0x00A7), (0x00A9, 0x00A9), @@ -41,7 +43,7 @@ FONT_RANGES: tuple[tuple[int, int], ...] = ( (0x250C, 0x250C), (0x2510, 0x2510), (0x2514, 0x2514), (0x2518, 0x2518), (0x251C, 0x251C), (0x2524, 0x2524), (0x252C, 0x252C), (0x2534, 0x2534), (0x253C, 0x253C), (0x2550, 0x2551), (0x2554, 0x2554), (0x2557, 0x2557), - (0x2559, 0x255B), + (0x255A, 0x255A), (0x255D, 0x2561), (0x2563, 0x2563), (0x2566, 0x256A), (0x256C, 0x2570), (0x2580, 0x2580), (0x2584, 0x2584), (0x2588, 0x2588), (0x258C, 0x258C), (0x2590, 0x2593), (0x25A0, 0x25A0), (0x25AC, 0x25AC), (0x25B2, 0x25B2), @@ -49,12 +51,13 @@ FONT_RANGES: tuple[tuple[int, int], ...] = ( (0x25C0, 0x25C0), (0x25C4, 0x25C4), (0x25C9, 0x25C9), (0x25CB, 0x25CB), (0x25CF, 0x25CF), (0x25F7, 0x25F7), (0x2605, 0x2605), (0x263C, 0x263C), (0x2699, 0x2699), (0x26A0, 0x26A0), (0x26BF, 0x26BF), (0x2713, 0x2713), - (0x2717, 0x2717), (0x276F, 0x276F), (0x2800, 0x28FF), (0xFFFD, 0xFFFD), + (0x2717, 0x2717), (0x276F, 0x276F), (0x2800, 0x28FF), (0xE0B0, 0xE0B1), + (0xFFFD, 0xFFFD), ) # fmt: on #: Every codepoint the console font can draw, as one frozen set (built once at import; -#: 520 members, so the set is small and the per-character membership test is a dict hit). +#: 519 members, so the set is small and the per-character membership test is a dict hit). FONT_CODEPOINTS: frozenset[int] = frozenset( cp for first, last in FONT_RANGES for cp in range(first, last + 1) ) diff --git a/meshterm/ui/pathline.py b/meshterm/ui/pathline.py index d687161..150e693 100644 --- a/meshterm/ui/pathline.py +++ b/meshterm/ui/pathline.py @@ -14,7 +14,9 @@ drawn*, so every surface the survey found can eventually route through it: chip ahead as its background — so a route reads as a ribbon whose segments meet on a chevron: the oh-my-posh look, at one cell a seam. The chip fill is the node's hash-derived hue (:func:`~meshterm.ui.theme.node_style`), our own node the map's yellow - ``★`` on a neutral dark grey, a faded hop dark slate, a keyless hop grey. Two chips that + ``★`` on a neutral dark grey, a faded hop dark slate, a keyless hop grey. (On the + 16-colour console of the PicoCalc, a node's chip is the dim twin of its hue, and the + label is in the hue itself. Refer to :func:`_slot_colours`.) Two chips that land on the *same* fill — a mirrored return leg, a stretch of keyless greys — or on two fills too close to tell apart (under :data:`SEAM_BLUR` apart in OKLab, the perceptual distance :mod:`~meshterm.ui.oklab` measures) take the one exception the interlock can't @@ -82,6 +84,7 @@ from __future__ import annotations import re from collections.abc import Callable, Sequence from dataclasses import dataclass +from typing import NamedTuple from rich.cells import cell_len from rich.console import Console, ConsoleOptions, RenderResult @@ -89,10 +92,11 @@ from rich.measure import Measurement from rich.text import Text from ..core.models import LOCAL_DEVICE_LABEL +from ..platforms import Platform, on_platform from . import oklab from .marks import SELF_MARK from .termfont import powerline_enabled, powerline_full -from .theme import active_theme, node_style +from .theme import DIM_TWIN, active_theme, node_style, slot_hex #: The powerline solid right-pointing triangle (U+E0B0) — the glyph the widget draws #: at every seam and closing edge, deliberately from the *core* set so every recommended @@ -152,6 +156,35 @@ _YOU_BG = "#3f3f46" _DIM_BG = "#334155" _DIM_FG = "#94a3b8" +#: On the 16-colour console, the page: slot 0, black. It is the fill of a chip that the +#: console cannot fill, because the VT has no dark grey background. That chip is our +#: ``★``, a faded hop, or the composer's slot. Its text then sits on the page, and the +#: chips on each side close on a point and open on a notch. +_SLOT_PAGE = slot_hex(0) +#: On the console, the text of a faded hop: slot 8, the dark grey. +_SLOT_FAINT = slot_hex(8) +#: On the console, white: slot 15. It is the text of a keyless chip and of the +#: composer's slot. +_SLOT_WHITE = slot_hex(15) +#: Each dim console slot and its bright twin: the reverse of +#: :data:`~meshterm.ui.theme.DIM_TWIN`. +_BRIGHT_TWIN = {dim: bright for bright, dim in DIM_TWIN.items()} + + +class _ChipColours(NamedTuple): + """The colours of one chip. + + Attributes: + fill: The background of the chip. + ink: The colour of the label. + soft: The colour of an annotation, and of a hash label after its lit bytes. + """ + + fill: str + ink: str + soft: str + + #: The mark standing in for elided hops (see :meth:`PathLine.ellipsized`) — rendered as #: a dim pseudo-hop so it recedes in both modes. _ELISION = "⋯" @@ -365,7 +398,7 @@ def cut_mark(line: Text, at: int, side: str) -> Text: fill = next((fills[i] for i in steps if fills[i]), None) if fill is None: return Text(_ELLIPSIS, style="muted") - return Text(CRACK_HEAD if side == ELIDE_HEAD else CRACK_TAIL, style=fill) + return Text(CRACK_HEAD if side == ELIDE_HEAD else CRACK_TAIL, style=f"{_FLAT}{fill}") def with_action_mark(line: Text, width: int) -> Text: @@ -690,7 +723,7 @@ class PathLine: foot = 0 if self._to_destination else cell_len(self._separator.rstrip()) return plain_cells, cell_len(self._separator), 0, 0, head, foot widths = [ - cell_len(hop.label) if hop.gap else self._chip(hop, self._chip_fill(hop)).cell_len + cell_len(hop.label) if hop.gap else self._chip(hop, _colours_of(hop)).cell_len for hop in hops ] sep = cell_len(POWERLINE_SEP) @@ -971,7 +1004,8 @@ class PathLine: carry_in: This line continues a wrapped path (it does not open one). carry_on: The path continues past this line (it does not end here). """ - fills = [self._chip_fill(hop) for hop in hops] + colours = [_colours_of(hop) for hop in hops] + fills = [colour.fill for colour in colours] rounded = powerline_full() # A fold and a half-drawn route are two reasons for the same mark; either is # enough. An elision already breaks the ribbon on that side, and a mark cut out @@ -981,29 +1015,29 @@ class PathLine: if opens_mid: text.append_text(self._notch(fills[0])) # the break's other half elif rounded and not hops[0].gap: - text.append(POWERLINE_ROUND_OPEN, style=fills[0]) + text.append(POWERLINE_ROUND_OPEN, style=f"{_FLAT}{fills[0]}") for i, hop in enumerate(hops): if i: if hops[i - 1].gap: text.append_text(self._notch(fills[i])) # the ribbon picks up again elif hop.gap: - text.append(POWERLINE_SEP, style=fills[i - 1]) # …and stops here + text.append(POWERLINE_SEP, style=f"{_FLAT}{fills[i - 1]}") # …and stops else: - text.append_text(self._seam(fills[i - 1], fills[i])) + text.append_text(self._seam(colours[i - 1], colours[i])) if hop.gap: text.append(hop.label, style="faint" if hop.dim else "muted") else: - text.append_text(self._chip(hop, fills[i])) + text.append_text(self._chip(hop, colours[i])) if hops[-1].gap: return text # the line ended on the page; there is no chip left to close if carry_on or not self._to_destination: - text.append(POWERLINE_SEP, style=fills[-1]) # the point: the path goes on + text.append(POWERLINE_SEP, style=f"{_FLAT}{fills[-1]}") # the point: it goes on elif rounded: - text.append(POWERLINE_ROUND_CLOSE, style=fills[-1]) # the lozenge's far end + text.append(POWERLINE_ROUND_CLOSE, style=f"{_FLAT}{fills[-1]}") # the far end return text @staticmethod - def _seam(before: str, after: str) -> Text: + def _seam(before: _ChipColours, after: _ChipColours) -> Text: """The one cell between two chips: the previous fill's point, laid on the next. The classic interlock — foreground the chip behind, background the chip ahead — @@ -1031,16 +1065,20 @@ class PathLine: and a chevron in the dark chip ink read as a mark laid on the route rather than a seam in it. One cell either way. + On the 16-colour console, the thin chevron is the bright twin of the fill, which + is the colour of the label on that chip (:func:`_slot_thin_ink`). + Args: - before: The fill the point is drawn in (the previous chip's). - after: The fill it is laid on (the next chip's). + before: The colours of the previous chip. The point is drawn in its fill. + after: The colours of the next chip. The point is laid on its fill. Returns: The seam's single styled cell. """ - if _fills_blur(before, after): - return Text(POWERLINE_THIN, style=f"{_seam_ink(before, after)} on {after}") - return Text(POWERLINE_SEP, style=f"{before} on {after}") + if _fills_blur(before.fill, after.fill): + ink = _thin_ink(before, after) + return Text(POWERLINE_THIN, style=f"{_FLAT}{ink} on {after.fill}") + return Text(POWERLINE_SEP, style=f"{_FLAT}{before.fill} on {after.fill}") @staticmethod def _notch(fill: str) -> Text: @@ -1058,37 +1096,12 @@ class PathLine: The one styled cell. """ text = Text() - text.append(POWERLINE_SEP, style=f"{fill} reverse") + text.append(POWERLINE_SEP, style=f"{_FLAT}{fill} reverse") return text - def _chip_fill(self, hop: PathHop) -> str: - """A chip's fill: cursor accent, override, dim slate, our grey, hue, keyless grey. - - The insertion slot outranks everything — it is the one chip that isn't a node, - and it wears the ``cursor`` white so it reads as chrome among identities rather - than as a hop with an unlucky hue (white sits outside the node spectrum, and the - chip ink is already picked to stay readable on it). The fade comes next, outranking - identity including our own: a dimmed hop is one nobody composed (an automatic landing - back home, a mirrored return leg), and our end must recede with the rest of that automatic - half rather than keep its yellow among the greys. Plain mode says the same thing - by fading the name. - """ - if hop.cursor: - return _style_hex("cursor") or _KEYLESS_BG - if hop.style: - resolved = _style_hex(hop.style) - if resolved: - return resolved - if hop.dim: - return _DIM_BG - if hop.you: - return _YOU_BG - if hop.key: - return _style_hex(node_style(hop.key)) or _KEYLESS_BG - return _KEYLESS_BG - - def _chip(self, hop: PathHop, fill: str) -> Text: - """One chip: same words as arrow mode, dark ink on the identity fill. + @staticmethod + def _chip(hop: PathHop, colours: _ChipColours) -> Text: + """One chip: the same words as arrow mode, in the chip's ink on its fill. Every chip is padded on both sides, the ``★`` included (JP, 2026-08-09): the pads are not there to make room for a long *word*, they are the chip's own shape, and a @@ -1096,24 +1109,134 @@ class PathLine: than as a segment of the route. Our own chip's ink is the map's yellow on the neutral dark grey :data:`_YOU_BG`, so the star in the line and the star on the map read as one mark. + + The colours come from the palette of the platform (:func:`_colours_of`). """ - ink = _DIM_FG if hop.dim else (_SELF_INK if hop.you else _CHIP_FG) - soft = _DIM_FG if hop.dim else _CHIP_FG_SOFT + fill, ink, soft = colours text = Text() - text.append(" ", style=f"on {fill}") + text.append(" ", style=f"{_FLAT}on {fill}") if hop.lit_bytes > 0 and not hop.dim: split = hop.lit_bytes * 2 - text.append(hop.label[:split], style=f"bold {ink} on {fill}") - text.append(hop.label[split:], style=f"{soft} on {fill}") + text.append(hop.label[:split], style=f"{_WEIGHT}{ink} on {fill}") + text.append(hop.label[split:], style=f"{_FLAT}{soft} on {fill}") else: - weight = "" if hop.dim else "bold " + weight = _FLAT if hop.dim else _WEIGHT text.append(hop.label, style=f"{weight}{ink} on {fill}") if hop.annotation: - text.append(f" ({hop.annotation})", style=f"{soft} on {fill}") - text.append(" ", style=f"on {fill}") + text.append(f" ({hop.annotation})", style=f"{_FLAT}{soft} on {fill}") + text.append(" ", style=f"{_FLAT}on {fill}") return text +def _spectrum_fill(hop: PathHop) -> str: + """A chip's fill: cursor accent, override, dim slate, our grey, hue, keyless grey. + + The insertion slot outranks everything — it is the one chip that isn't a node, + and it wears the ``cursor`` white so it reads as chrome among identities rather + than as a hop with an unlucky hue (white sits outside the node spectrum, and the + chip ink is already picked to stay readable on it). The fade comes next, outranking + identity including our own: a dimmed hop is one nobody composed (an automatic landing + back home, a mirrored return leg), and our end must recede with the rest of that automatic + half rather than keep its yellow among the greys. Plain mode says the same thing + by fading the name. + """ + if hop.cursor: + return _style_hex("cursor") or _KEYLESS_BG + if hop.style: + resolved = _style_hex(hop.style) + if resolved: + return resolved + if hop.dim: + return _DIM_BG + if hop.you: + return _YOU_BG + if hop.key: + return _style_hex(node_style(hop.key)) or _KEYLESS_BG + return _KEYLESS_BG + + +def _spectrum_colours(hop: PathHop) -> _ChipColours: + """The colours of a chip on a truecolor terminal: dark ink on the fill.""" + ink = _DIM_FG if hop.dim else (_SELF_INK if hop.you else _CHIP_FG) + soft = _DIM_FG if hop.dim else _CHIP_FG_SOFT + return _ChipColours(_spectrum_fill(hop), ink, soft) + + +def _slot_colours(hop: PathHop) -> _ChipColours: + """The colours of a chip on the 16-colour console: a bright slot on its dim twin. + + The VT draws a background only from slots 0 to 7, which are the dim twins of the + bright slots that colour the node names. Thus a node's chip is the dim twin of its + hue, and its label is in the hue itself: the colour that its name has in arrow mode + and in each list. A pink name gets a purple chip, a yellow name a brown chip. The + whole label is in this colour, a hash label too: a grey for the rest of a hash was + not readable on the green and the cyan fills (JP, 2026-10-06). + + A keyless chip is white on light grey, the same pair one bank down. Our ``★``, a + faded hop, and the composer's slot have no fill of their own, because the console + has no dark grey background. They stand on the page (:data:`_SLOT_PAGE`): the star + in its yellow, a faded hop in the dark grey, the slot as a white ``+`` in a break of + the ribbon. The order of the rules is the same as on a truecolor terminal + (:func:`_spectrum_fill`). + """ + if hop.cursor: + return _ChipColours(_SLOT_PAGE, _SLOT_WHITE, _SLOT_WHITE) + if hop.style: + resolved = _style_hex(hop.style) + if resolved: + fill = DIM_TWIN.get(resolved, resolved) + ink = resolved if resolved in DIM_TWIN else _SLOT_WHITE + return _ChipColours(fill, ink, ink) + if hop.dim: + return _ChipColours(_SLOT_PAGE, _SLOT_FAINT, _SLOT_FAINT) + if hop.you: + return _ChipColours(_SLOT_PAGE, _SELF_INK, _SELF_INK) + hue = _style_hex(node_style(hop.key)) if hop.key else None + ink = hue if hue in DIM_TWIN else _SLOT_WHITE + return _ChipColours(DIM_TWIN[ink], ink, ink) + + +def _spectrum_thin_ink(before: _ChipColours, after: _ChipColours) -> str: + """The thin chevron on a truecolor terminal: the previous fill, shaded.""" + return _seam_ink(before.fill, after.fill) + + +def _slot_thin_ink(before: _ChipColours, after: _ChipColours) -> str: + """The thin chevron on the console: the bright twin of the previous fill. + + On a node's chip, this colour is the label's hue. On the page, it is the dark grey. + """ + return _BRIGHT_TWIN.get(before.fill, before.ink) + + +#: The colours of a chip, the colour of a thin seam, and the two prefixes of the chip +#: styles. They are bound at platform-switch time (:func:`_bind_chips`). +_colours_of: Callable[[PathHop], _ChipColours] = _spectrum_colours +_thin_ink: Callable[[_ChipColours, _ChipColours], str] = _spectrum_thin_ink +#: The weight of a chip label: ``bold`` on a truecolor terminal. +_WEIGHT = "bold " +#: The prefix of each other chip style: empty on a truecolor terminal. +_FLAT = "" + + +@on_platform +def _bind_chips(platform: Platform) -> None: + """Bind the chip palette to the platform (runs now and on every switch). + + On the 16-colour console, bold is brightness. Rich merges the base style of a row + into each span on it, so the ``bold`` of a selected row would move each dim fill on + the ribbon to its bright twin. Thus each chip style there says ``not bold``, and a + label gets no weight: its bright slot is its emphasis. + """ + global _colours_of, _thin_ink, _WEIGHT, _FLAT + if platform.truecolor: + _colours_of, _thin_ink = _spectrum_colours, _spectrum_thin_ink + _WEIGHT, _FLAT = "bold ", "" + else: + _colours_of, _thin_ink = _slot_colours, _slot_thin_ink + _WEIGHT = _FLAT = "not bold " + + def _shorten(value: str, hash_bytes: int | None) -> str: """Bare lowercase hex, truncated to ``hash_bytes`` bytes (whole when falsy).""" raw = value.lower().removeprefix("0x") diff --git a/meshterm/ui/termfont.py b/meshterm/ui/termfont.py index 1659aa6..773b3a4 100644 --- a/meshterm/ui/termfont.py +++ b/meshterm/ui/termfont.py @@ -42,9 +42,10 @@ Four sources of truth, in confidence order: (:attr:`~meshterm.platforms.Platform.font`), every frame is folded down to that font at the render boundary, whatever terminal started the app — so the inventory is the answer, and asking the terminal would be asking the wrong glass. The Cardputer's panel - is Terminus, which carries the core chevrons, so its paths are chips; the PicoCalc's - 512-glyph console font has no room for them, so its paths stay arrows. Nothing is - probed: :data:`~meshterm.ui.fontset.FONTS` already says what each font can draw. + is Terminus, which carries the core chevrons. The PicoCalc's 512-glyph console font + draws the two chevrons in two donor slots. Thus the paths are chips on both handhelds. + Nothing is probed: :data:`~meshterm.ui.fontset.FONTS` already says what each font can + draw. * **The configured font**, resolved per terminal: Windows Terminal (``WT_SESSION`` + ``WT_PROFILE_ID`` → the profile's ``font.face`` in ``settings.json``), VS Code's integrated terminal (``TERM_PROGRAM=vscode`` → ``terminal.integrated.fontFamily``, diff --git a/meshterm/ui/theme.py b/meshterm/ui/theme.py index e61c986..bd8d85e 100644 --- a/meshterm/ui/theme.py +++ b/meshterm/ui/theme.py @@ -253,6 +253,19 @@ _VT_SLOTS: tuple[tuple[int, str, str], ...] = ( (15, "white (you, heat.hot, the room mark)", "#ffffff"), ) + +def slot_hex(slot: int) -> str: + """The ``#rrggbb`` of one console palette slot (:data:`_VT_SLOTS`).""" + return _VT_SLOTS[slot][2] + + +#: Each bright console slot and its twin in the dim bank: slot ``n + 8`` and slot ``n``. +#: The VT draws a background only from slots 0 to 7. Thus, where a bright colour must +#: become a fill, its dim twin is the only background of the same hue. The path chips +#: use this pair on the console (:mod:`~meshterm.ui.pathline`): the dim twin is the fill, +#: and the bright slot is the text on it. +DIM_TWIN: dict[str, str] = {slot_hex(n + 8): slot_hex(n) for n in range(8)} + #: The custom 16-slot remap P3 originally shipped (tailwind-family RGBs programmed via #: ``setvtrgb``) — **archived, not installed**: JP chose the standard palette but asked #: to keep this in case he changes his mind. Reinstall with diff --git a/scripts/picocalc-lyra/calculinux-console-font-6x12.sh b/scripts/picocalc-lyra/calculinux-console-font-6x12.sh index 9274103..815e976 100755 --- a/scripts/picocalc-lyra/calculinux-console-font-6x12.sh +++ b/scripts/picocalc-lyra/calculinux-console-font-6x12.sh @@ -170,6 +170,14 @@ MARKS = { 0x26BF: art([ # SQUARED KEY (drawn as a padlock) -- private channel "......", "......", ".####.", ".#..#.", "######", "######", "##..##", "##..##", "######", "######", "......", "......"]), + # The two powerline chevrons of the path chips (meshterm/ui/pathline.py). They fill + # the full cell, so that the point of one chip touches the fill of the next. + 0xE0B0: art([ # POWERLINE SOLID RIGHT TRIANGLE: the seam between two chips + "#.....", "##....", "###...", "####..", "#####.", "######", + "######", "#####.", "####..", "###...", "##....", "#....."]), + 0xE0B1: art([ # POWERLINE THIN RIGHT CHEVRON: the seam between two chips of one colour + "#.....", ".#....", "..#...", "...#..", "....#.", ".....#", + ".....#", "....#.", "...#..", "..#...", ".#....", "#....."]), } # Donor codepoints whose glyph slots we may repurpose (glyphs MeshTerm never draws). diff --git a/scripts/picocalc-lyra/calculinux-console-font-6x8.sh b/scripts/picocalc-lyra/calculinux-console-font-6x8.sh index 791d33f..e2ee47b 100755 --- a/scripts/picocalc-lyra/calculinux-console-font-6x8.sh +++ b/scripts/picocalc-lyra/calculinux-console-font-6x8.sh @@ -21,7 +21,7 @@ # one; point SHARED at it if it does not. The shared generator is additionally offered # under GPL-2.0-only by its source file, permitting its extraction and use here. # -# WHAT IS ONLY HERE. The GPL-2.0 base bitmap, its CP437 unicode mapping, and the same 18 +# WHAT IS ONLY HERE. The GPL-2.0 base bitmap, its CP437 unicode mapping, and the same 20 # marks redrawn for the shorter cell (MARKS8). # # The 6x8 base has CP437 coverage, so unlike the Terminus base it has NO CYRILLIC: a node @@ -72,7 +72,7 @@ exec(compile(_block.group(1), SHARED, "exec"), globals()) OUT8 = os.environ["OUT8"] -# The same 18 marks redrawn for the 6x8 cell (first-draft art; a later tweak round +# The same 20 marks redrawn for the 6x8 cell (first-draft art; a later tweak round # refines whichever font wins the A/B). MARKS8 = { 0x25CF: art([ # BLACK CIRCLE @@ -111,6 +111,10 @@ MARKS8 = { "..##..", "......", "#.##.#", "#.##.#", "......", "..##..", "......", "......"]), 0x26BF: art([ # SQUARED KEY -- padlock ".####.", ".#..#.", "######", "##..##", "##..##", "######", "######", "......"]), + 0xE0B0: art([ # POWERLINE SOLID RIGHT TRIANGLE: the full cell + "##....", "###...", "#####.", "######", "######", "#####.", "###...", "##...."]), + 0xE0B1: art([ # POWERLINE THIN RIGHT CHEVRON: on the edge of the solid triangle + "##....", ".##...", "...##.", "....##", "....##", "...##.", ".##...", "##...."]), } # --- the base bitmap: the Linux kernel's font_6x8 (GPL-2.0; see the header) ------------ diff --git a/tests/test_pathline.py b/tests/test_pathline.py index aa4f905..fdf7e11 100644 --- a/tests/test_pathline.py +++ b/tests/test_pathline.py @@ -12,10 +12,14 @@ from __future__ import annotations import random import pytest +from rich.text import Text import meshterm.ui.pathline as pathline -from meshterm.platforms import CARDPUTER_ZERO, set_platform +from meshterm.emulator import vt +from meshterm.emulator.vt import Style, Terminal +from meshterm.platforms import CARDPUTER_ZERO, PICOCALC_LYRA, set_platform from meshterm.ui import oklab +from meshterm.ui.fontset import FONT_CODEPOINTS from meshterm.ui.pathline import ( _DIM_BG, _SELF_INK, @@ -45,7 +49,8 @@ from meshterm.ui.pathline import ( path_line, with_action_mark, ) -from meshterm.ui.theme import fold_text, node_style +from meshterm.ui.theme import DIM_TWIN, fold_text, node_style, slot_hex +from meshterm.ui.tui.render import render_to_ansi from meshterm.ui.widgets import path_text @@ -176,6 +181,122 @@ def test_the_cardputer_draws_chips_in_glyphs_its_font_has(monkeypatch: pytest.Mo assert fold_text(ink) == ink +def _rgb(colour: str) -> tuple[int, int, int]: + return (int(colour[1:3], 16), int(colour[3:5], 16), int(colour[5:7], 16)) + + +def _console_cells(row: Text) -> list[tuple[str, Style]]: + """The cells that the PicoCalc console gets for one row, read back through a VT.""" + palette = tuple(_rgb(slot_hex(n)) for n in range(16)) + term = Terminal(53, 1, palette=palette) + term.feed(render_to_ansi(row, 53, no_wrap=True)) + return term.screen[0] + + +def _selected(line: Text) -> Text: + """``line`` on a selected row, whose base style is the bold ``cursor``.""" + row = Text(style="cursor") + row.append("❯ ") + row.append_text(line) + return row + + +@pytest.fixture +def picocalc(monkeypatch: pytest.MonkeyPatch) -> None: + """The PicoCalc, with the chip verdict that its own font gives.""" + monkeypatch.delenv("MESHTERM_POWERLINE") + set_platform(PICOCALC_LYRA) + + +def test_the_picocalc_draws_chips_from_its_eight_backgrounds(picocalc: None) -> None: + """A node's chip is the dim twin of its hue, and the label is the hue itself. + + The VT draws a background only from slots 0 to 7. Thus each fill is a dim slot, and + each label is the bright slot that the name has in arrow mode. A keyless chip is + white on light grey. Our star and a faded hop stand on the page, because the console + has no dark grey background. The row is a selected row, whose ``bold`` base style + must not move a dim fill to its bright twin: each chip cell arrives without bold. + """ + hops = [ + PathHop(SELF_GLYPH, you=True), + PathHop("Hilltop", key="1c"), + PathHop("Alice", key="0a", annotation="0a"), + PathHop("3d"), + PathHop("Ridge", key="1c", dim=True), + PathHop(SELF_GLYPH, you=True), + ] + line = PathLine(hops).text() # auto: the console font has the chevrons + assert POWERLINE_SEP in line.plain + cells = _console_cells(_selected(line)) + text = "".join(char for char, _ in cells) + dim_slots = {_rgb(slot_hex(n)) for n in range(8)} + for char, style in cells[2 : 2 + len(line.plain)]: + assert style.bg is None or style.bg in dim_slots, f"{char!r} on {style.bg}" + assert not style.flags & vt.BOLD, f"{char!r} is bold, so a dim slot turns bright" + + def at(word: str) -> Style: + return cells[text.index(word)][1] + + yellow, red = _style_hex(node_style("1c")), _style_hex(node_style("0a")) + assert (at("Hilltop").fg, at("Hilltop").bg) == (_rgb(yellow), _rgb(DIM_TWIN[yellow])) + assert (at("Alice").fg, at("Alice").bg) == (_rgb(red), _rgb(DIM_TWIN[red])) + assert at("(0a)").fg == _rgb(red) # the annotation is in the label's colour too + assert (at("3d").fg, at("3d").bg) == (_rgb(slot_hex(15)), _rgb(slot_hex(7))) + assert at("Ridge").bg in (None, _rgb(slot_hex(0))) # faded: on the page + assert at("Ridge").fg == _rgb(slot_hex(8)) + assert at(SELF_GLYPH).bg in (None, _rgb(slot_hex(0))) + + +def test_a_picocalc_hash_label_is_one_colour(picocalc: None) -> None: + """The rest of a hash is in the hue of its lit bytes, not in a grey. + + A grey after the lit byte was not readable on the green and the cyan fills + (JP, 2026-10-06). + """ + for key in ("0a", "1c", "44", "70", "9a", "c4"): + line = PathLine([PathHop(f"{key}63c6", key=key, lit_bytes=1)]).text() + cells = _console_cells(line) + hue = _rgb(_style_hex(node_style(key))) + label = [style.fg for char, style in cells if char in f"{key}63c6" and char != " "] + assert label == [hue] * 6, key + + +def test_picocalc_seams_on_one_colour_and_the_composer_slot(picocalc: None) -> None: + """Two chips of one colour join on a thin chevron in the hue. The slot is a break. + + The thin chevron is the bright twin of the fill: the colour of the label on the + chip. The composer's slot has no white background on the console, so it is a white + ``+`` on the page, and the chips on each side close on a point and open on a notch. + """ + same = PathLine([PathHop("Mill", key="1a"), PathHop("Tower", key="20")]).text() + seam = same.plain.index(POWERLINE_THIN) + cell = _console_cells(same)[seam][1] + yellow = _style_hex(node_style("1a")) + assert (cell.fg, cell.bg) == (_rgb(yellow), _rgb(DIM_TWIN[yellow])) + + slot = [ + PathHop("Hilltop", key="1c"), + PathHop(CURSOR_GLYPH, cursor=True), + PathHop("Alice", key="0a"), + ] + line = PathLine(slot).text() + cells = _console_cells(line) + plus = cells[line.plain.index(CURSOR_GLYPH)][1] + assert plus.fg == _rgb(slot_hex(15)) and plus.bg in (None, _rgb(slot_hex(0))) + assert line.plain.count(POWERLINE_SEP) == 2 # a point into the break, a notch out of it + + +def test_every_picocalc_chip_glyph_is_in_its_font(picocalc: None) -> None: + """Each mark of the chip language is in the console font, so nothing folds to ``?``.""" + hops = [PathHop(SELF_GLYPH, you=True), PathHop("Hilltop", key="1c"), elision_hop()] + line = PathLine([*hops, PathHop("a"), PathHop("b")]).text() + drawn = [line, cut_to(line, 12), cut_mark(line, 1, ELIDE_HEAD)] + ink = "".join(text.plain for text in drawn) + assert {POWERLINE_SEP, POWERLINE_THIN, CRACK_TAIL, CRACK_HEAD, SELF_GLYPH} <= set(ink) + assert all(ord(char) in FONT_CODEPOINTS for char in ink) + assert fold_text(ink) == ink + + def test_the_cursor_is_a_hop_of_its_own_in_either_mode() -> None: """The composer's insertion point is a slot *in* the route, not a gap between hops. diff --git a/tests/test_termfont.py b/tests/test_termfont.py index 587288d..6682c35 100644 --- a/tests/test_termfont.py +++ b/tests/test_termfont.py @@ -256,15 +256,17 @@ def test_powerline_support_kitty_ssh_and_unknown() -> None: def test_powerline_support_a_handheld_answers_from_its_own_font() -> None: """A handheld's font draws the screen, so its inventory answers and no terminal does. - The Cardputer's Terminus carries the core chevrons and no rounded caps; the PicoCalc's - 512-glyph console font has neither, so a kitty that started it changes nothing. The - override still outranks the font, as it outranks every probe. + The Cardputer's Terminus and the PicoCalc's console font both have the core chevrons + and no rounded caps. A font without the chevrons gets ``none``, also when a kitty + started the app. The override still outranks the font, as it outranks each probe. """ kitty = {"TERM": "xterm-kitty"} # a terminal that would otherwise earn core cardputer = _powerline_support(kitty, handheld=("cardputer-zero", CARDPUTER_ZERO_CODEPOINTS)) assert (cardputer.level, cardputer.source) == (CORE, "platform:cardputer-zero") picocalc = _powerline_support(kitty, handheld=("picocalc-lyra", FONT_CODEPOINTS)) - assert (picocalc.level, picocalc.source) == (NONE, "platform:picocalc-lyra") + assert (picocalc.level, picocalc.source) == (CORE, "platform:picocalc-lyra") + bare = _powerline_support(kitty, handheld=("bare", frozenset(range(0x20, 0x7F)))) + assert (bare.level, bare.source) == (NONE, "platform:bare") capped = CARDPUTER_ZERO_CODEPOINTS | {0xE0B4, 0xE0B6} assert _powerline_support({}, handheld=("capped", capped)).level == FULL pinned = {"MESHTERM_POWERLINE": "0"}