diff --git a/CHANGELOG.md b/CHANGELOG.md index 10d3ebe..872ab6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,12 @@ can change how things behave. It does not only add new things. chat. Before, your messages showed only the `★`. - **Your node's star is white.** The `★` that marks your node on the map, in routes, in lists, and in a chat is white, the same white as your name in the lists. Before, it was - yellow. A star in a faded part of a route stays grey. + yellow. +- **The faded part of a route keeps the colours of its nodes.** The part of a route that + you do not choose, for example the mirrored way back of a trace, shows each node in the + colour of its key, but less vivid. Thus you can still see which node each one is. + Before, each of these nodes was the same grey box. When a route is drawn with arrows, + the faded part stays grey. - **In a chat, a name that MeshTerm cannot match to a node is black on grey.** The box around a sender or a mention with no known node has black text on a grey box. Thus it does not stand out among the coloured names. Before, it was light blue on slate. diff --git a/CLAUDE.md b/CLAUDE.md index e04c792..720550c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -423,7 +423,8 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow `marks.SELF_MARK` carries the theme name `you` — the white of our name — so the console picks its own white, and a raster resolves it through `mark_rgb`. It was the map's yellow, then briefly our key's hue (set from the self-info at run time), and both were - dropped. A star something *mutes* keeps its grey — a faded hop, a dimmed return leg. + dropped. In arrows, a star something *mutes* keeps its `faint` grey — a faded hop, a + dimmed return leg; a faded *chip* only loses chroma, and white has none to lose. - Path cuts: a path line drawn as **chips** that gets cut — a lane that ran out, a row scrolled past its edge — breaks the chip off on a half block in that chip's own fill (`▐` ends a line, `▌` opens one; `pathline.cut_mark`/`cut_to`), so half the cell is @@ -443,8 +444,12 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow 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 + bold. A keyless chip is the same pair in grey; our `★` keeps its dark grey; a **faded + hop** (a mirrored return leg, the return home: what the user doesn't compose) is its own + chip at `pathline.FADE_CHROMA` of its chroma, same lightness, so it still says which node + it is (JP, 2026-10-14; it was one dark slate) — the PicoCalc, with no colour of less + chroma, keeps its label colour and drops its fill; arrows keep `faint`; 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 diff --git a/meshterm/ui/oklab.py b/meshterm/ui/oklab.py index 75def80..13738f1 100644 --- a/meshterm/ui/oklab.py +++ b/meshterm/ui/oklab.py @@ -121,6 +121,16 @@ def distance(first: str, second: str) -> float: return math.dist(from_hex(first), from_hex(second)) +def desaturated(colour: str, keep: float) -> str: + """``colour`` with its chroma multiplied by ``keep``. The lightness and the hue do not change. + + ``1`` returns the colour, and ``0`` returns the grey of the same lightness. Less chroma + at the same lightness always stays in the sRGB gamut, because the grey is in it. + """ + lightness, a, b = from_hex(colour) + return to_hex((lightness, a * keep, b * keep)) + + def shaded(colour: str, by: float, *, floor: float | None = None) -> str: """``colour`` moved ``by`` in lightness, away from the end of the scale that is nearer. diff --git a/meshterm/ui/pathline.py b/meshterm/ui/pathline.py index 62da824..802d1a4 100644 --- a/meshterm/ui/pathline.py +++ b/meshterm/ui/pathline.py @@ -17,9 +17,10 @@ surface that the survey found can in the end use it. look, and each seam uses one cell. The label of the chip of a node has the hue that comes from its hash (:func:`~meshterm.ui.theme.node_style`). This is the colour that its name has on each surface. The fill is that hue, but darker (:data:`CHIP_LIGHTNESS`). Our - node is the white ``★`` of the map on a neutral dark grey. A faded hop is dark slate. 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`.) + node is the white ``★`` of the map on a neutral dark grey. A faded hop has the colours + of its own chip, with less chroma (:data:`FADE_CHROMA`). 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`.) Sometimes two chips have the *same* fill (a mirrored return leg, or a stretch of keyless greys). Sometimes they have two fills that are too close to tell apart (less @@ -87,7 +88,8 @@ surface that the survey found can in the end use it. ``★`` of the app. The user already knows who both ends are, and the cells are for the hops that *are* news. These stars fade where the route is being *composed*, because our two ends are fixtures and not choices. A surface that only *shows* a walk passes - ``dim_self=False`` and keeps our stars in the ``you`` white. + ``dim_self=False`` and keeps our stars in the ``you`` white. (Only arrows show the + difference. A faded chip loses chroma, and the white star has no chroma to lose.) The existing call sites still render through ``path_text``. A separate pass for each surface will move them here. The insertion cursor of the composer is a hop in its own @@ -101,7 +103,7 @@ from __future__ import annotations import re from collections.abc import Callable, Sequence -from dataclasses import dataclass +from dataclasses import dataclass, replace from typing import NamedTuple from rich.cells import cell_len @@ -215,18 +217,19 @@ _WHITE = "#ffffff" #: route is a fixture that the user already knows. It stays back and lets the hops that #: differ carry the colour. _YOU_BG = "#3f3f46" -#: The chip of a dimmed hop: a dark slate fill with muted ink. It recedes like ``faint`` -#: text. -_DIM_BG = "#334155" -_DIM_FG = "#94a3b8" +#: The part of its chroma (OKLab ``a`` and ``b``) that the chip of a faded hop keeps. A +#: faded hop is a hop that the user does not compose: a mirrored return leg, or the +#: automatic return home. Its chip has the colours that it has with no fade, with less +#: chroma and the same lightness. Thus each faded hop still says which node it is, and the +#: automatic part of the route recedes (JP, 2026-10-14). Before, each faded hop was the same +#: dark slate chip. +FADE_CHROMA = 0.35 #: 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 slot of the composer. 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 slot of the #: composer. _SLOT_WHITE = slot_hex(15) @@ -305,7 +308,8 @@ class PathHop: lit_bytes: For a hash label: the leading *bytes* that are drawn in the hue (the two tones of ``highlighted_hash``). The rest is muted or soft. ``0`` for names. dim: Fade the hop (and the arrow that leads into it). It is the resolved return leg - of a planned route, which the user does not compose. + of a planned route, which the user does not compose. A chip keeps its colours, + with less chroma (:data:`FADE_CHROMA`). Arrows draw the hop ``faint``. style: An explicit colour override. It is a hex style (``"#rrggbb"``, attributes are permitted) or a theme name. It is for colourings of a context that win over the identity. Plain mode uses it as the label style, and powerline uses it as the @@ -1216,18 +1220,19 @@ def _spectrum_colours(hop: PathHop) -> _ChipColours: The label of a node is its hue. This is the colour that its name has in each list and in arrow mode. The fill is that hue, but darker (:data:`CHIP_LIGHTNESS`). The whole label is in the hue, also a hash label and an annotation. 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`). + bold. A keyless chip is the same pair in grey. Our ``★`` keeps its dark grey, which + already carries a light label. The PicoCalc draws the same rules at the resolution of + its console (:func:`_slot_colours`). The insertion slot wins over all other rules. It is the one chip that is not a node, and it has the ``cursor`` white. Thus it reads as chrome among identities, and not as a hop with a hue that is unlucky. (White is outside the node spectrum, and its dark text stays - readable on it.) It is the only light chip on the ribbon. The fade is next, and it wins - over the identity, also our own. A dimmed hop is a hop that nobody composed (an - automatic return home, a mirrored return leg). Our end must recede with the rest of - that automatic half, and not keep its hue among the greys. Plain mode says the same - thing when it fades the name. + readable on it.) It is the only light chip on the ribbon. The fade is next. A dimmed + hop is a hop that nobody composed (an automatic return home, a mirrored return leg). It + has the colours that it has with no fade, with less chroma (:data:`FADE_CHROMA`). Thus + each faded hop still says which node it is, and that automatic half recedes (JP, + 2026-10-14). Our white star has no chroma, so its chip does not change. Plain mode + draws the same hops ``faint``. Our chip in a chat (``white_ink``) has the fill of the chip of its key, and a white label. A quiet chip (a node with no key in a chat) is black on a grey fill. @@ -1238,7 +1243,8 @@ def _spectrum_colours(hop: PathHop) -> _ChipColours: if resolved: return _ChipColours(_chip_fill(resolved), resolved, resolved) if hop.dim: - return _ChipColours(_DIM_BG, _DIM_FG, _DIM_FG) + normal = _spectrum_colours(replace(hop, dim=False)) + return _ChipColours(*(oklab.desaturated(c, FADE_CHROMA) for c in normal)) hue = _style_hex(node_style(hop.key)) if hop.key else None if hop.white_ink and hue: return _ChipColours(_chip_fill(hue), _WHITE, _WHITE) @@ -1263,10 +1269,10 @@ def _slot_colours(hop: PathHop) -> _ChipColours: A keyless chip is white on light grey, the same pair one bank down. Our ``★``, a faded hop, and the slot of the composer 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 our - hue, a faded hop in the dark grey, and 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_colours`). + dark grey background. They stand on the page (:data:`_SLOT_PAGE`): the white star, and + the slot as a white ``+`` in a break of the ribbon. The console also has no colour with + less chroma. Thus a faded hop keeps the colour of its label and loses its fill. The + order of the rules is the same as on a truecolor terminal (:func:`_spectrum_colours`). Our chip in a chat (``white_ink``) is white on the dim twin of its hue, the same rule as on a truecolor terminal. A quiet chip is black on the light grey of the keyless chip. @@ -1280,7 +1286,7 @@ def _slot_colours(hop: PathHop) -> _ChipColours: 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) + return _slot_colours(replace(hop, dim=False))._replace(fill=_SLOT_PAGE) if hop.you: return _ChipColours(_SLOT_PAGE, _SLOT_WHITE, _SLOT_WHITE) hue = _style_hex(node_style(hop.key)) if hop.key else None @@ -1379,9 +1385,10 @@ def path_line( between them need. dim_self: Fade the bare stars (the default). On a surface where the user *composes* the route, to set out from us and to come home to us are fixtures. The user does - not choose or remove them, the same as for a mirrored return leg. Thus they have - the same automatic grey, and the colour on the line is for the nodes that the - user really picks. Pass ``False`` where nobody composes anything (a record of a + not choose or remove them, the same as for a mirrored return leg. Thus they fade + as that leg does. (Arrows draw them ``faint``. A faded chip loses chroma, and our + white star has no chroma to lose, so its chip does not change.) Pass ``False`` + where nobody composes anything (a record of a walk that is already made). Then there is no "not yours" to say, and our node reads in the ``you`` white of the app, as in other places. A ``dim_from`` that reaches a star still fades it in both cases. diff --git a/tests/test_pathline.py b/tests/test_pathline.py index 2c6bb76..a6a6f3c 100644 --- a/tests/test_pathline.py +++ b/tests/test_pathline.py @@ -9,6 +9,7 @@ cells. Thus these tests assert rendered strings and span styles, and not impress from __future__ import annotations +import math import random import pytest @@ -21,8 +22,6 @@ 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, - _DIM_FG, _YOU_BG, CHIP_LIGHTNESS, CRACK_HEAD, @@ -30,6 +29,7 @@ from meshterm.ui.pathline import ( CURSOR_GLYPH, ELIDE_HEAD, ELIDE_TAIL, + FADE_CHROMA, PATH_INK, POWERLINE_ROUND_CLOSE, POWERLINE_ROUND_OPEN, @@ -279,7 +279,7 @@ def test_the_picocalc_draws_chips_from_its_eight_backgrounds(picocalc: None) -> assert at("(0a)").fg == _rgb(red) # the annotation also has the colour of the label 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("Ridge").fg == _rgb(yellow) # in the colour of its label, with no fill assert at(SELF_GLYPH).bg in (None, _rgb(slot_hex(0))) @@ -856,8 +856,33 @@ def test_a_seam_between_two_of_one_colour_is_the_thin_chevron_in_its_label() -> f"{_fill('aa')} on {_fill('77')}", ) # a blend, one cell + faded = pathline._spectrum_colours(PathHop("B", dim=True)) glyph, style = _seams(PathHop("A", dim=True), PathHop("B", dim=True))[0] - assert (glyph, style) == (POWERLINE_THIN, f"{_DIM_FG} on {_DIM_BG}") + assert (glyph, style) == (POWERLINE_THIN, f"{faded.ink} on {faded.fill}") + + +def test_a_faded_chip_is_its_own_chip_with_less_chroma() -> None: + """A faded hop keeps the colours of its node, with less chroma and the same lightness. + + The faded part of a route is the part that the user does not compose. Each node there + still has its hue, so the user can see which node it is (JP, 2026-10-14). Before, each + faded hop was the same dark slate chip. + """ + + def chroma(colour: str) -> float: + _lightness, a, b = oklab.from_hex(colour) + return math.hypot(a, b) + + for key in ("00", "4c", "aa", None): + normal = pathline._spectrum_colours(PathHop("N", key=key)) + faded = pathline._spectrum_colours(PathHop("N", key=key, dim=True)) + for before, after in zip(normal, faded, strict=True): + assert after == oklab.desaturated(before, FADE_CHROMA) + assert abs(oklab.from_hex(after)[0] - oklab.from_hex(before)[0]) < 0.01 + if key: + assert chroma(after) < chroma(before) * 0.5 + star = pathline._spectrum_colours(PathHop(SELF_GLYPH, you=True, dim=True)) + assert star.ink == "#ffffff" # white has no chroma to lose def test_a_seam_blurs_by_perceived_distance_not_by_hue_gap() -> None: @@ -866,9 +891,9 @@ def test_a_seam_blurs_by_perceived_distance_not_by_hue_gap() -> None: The eye decides, and not the wheel. Sixteen steps of the first byte across the greens are one colour to a user. Sixteen steps across the reds are two colours. Thus the same gap of hue blurs on one side 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 exception. The keyless grey and the faded slate are far apart, and they keep - their seam. + the fills, because the solid point is a fill on a fill. A faded chip follows the same + metric with no exception. A hue and its faded chip are far apart, and they 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] @@ -880,16 +905,16 @@ def test_a_seam_blurs_by_perceived_distance_not_by_hue_gap() -> None: wrapped = _seams(PathHop("A", key="fc"), PathHop("B", key="02"))[0] assert wrapped[0] == POWERLINE_THIN # neighbours across the seam of the wheel, also - glyph, style = _seams(PathHop("A"), PathHop("B", dim=True))[0] - assert glyph == POWERLINE_SEP and " on " in style # two greys, which still interlock + glyph, style = _seams(PathHop("A", key="00"), PathHop("B", key="00", dim=True))[0] + assert glyph == POWERLINE_SEP and " on " in style # a hue and its fade still interlock def test_bare_self_stands_us_on_a_star_and_fades_both_ends() -> None: """``bare_self`` puts a ★ for us at both ends, and fades both. To set out from us and to land back on us are fixtures of the route and not choices. - Thus they have the same automatic grey as a mirrored return leg. In chips, they have the - same dark slate, and not the loud white of you. + Thus they fade as a mirrored return leg does: ``faint`` in arrows. A faded chip only + loses chroma, and the white star has no chroma to lose, so its chip stays white. """ hops = [None, *(f"{i:02x}aa" for i in range(6)), None] line = path_line( @@ -909,7 +934,7 @@ def test_bare_self_stands_us_on_a_star_and_fades_both_ends() -> None: assert [str(s.style) for s in stars] == ["faint", "faint"] chips = PathLine(line.hops, mode="powerline").text() star_fills = [str(s.style) for s in chips.spans if chips.plain[s.start : s.end] == SELF_GLYPH] - assert all("#ffffff" not in fill for fill in star_fills) # never the white of you + assert star_fills and all(fill.startswith("#ffffff on ") for fill in star_fills) wrapped = path_line( hops, prefix_bytes=2, self_name="Me", bare_self=True, mode="powerline"