From 353dcb7792448c96f8f5d1b7d7b283b033017e3e Mon Sep 17 00:00:00 2001 From: Jean-Pierre Martineau Date: Tue, 6 Oct 2026 03:25:13 -0400 Subject: [PATCH] A path chip is the node's name colour on a darker ground of itself On the desktop and the Cardputer, a chip was the node's hue with dark ink on it, so a node's colour moved from its name to the ground behind it the moment a route drew it. Now the label is the hue, the colour the name has in every list and in arrow mode, and the fill is the same hue at 0.60 of its OKLab lightness (CHIP_LIGHTNESS): the rule the PicoCalc draws with its dim twins, at full resolution. A hash label and an annotation are the label colour throughout, with the lit bytes bold. A keyless chip is the same pair in grey; our star, a faded hop and the composer's white slot keep what they had. The value came from two ladders over twelve hues. A fixed step down in lightness made the blue fills almost black (blue is the wheel's darkest hue), one lightness for every fill made the blue label hard to read on its own ground, and 0.65 / 0.55 were too weak in blue / too dull in yellow. Darkening at constant chroma leaves sRGB, and the clip merged neighbouring greens into one #008000, so oklab.fitted lowers the chroma instead and keeps the hue. A thin seam between two chips of one colour is now drawn in the previous chip's label colour, the light half of its own pair, on both palettes; the shaded-fill rule (SEAM_SHADE) is gone with it. Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 14 +++- meshterm/ui/oklab.py | 36 ++++++++- meshterm/ui/pathline.py | 173 +++++++++++++++++++--------------------- meshterm/ui/widgets.py | 9 +-- tests/test_oklab.py | 19 +++++ tests/test_pathline.py | 85 ++++++++++++-------- tests/test_widgets.py | 10 ++- 7 files changed, 209 insertions(+), 137 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f1d104c..4ce02e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -409,13 +409,23 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow name: the one node the reader never has to be told, and the cells belong to the hops that differ from row to row. As a chip it is the map's yellow star on neutral dark grey, padded like every other chip. +- **A path chip is the node's name colour on a darker ground of itself.** The label is the + hue `node_style` gives the name on every other surface, so a node is one colour in a + list, in arrows and in a chip; the fill is that hue at `pathline.CHIP_LIGHTNESS` of its + OKLab lightness, chroma lowered only as far as sRGB needs (`oklab.fitted`; a clip there + merged neighbouring greens). The PicoCalc is the same rule at its resolution: the fill + is the bright slot's dim twin (`theme.DIM_TWIN`). A hash label and an annotation are + the label colour throughout, never a grey; on a truecolor terminal the lit bytes are + bold. A keyless chip is the same pair in grey; our `★` and a faded hop keep their dark + greys; the composer's slot keeps the white chip (on the PicoCalc, a white `+` in a + break of the ribbon). - Chip seams are **one** interlocked chevron (previous fill on next). Two chips of the same fill are the exception the interlock can't draw — and so are two fills the eye can't tell apart: under `pathline.SEAM_BLUR` apart in OKLab (`ui/oklab.py`, THE perceptual colour distance — never a hue gap, never sRGB, both of which mis-size the greens against the cyans). There the seam is the **thin** chevron (`POWERLINE_THIN`) - in the previous chip's own fill shaded `SEAM_SHADE` darker (lighter for a dark fill), - drawn on the next, so the ribbon runs on unbroken and the join is a line the chip draws + in the previous chip's label colour, drawn on the next, so the ribbon runs on unbroken + and the join is a line the chip draws on itself, never a wedge of page cut out of the route and never a third colour. An elision breaks the ribbon rather than joining it — bare `⋯` on the page between a closing point and the next chip's notch, no fill, no padding. diff --git a/meshterm/ui/oklab.py b/meshterm/ui/oklab.py index 1aaf908..c59c86a 100644 --- a/meshterm/ui/oklab.py +++ b/meshterm/ui/oklab.py @@ -65,17 +65,49 @@ def to_hex(lab: Lab) -> str: clipped colour is the nearest the terminal can show, and it stays the right hue to the eye at the shifts the app asks for. """ + linear = _linear_rgb(lab) + return "#" + "".join(f"{round(_encoded(min(1.0, max(0.0, c))) * 255):02x}" for c in linear) + + +def _linear_rgb(lab: Lab) -> tuple[float, float, float]: + """OKLab → linear sRGB. A channel outside 0 to 1 is outside the sRGB gamut.""" lightness, a, b = lab l_ = lightness + 0.3963377774 * a + 0.2158037573 * b m_ = lightness - 0.1055613458 * a - 0.0638541728 * b s_ = lightness - 0.0894841775 * a - 1.2914855480 * b lms = (l_**3, m_**3, s_**3) - linear = ( + return ( 4.0767416621 * lms[0] - 3.3077115913 * lms[1] + 0.2309699292 * lms[2], -1.2684380046 * lms[0] + 2.6097574011 * lms[1] - 0.3413193965 * lms[2], -0.0041960863 * lms[0] - 0.7034186147 * lms[1] + 1.7076147010 * lms[2], ) - return "#" + "".join(f"{round(_encoded(min(1.0, max(0.0, c))) * 255):02x}" for c in linear) + + +def _in_gamut(lab: Lab) -> bool: + """Whether the sRGB gamut holds ``lab`` (a very small tolerance for rounding).""" + return all(-1e-6 <= channel <= 1 + 1e-6 for channel in _linear_rgb(lab)) + + +def fitted(lab: Lab) -> Lab: + """``lab`` with its chroma lowered until the sRGB gamut holds it. + + The lightness and the hue do not change. A colour that is already in the gamut comes + back unchanged. Use this instead of the clip in :func:`to_hex` when a set of colours + must stay apart: a dark, saturated colour leaves the gamut, and the clip moves many + such colours to one corner of the gamut. For example, two greens a few steps apart + on the hue wheel both clip to ``#008000``. + """ + if _in_gamut(lab): + return lab + lightness, a, b = lab + low, high = 0.0, 1.0 + for _ in range(24): # the chroma scale, to approximately 1e-7 + middle = (low + high) / 2 + if _in_gamut((lightness, a * middle, b * middle)): + low = middle + else: + high = middle + return (lightness, a * low, b * low) def distance(first: str, second: str) -> float: diff --git a/meshterm/ui/pathline.py b/meshterm/ui/pathline.py index 150e693..20e6b62 100644 --- a/meshterm/ui/pathline.py +++ b/meshterm/ui/pathline.py @@ -12,16 +12,17 @@ drawn*, so every surface the survey found can eventually route through it: look, unchanged. ``powerline`` renders each hop as a colour-filled chip, joined by one solid triangle U+E0B0 interlocked between them — the chip behind as its foreground, the 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. (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 + chevron: the oh-my-posh look, at one cell a seam. The label of a node's chip is its + hash-derived hue (:func:`~meshterm.ui.theme.node_style`), the colour that its name has + on each surface, and the fill is that hue, darker (:data:`CHIP_LIGHTNESS`). Our own + node is the map's yellow ``★`` on a neutral dark grey, a faded hop is dark slate, and a + keyless hop is light grey on grey. (On the 16-colour console of the PicoCalc, the fill + is the dim twin of the hue. 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 - draw: the seam is the *thin* chevron instead, the previous fill shaded a step darker - (lighter, for a dark fill) and drawn on the next, so the ribbon runs on unbroken and the + draw: the seam is the *thin* chevron instead, in the colour of the previous chip's + label and drawn on the next, so the ribbon runs on unbroken and the join is a line the chip draws on itself (:meth:`PathLine._seam`). Hops elided out of the middle break the ribbon instead of joining it: the mark sits bare on the page between a closing point and @@ -140,13 +141,43 @@ _ARROW = " → " #: (:func:`~meshterm.ui.tui.render._whiten_identities`) instead of whitening a whole route. PATH_INK = "pathline" -#: Chip ink: near-black slate for readable text on every spectrum hue and on white. +#: The lightness of a node's chip, as a part of the lightness of its hue (OKLab ``L``). +#: On a truecolor terminal, the fill of a node's chip is its hue with ``L`` multiplied by +#: this value, and the label on it is the hue itself: the colour that the name has in each +#: list and in arrow mode. Thus a node is one colour on each surface, and a chip only puts +#: a darker ground of that colour under it. JP chose this look on the PicoCalc +#: (2026-10-06), where the dim twin of a slot is the same rule at the resolution of the +#: console (:data:`~meshterm.ui.theme.DIM_TWIN`). Its dim twins have 0.55 to 0.73 of the +#: lightness of their bright slots. +#: +#: The value came from two ladders of rules over twelve hues. A fixed step down in ``L`` +#: made the blue fills almost black, because the blue hue is the darkest on the wheel. +#: One ``L`` for all fills made the blue label hard to read on its fill. ``0.65`` made the +#: blue label weak, and ``0.55`` made the yellow fills dull. The fill also keeps its hue +#: when the darker colour leaves the sRGB gamut: the chroma comes down, refer to +#: :func:`~meshterm.ui.oklab.fitted`. A clip there made the greens near ``0x50`` one colour. +CHIP_LIGHTNESS = 0.60 + + +def _chip_fill(colour: str) -> str: + """The fill of a chip whose label is ``colour``: the same hue, darker.""" + lightness, a, b = oklab.from_hex(colour) + return oklab.to_hex(oklab.fitted((lightness * CHIP_LIGHTNESS, a, b))) + + +def _chip_label(fill: str) -> str: + """The label for a chip whose fill is ``fill``: the reverse of :func:`_chip_fill`.""" + lightness, a, b = oklab.from_hex(fill) + return oklab.to_hex(oklab.fitted((min(1.0, lightness / CHIP_LIGHTNESS), a, b))) + + +#: The text of the composer's slot: near-black slate on the white chip. _CHIP_FG = "#0f172a" -#: Chip ink, softened — annotations and a hash label's unlit tail (the two-tone). -_CHIP_FG_SOFT = "#334155" #: Chip fill for a keyless hop — the ``faint`` grey; colour stays reserved for #: keyed identities in chips just as it is in arrow mode. _KEYLESS_BG = "#64748b" +#: The label of a keyless chip: the same pair as a node's chip, in grey. +_KEYLESS_FG = _chip_label(_KEYLESS_BG) #: Our own node's chip fill: a neutral dark grey, so the yellow ``★`` riding it reads as #: the map's marker rather than as a node's hue (JP, 2026-08-09). Deliberately *not* a #: spectrum colour and not the white it used to be — our end of a route is a fixture the @@ -166,9 +197,6 @@ _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): @@ -313,28 +341,12 @@ def _style_hex(style: str) -> str | None: #: doesn't; the two greys, ``0.18`` apart, keep their interlock on the metric alone. SEAM_BLUR = 0.06 -#: How far the thin chevron's colour steps from the fill it is drawn in, in OKLab ``L`` -#: — a lightness shift alone, so it reads as *that chip's colour, shaded* rather than as -#: a third colour on the line. The step is taken from the previous fill but measured from -#: whichever of the two fills is further along (:func:`~meshterm.ui.oklab.shaded`'s -#: ``floor``), so the mark clears the fill it actually sits on by this much even when the -#: two differ by everything :data:`SEAM_BLUR` allows. A lightness step is what a thin line -#: is seen by (the eye resolves detail in luminance, not in chroma), so it needs nothing -#: like the blur distance to read: ``0.10`` is five noticeable differences, a clear line -#: that is still plainly the chip's own colour, chosen on the same ladder (JP, 2026-09-16). -SEAM_SHADE = 0.10 - def _fills_blur(before: str, after: str) -> bool: """Whether two fills are the same to the eye: identical, or under :data:`SEAM_BLUR`.""" return before == after or oklab.distance(before, after) < SEAM_BLUR -def _seam_ink(before: str, after: str) -> str: - """The thin chevron's colour: ``before`` shaded :data:`SEAM_SHADE` past both fills.""" - return oklab.shaded(before, SEAM_SHADE, floor=oklab.from_hex(after)[0]) - - #: A chip's fill, as it appears in a rendered span's style — the ``on #rrggbb`` half. #: Only :meth:`PathLine._chip` sets a background, which is what makes this the test for #: "is there a chip here": a seam's point, a notch, and every arrow-mode span carry a @@ -1055,18 +1067,16 @@ class PathLine: one shade of green on the next, so the test is a perceptual distance under :data:`SEAM_BLUR` (:func:`_fills_blur`), not equality and not a hue gap. - There the seam is the **thin** chevron, :data:`POWERLINE_THIN`, in the previous - chip's own fill shaded a step darker — lighter, for a dark fill — and drawn on - the fill ahead (:func:`_seam_ink`, JP, 2026-09-16): powerline's own mark for a - join inside one colour, and the chip it belongs to drawing a line on itself, so - the ribbon runs on unbroken and nothing on it is a colour that isn't a chip's. - The solid point drawn bare used to cut a wedge of page out of the route here (a - run of near hues read as dashes, and on a light terminal the wedge was a hole), - 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`). + There the seam is the **thin** chevron, :data:`POWERLINE_THIN`, drawn on the fill + ahead in the colour of the previous chip's label: powerline's own mark for a join + inside one colour, and the chip it belongs to drawing a line on itself, so the + ribbon runs on unbroken and nothing on it is a colour that isn't a chip's. The + solid point drawn bare used to cut a wedge of page out of the route here (a run of + near hues read as dashes, and on a light terminal the wedge was a hole). The thin + chevron was then the previous fill, a lightness step darker (JP, 2026-09-16). When + the label became the node's hue on a darker fill of itself (2026-10-06), the label + colour became the line: it is the light half of the same pair, so it is visible + on each fill and is still the chip's own colour. One cell either way. Args: before: The colours of the previous chip. The point is drawn in its fill. @@ -1076,8 +1086,7 @@ class PathLine: The seam's single styled cell. """ 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_THIN, style=f"{_FLAT}{before.ink} on {after.fill}") return Text(POWERLINE_SEP, style=f"{_FLAT}{before.fill} on {after.fill}") @staticmethod @@ -1128,38 +1137,38 @@ class PathLine: return text -def _spectrum_fill(hop: PathHop) -> str: - """A chip's fill: cursor accent, override, dim slate, our grey, hue, keyless grey. +def _spectrum_colours(hop: PathHop) -> _ChipColours: + """The colours of a chip on a truecolor terminal: the hue on a darker fill of itself. + + A node's label is its hue: the colour that its name has in each list and in arrow + mode. The fill is that hue, darker (:data:`CHIP_LIGHTNESS`). The whole + label is in the hue, a hash label and an annotation too, and the lit bytes of a hash + are bold. A keyless chip is the same pair in grey. Our ``★`` and a faded hop keep their + dark greys, which already carry a light label. The PicoCalc draws the same rules at + the resolution of its console (:func:`_slot_colours`). 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. + than as a hop with an unlucky hue (white sits outside the node spectrum, and its + dark text stays readable on it). It is the only light chip on the ribbon. 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 + return _ChipColours(_style_hex("cursor") or _KEYLESS_BG, _CHIP_FG, _CHIP_FG) + resolved = _style_hex(hop.style) if hop.style else None + if resolved: + return _ChipColours(_chip_fill(resolved), resolved, resolved) if hop.dim: - return _DIM_BG + return _ChipColours(_DIM_BG, _DIM_FG, _DIM_FG) 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) + return _ChipColours(_YOU_BG, _SELF_INK, _SELF_INK) + hue = _style_hex(node_style(hop.key)) if hop.key else None + if hue: + return _ChipColours(_chip_fill(hue), hue, hue) + return _ChipColours(_KEYLESS_BG, _KEYLESS_FG, _KEYLESS_FG) def _slot_colours(hop: PathHop) -> _ChipColours: @@ -1177,7 +1186,7 @@ def _slot_colours(hop: PathHop) -> _ChipColours: 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`). + (:func:`_spectrum_colours`). """ if hop.cursor: return _ChipColours(_SLOT_PAGE, _SLOT_WHITE, _SLOT_WHITE) @@ -1196,23 +1205,9 @@ def _slot_colours(hop: PathHop) -> _ChipColours: 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`). +#: The colours of a chip, 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. @@ -1228,13 +1223,11 @@ def _bind_chips(platform: Platform) -> None: 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 + global _colours_of, _WEIGHT, _FLAT if platform.truecolor: - _colours_of, _thin_ink = _spectrum_colours, _spectrum_thin_ink - _WEIGHT, _FLAT = "bold ", "" + _colours_of, _WEIGHT, _FLAT = _spectrum_colours, "bold ", "" else: - _colours_of, _thin_ink = _slot_colours, _slot_thin_ink - _WEIGHT = _FLAT = "not bold " + _colours_of, _WEIGHT, _FLAT = _slot_colours, "not bold ", "not bold " def _shorten(value: str, hash_bytes: int | None) -> str: diff --git a/meshterm/ui/widgets.py b/meshterm/ui/widgets.py index a69a4df..6c0cf6a 100644 --- a/meshterm/ui/widgets.py +++ b/meshterm/ui/widgets.py @@ -557,14 +557,13 @@ def name_chip(label: str, key: str | None = None, *, you: bool = False) -> Text: :class:`~meshterm.ui.pathline.PathLine` and asks it for its line, so the fill, the ink, the padding, the rounded cap and the closing point are all whatever a route's segments are wearing today, and can never drift from them (JP, 2026-08-12). The colour - reads exactly as it does in a route — the *fill* carries the node's hue with dark ink - on it, a node no key can place takes the keyless grey, and our own end is the map's - ``★`` on its neutral dark grey rather than a name. + reads exactly as it does in a route. The name is in the node's hue, on a darker fill + of that hue. A node that no key can place takes the keyless grey, and our own end is + the map's ``★`` on its neutral dark grey instead of a name. Degrading is inherited too: where the terminal can't draw powerline separators, a path line is arrow-mode text and a lone hop is simply the name in its own hue — which is - what a sender label was before it was framed, and what the PicoCalc console (whose font - carries no separator glyph) goes on drawing. + what a sender label was before it was framed. Args: label: The name as it should read (already resolved; this never renames it). diff --git a/tests/test_oklab.py b/tests/test_oklab.py index d02d15f..3bf6e9e 100644 --- a/tests/test_oklab.py +++ b/tests/test_oklab.py @@ -61,6 +61,25 @@ def test_shaded_floor_clears_a_second_colour_too() -> None: assert oklab.from_hex(floored)[0] >= oklab.from_hex(oklab.shaded(slate, 0.15))[0] +def test_fitted_lowers_chroma_alone_and_keeps_near_hues_apart() -> None: + """A colour outside the gamut comes back inside it, with its lightness and hue. + + A colour in the gamut comes back unchanged. The clip in ``to_hex`` gave two dark + greens near ``0x50`` almost one colour. After the fit, they are clearly two colours. + """ + inside = oklab.from_hex(_fill("80")) + assert oklab.fitted(inside) == inside + darker = [oklab.from_hex(_fill(key)) for key in ("4c", "5c")] + outside = [(lab[0] * 0.6, lab[1], lab[2]) for lab in darker] + fits = [oklab.fitted(lab) for lab in outside] + clipped = oklab.distance(*(oklab.to_hex(lab) for lab in outside)) + assert oklab.distance(*(oklab.to_hex(lab) for lab in fits)) > 2 * clipped + for before, after in zip(outside, fits, strict=True): + assert after[0] == before[0] + assert after[1] / before[1] == pytest.approx(after[2] / before[2]) # the same hue + assert abs(after[1]) < abs(before[1]) # less chroma + + def test_from_hex_rejects_anything_but_six_digits() -> None: """Six hex digits, ``#`` and case optional; anything else is a ``ValueError``.""" with pytest.raises(ValueError): diff --git a/tests/test_pathline.py b/tests/test_pathline.py index fdf7e11..8186679 100644 --- a/tests/test_pathline.py +++ b/tests/test_pathline.py @@ -22,8 +22,10 @@ from meshterm.ui import oklab from meshterm.ui.fontset import FONT_CODEPOINTS from meshterm.ui.pathline import ( _DIM_BG, + _DIM_FG, _SELF_INK, _YOU_BG, + CHIP_LIGHTNESS, CRACK_HEAD, CRACK_TAIL, CURSOR_GLYPH, @@ -35,12 +37,10 @@ from meshterm.ui.pathline import ( POWERLINE_SEP, POWERLINE_THIN, SEAM_BLUR, - SEAM_SHADE, SELF_GLYPH, WRAP_OFFSET, PathHop, PathLine, - _seam_ink, _style_hex, cut_mark, cut_to, @@ -54,6 +54,11 @@ from meshterm.ui.tui.render import render_to_ansi from meshterm.ui.widgets import path_text +def _fill(key: str) -> str: + """The fill of a node's chip on a truecolor terminal: its hue, darker.""" + return pathline._chip_fill(_style_hex(node_style(key))) + + def _styles(text) -> dict[str, str]: # noqa: ANN001 """Map each styled slice of a Text to its style string, for spot checks.""" return {text.plain[s.start : s.end]: str(s.style) for s in text.spans} @@ -132,7 +137,7 @@ def test_chips_are_joined_by_one_interlocked_chevron() -> None: the route continues, so a finished path never wears one. It ends square on the last chip's own pad, and the only chevrons in the line are the joins. """ - alice_fill = node_style("aa").split()[-1] + alice_fill = _fill("aa") line = PathLine([PathHop("Alice", key="aa"), PathHop("you", you=True)], mode="powerline") text = line.text() assert text.plain == f" Alice {POWERLINE_SEP} you " @@ -143,16 +148,37 @@ def test_chips_are_joined_by_one_interlocked_chevron() -> None: def test_chips_keep_the_same_words_and_honour_style_overrides() -> None: """Chips change colours and separators, never the words. - An explicit style override (hex or theme name) becomes the chip fill. + An explicit style override (hex or theme name) becomes the chip's colour: the label + is in it, on a darker fill of it, as a node's hue is. """ hops = [PathHop("Hub", key="3d", annotation="3d"), PathHop("you", you=True)] plain = PathLine(hops, mode="plain").text().plain chips = PathLine(hops, mode="powerline").text().plain assert plain.replace(" → ", " ") == chips.replace(POWERLINE_SEP, "").replace(" ", " ").strip() themed = PathLine([PathHop("X", style="brand")], mode="powerline").text() - assert pathline._fills(themed)[-1] == "#5eead4" # the chip runs to the very last cell + brand = pathline._chip_fill("#5eead4") + assert pathline._fills(themed)[-1] == brand # the chip runs to the very last cell + assert any(str(s.style) == f"bold #5eead4 on {brand}" for s in themed.spans) hexed = PathLine([PathHop("X", style="bold #123456")], mode="powerline").text() - assert pathline._fills(hexed)[-1] == "#123456" + assert pathline._fills(hexed)[-1] == pathline._chip_fill("#123456") + + +def test_a_chip_is_the_name_colour_on_a_darker_fill_of_it() -> None: + """A node's label is its hue, the colour of its name on each surface. + + The fill is the same hue, darker (``CHIP_LIGHTNESS`` of its OKLab lightness). A hash + label and an annotation are in the hue too, with only the lit bytes in bold. Where + the darker colour leaves the sRGB gamut, the chroma comes down and the hue stays, so + two greens a few steps apart on the wheel keep two fills. + """ + hue = _style_hex(node_style("3d")) + line = PathLine([PathHop("3d63c6", key="3d", lit_bytes=1, annotation="3d")], mode="powerline") + styles = {line.text().plain[s.start : s.end]: str(s.style) for s in line.text().spans} + assert styles["3d"] == f"bold {hue} on {_fill('3d')}" + assert styles["63c6"] == styles[" (3d)"] == f"{hue} on {_fill('3d')}" + fill = oklab.from_hex(_fill("3d")) + assert fill[0] == pytest.approx(oklab.from_hex(hue)[0] * CHIP_LIGHTNESS, abs=0.01) + assert _fill("4c") != _fill("5c") # a clip made both #008000 def test_auto_mode_follows_the_terminal_verdict(monkeypatch: pytest.MonkeyPatch) -> None: @@ -374,7 +400,7 @@ def test_cut_to_cracks_a_chip_in_its_own_fill_instead_of_ellipsizing() -> None: assert fitted.plain.endswith(CRACK_TAIL) assert "…" not in fitted.plain # The cut lands inside ``BBBB``, so the crack wears BBBB's hue, not its neighbours'. - assert str(fitted.spans[-1].style) == _style_hex(node_style("22bb")) + assert str(fitted.spans[-1].style) == _fill("22bb") assert cut_to(full, 200) is full # fits → untouched, no copy, no mark @@ -399,13 +425,13 @@ def test_cut_mark_mirrors_itself_and_reads_the_visible_side() -> None: body = full.plain.index("AAAA") # inside the first chip, either way you scan head, tail = cut_mark(full, body, ELIDE_HEAD), cut_mark(full, body, ELIDE_TAIL) assert (head.plain, tail.plain) == (CRACK_HEAD, CRACK_TAIL) - assert str(head.style) == str(tail.style) == _style_hex(node_style("11aa")) + assert str(head.style) == str(tail.style) == _fill("11aa") # A cut landing on the interlocked seam itself cracks in the field that cell carries — # the chip *ahead*, whose fill is literally the seam's background — so the mark reads # as the next segment beginning and being sheared, not as a colour off the line. seam = full.plain.index(POWERLINE_SEP) - assert str(cut_mark(full, seam, ELIDE_TAIL).style) == _style_hex(node_style("22bb")) + assert str(cut_mark(full, seam, ELIDE_TAIL).style) == _fill("22bb") def test_cut_mark_falls_back_to_the_ellipsis_off_a_chip() -> None: @@ -665,7 +691,7 @@ def test_wrapped_chip_lines_open_on_the_break_they_continue() -> None: # …past the line's own PATH_INK stamp, which covers the whole body from the same cell # (it marks the run as a path line for the cursor fold and draws nothing). carried = next(s for s in lines[1].spans if s.start == step and str(s.style) != PATH_INK) - assert str(carried.style) == f"{_style_hex(node_style('3d'))} reverse" + assert str(carried.style) == f"{_fill('3d')} reverse" assert lines[0].plain.endswith(POWERLINE_SEP) # …the path runs on past this line assert lines[-1].plain.endswith(" ") # …and stops square on the last chip's pad assert all(line.cell_len <= 20 for line in lines) @@ -722,7 +748,7 @@ def test_a_line_drawing_only_a_route_middle_wears_the_chevron_at_that_end(monkey assert text.plain.startswith(POWERLINE_SEP + " AAAA") # the notch, not the lozenge assert text.plain.endswith("BBBB " + POWERLINE_SEP) # the point: the route goes on opening = next(s for s in text.spans if s.start == 0 and str(s.style) != PATH_INK) - assert str(opening.style) == f"{_style_hex(node_style('aa'))} reverse" # cut out of AAAA + assert str(opening.style) == f"{_fill('aa')} reverse" # cut out of AAAA # One end at a time: the half that *is* an endpoint keeps its cap either way. head = PathLine(hops, mode="powerline", to_destination=False).text().plain @@ -794,14 +820,14 @@ def _seams(*hops: PathHop) -> list[tuple[str, str]]: ] -def test_a_seam_between_two_of_one_colour_is_the_thin_chevron_shaded() -> None: - """Where two chips land on the same fill, the seam is the thin chevron in that fill, shaded. +def test_a_seam_between_two_of_one_colour_is_the_thin_chevron_in_its_label() -> None: + """Where two chips land on the same fill, the seam is the thin chevron in the label colour. The interlock can't draw a solid point in its own background, so the join is drawn *on* the shared fill instead — powerline's own mark for a join inside one colour — - and the ribbon runs on unbroken. Its colour is the previous chip's own, a lightness - step darker, so nothing on the line is a colour that isn't a chip's; a dark fill (the - faded slate) steps lighter instead. + and the ribbon runs on unbroken. Its colour is the label of the previous chip: the + light half of the chip's own pair, so it is visible on the fill, and nothing on the + line is a colour that is not a chip's. The faded slate draws it in its muted text. Not a rare accident, either: a mirrored return leg is a run of identically faded hops by construction, and a stretch of keyless hops shares one grey. Either way it stays a @@ -809,21 +835,15 @@ def test_a_seam_between_two_of_one_colour_is_the_thin_chevron_shaded() -> None: """ hue = _style_hex(node_style("aa")) glyph, style = _seams(PathHop("A", key="aa"), PathHop("B", key="aa"))[0] - assert glyph == POWERLINE_THIN and style == f"{_seam_ink(hue, hue)} on {hue}" - shade = _seam_ink(hue, hue) - assert oklab.from_hex(shade)[0] < oklab.from_hex(hue)[0] # darker… - assert oklab.distance(shade, hue) == pytest.approx(SEAM_SHADE, abs=0.01) # …by the step - assert oklab.distance(shade, hue) < oklab.distance(hue, _style_hex(node_style("77"))) + assert (glyph, style) == (POWERLINE_THIN, f"{hue} on {_fill('aa')}") assert _seams(PathHop("A", key="aa"), PathHop("B", key="77"))[0] == ( POWERLINE_SEP, - f"{hue} on {_style_hex(node_style('77'))}", + f"{_fill('aa')} on {_fill('77')}", ) # blended, one cell glyph, style = _seams(PathHop("A", dim=True), PathHop("B", dim=True))[0] - assert glyph == POWERLINE_THIN and style.endswith(f" on {_DIM_BG}") - lighter = style.split()[0] - assert oklab.from_hex(lighter)[0] > oklab.from_hex(_DIM_BG)[0] # a dark fill steps up + assert (glyph, style) == (POWERLINE_THIN, f"{_DIM_FG} on {_DIM_BG}") def test_a_seam_blurs_by_perceived_distance_not_by_hue_gap() -> None: @@ -831,19 +851,16 @@ def test_a_seam_blurs_by_perceived_distance_not_by_hue_gap() -> None: The eye, not the wheel: sixteen first-byte steps across the greens are one colour to a reader and sixteen across the reds are two, so the same hue gap blurs on one side - of the wheel and interlocks on the other. The greys follow the same metric with no - exemption: the keyless grey and the faded slate sit well apart and keep their seam. + of the wheel and interlocks on the other. The test measures the fills, because the + solid point is a fill on a fill. The greys follow the same metric with no exemption: + the keyless grey and the faded slate sit well apart and keep their seam. """ green = _seams(PathHop("A", key="4c"), PathHop("B", key="5c"))[0] red = _seams(PathHop("A", key="00"), PathHop("B", key="10"))[0] assert green[0] == POWERLINE_THIN and red[0] == POWERLINE_SEP - assert oklab.distance(_style_hex(node_style("4c")), _style_hex(node_style("5c"))) < SEAM_BLUR - assert oklab.distance(_style_hex(node_style("00")), _style_hex(node_style("10"))) > SEAM_BLUR - - # The shade is taken from the chip behind but clears the chip ahead as well. - before, after = _style_hex(node_style("4c")), _style_hex(node_style("5c")) - assert green[1] == f"{_seam_ink(before, after)} on {after}" - assert oklab.distance(_seam_ink(before, after), after) >= SEAM_SHADE - 1e-6 + assert oklab.distance(_fill("4c"), _fill("5c")) < SEAM_BLUR + assert oklab.distance(_fill("00"), _fill("10")) > SEAM_BLUR + assert green[1] == f"{_style_hex(node_style('4c'))} on {_fill('5c')}" wrapped = _seams(PathHop("A", key="fc"), PathHop("B", key="02"))[0] assert wrapped[0] == POWERLINE_THIN # neighbours across the wheel's seam, too diff --git a/tests/test_widgets.py b/tests/test_widgets.py index f9c5372..1d4382f 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -17,6 +17,7 @@ from meshterm.ui.pathline import ( POWERLINE_ROUND_OPEN, PathHop, PathLine, + _chip_fill, ) from meshterm.ui.theme import node_style from meshterm.ui.widgets import ( @@ -242,15 +243,16 @@ def test_name_chip_is_a_path_line_of_one_hop() -> None: ] -def test_name_chip_wears_the_hue_as_its_fill_padded_like_every_chip(powerline) -> None: - """A route's colour scheme: the node's hue fills the chip, the ink on it is dark.""" +def test_name_chip_wears_the_hue_as_its_label_padded_like_every_chip(powerline) -> None: + """A route's colour scheme: the name is in its hue, on a darker fill of that hue.""" powerline(True) chip = name_chip("Alice", "a1b2") assert chip.plain.strip(POWERLINE_ROUND_OPEN + POWERLINE_ROUND_CLOSE) == " Alice " ink = _span_style(chip, "Alice") - assert ink.bgcolor.name == node_style("a1b2").removeprefix("bold ") # the hue - assert ink.color.name == "#0f172a" # the chip ink every hop is lettered in + hue = node_style("a1b2").removeprefix("bold ") + assert ink.color.name == hue # the colour of the name on each surface + assert ink.bgcolor.name == _chip_fill(hue) # the same hue, darker def test_name_chip_draws_us_as_the_star_never_as_our_name(powerline) -> None: