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: