Our star takes the hue of our key, and unknown chat names go grey

The star of our node was the yellow of the map. SELF_MARK now names a
theme style, you.mark, and the theme writes our key's hue into it when the
device-state cache reads the self-info. The context makes that connection,
so the services still do not import the UI. The map, the routes, the lists,
the legends, and the chat all draw it, and the render cache keys on a style
generation so no old bytes stay. A faded star keeps its grey.

In a chat, a sender or a mention with no key had the light blue keyless
chip. It is now quiet: the grey of plain text on that grey darkened by the
chip rule. Routes keep the keyless chip, arrow mode keeps node.unknown, and
the PicoCalc keeps the keyless pair.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 5.5 committed 2026-10-13 23:46:39 -04:00
1 parent 3fdc638812
commit b680b172d7
23 files changed
+347 -72

No files matched your search

+7
View File
@@ -19,6 +19,13 @@ can change how things behave. It does not only add new things.
left of its time. Thus the messages that name you are easy to find down
the left side of the chat. On the PicoCalc, the console cannot draw a light fill, so
there the `★` marks your messages. Before, your messages showed only the `★`.
- **Your node's star has your colour.** The `★` that marks your node on the map, in
routes, in lists, and in a chat has the colour of your key, the same colour as your name
in a chat. Before, it was yellow. It is yellow only until the device gives MeshTerm your
key. A star in a faded part of a route stays grey.
- **In a chat, a name that MeshTerm cannot match to a node is grey.** The box around a
sender or a mention with no known node has the grey of plain text, on a darker grey. Thus
it does not stand out among the coloured names. Before, it was light blue on slate.
- **A mention has no `@` in front of it.** The coloured box around the name already says
that it is a mention. On a terminal that cannot draw the boxes, a mention keeps its `@`.
- **The line where you write a message shows each mention as a box.** When you type the
+18 -5
View File
@@ -417,8 +417,16 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow
- Status marks (single-width, themed): `✓` ok · `✗` err · `⚠`/`?` warn · `●` unread/
unacked (err) · `○` acked/empty (muted). Never `✔`, `✖`, `✅`, `❌` as status.
- Node types (shared with the map): `★` you (yellow) · `●` companion · `▲` repeater ·
`■` room server · `◉` sensor · `○` unknown.
- Node types (shared with the map): `★` you (our key's hue) · `●` companion · `▲`
repeater · `■` room server · `◉` sensor · `○` unknown.
- **Our `★` is our key's hue, everywhere** (JP, 2026-10-13): map, routes, lists,
legends, chat. `marks.SELF_MARK` carries the theme name `you.mark`, never a hex, and
`theme.set_self_key` writes `node_style(our key)` into it when the device reports the
key (`DeviceState.on_self_info` → `context._publish_self_key`; the services never
import the UI). One call recolours every surface; the render cache keys on
`theme.style_generation()` so no stale bytes survive. Until the key is known (and with
no device, as in `specimen`) it is the map's old yellow. A star something *mutes* keeps
its grey — a faded hop, a dimmed return leg — and our name stays the `you` white.
- 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
@@ -429,8 +437,8 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow
origin and destination, never the first and last *relay*. Our own end is always the
app-wide `★` (`pathline.SELF_GLYPH`, taken straight from `marks.SELF_MARK`), never our
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.
that differ from row to row. As a chip it is the map's star, in our hue, 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
@@ -530,7 +538,12 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow
live claimants; outside that window the newest key wins, because a reflash makes a new
key routinely; JP, 2026-10-11), a bare hash standing in as a name, an `○` ring — takes
`node.unknown`, THE light grey for an unidentified node, everywhere and on both
platforms. Colour is reserved for keyed identities, never seeded from a name's
platforms. One exception, a chat's **chips** (`PathHop.quiet`, via `widgets.author_chip`
and `mention_hop`): an unknown sender or mention there is the grey of plain text
(`#cccccc`) on that grey at `CHIP_LIGHTNESS`, so it stands back among the coloured
senders (JP, 2026-10-13); routes keep the keyless chip, arrow mode keeps `node.unknown`,
and the PicoCalc keeps the keyless pair (no grey background darker than its text).
Colour is reserved for keyed identities, never seeded from a name's
characters. `node.unknown` is deliberately not `muted`: muted is chrome and may sit a
step darker, while an unidentified node is content you can still act on. Our own node
is the pure-white `you` style; a context colouring (chart quality) may still win.
+13
View File
@@ -51,6 +51,18 @@ if TYPE_CHECKING:
from .ui.surface import Ui
def _publish_self_key(info: dict) -> None:
"""Give the theme our key from a new self-info, so that our star takes our hue.
The device-state cache calls this after each read (refer to
:attr:`~meshterm.services.device_state.DeviceState.on_self_info`). The context makes
the connection, because the services do not import the UI.
"""
from .ui.theme import set_self_key
set_self_key(str(info.get("public_key") or "") or None)
@dataclass(slots=True)
class AppContext:
"""The shared services and the state of one run, which MeshTerm gives to tools.
@@ -505,6 +517,7 @@ class AppContext:
from .services.device_state import DeviceState
self._devstate = DeviceState(self)
self._devstate.on_self_info = _publish_self_key
return self._devstate
@property
+9
View File
@@ -55,6 +55,8 @@ from typing import TYPE_CHECKING
from ..core.channel_probe import ChannelSlot, probe_channel_slots
if TYPE_CHECKING:
from collections.abc import Callable
from ..context import AppContext
from ..core.models import Contact
@@ -130,6 +132,11 @@ class DeviceState:
self._channels_lock = asyncio.Lock()
self._capacity_lock = asyncio.Lock()
self._tasks: set[asyncio.Task] = set()
#: Called with each self-info that the cache reads from the device. The context
#: connects it to the theme, so that our star takes the hue of our key (refer to
#: :meth:`AppContext.devstate <meshterm.context.AppContext.devstate>`). ``None``
#: calls nothing.
self.on_self_info: Callable[[dict], None] | None = None
# -- contacts (stale-while-revalidate) --------------------------------------
@@ -317,6 +324,8 @@ class DeviceState:
device = await self._ctx.device()
self._self_info = dict(await device.get_self_info())
self._self_info_at = time.monotonic()
if self.on_self_info is not None:
self.on_self_info(self._self_info)
return self._self_info
async def _refresh_self_info_quietly(self) -> None:
+2 -3
View File
@@ -58,7 +58,6 @@ from ..services.trace_runner import (
from ..ui import renderers
from ..ui.fields import ChannelRef, NodeRef
from ..ui.menus import Lane, column_header, fit_cells, section_heading
from ..ui.pathline import PathHop
from ..ui.termfont import powerline_enabled
from ..ui.theme import name_style
from ..ui.tui import Choice, DeleteRequest, Separator
@@ -66,9 +65,9 @@ from ..ui.widgets import (
NODE_GLYPHS,
ORIGIN,
age_seconds,
author_chip,
channel_glyph,
format_age,
hop_chip,
mention_chip,
mention_mark,
self_chip,
@@ -1105,7 +1104,7 @@ def _preview_text(
def _origin(name: str, key: str | None) -> Text:
"""The chip of the author of a preview: it closes on the point, as in the transcript."""
return hop_chip(PathHop(name, key=key), role=ORIGIN)
return author_chip(name, key)
def _append_author(text: Text, chip: Text, byline: Text) -> None:
+2 -2
View File
@@ -47,10 +47,10 @@ from .tui.spinner import Spinner, spinner_interval
from .widgets import (
DESTINATION,
ORIGIN,
author_chip,
hop_chip,
mention_hop,
mention_mark,
name_chip,
self_chip,
)
@@ -751,7 +751,7 @@ class ChatScreen(Screen):
return self_chip(self._self_name, self._self_key, role=ORIGIN)
# ``·`` is a channel line that arrived with no sender prefix: there is no key to use.
key = None if sender == "·" else self._header_key(sender, message)
return name_chip(sender, key, role=ORIGIN)
return author_chip(sender, key)
def _header_key(self, sender: str, message: ChatMessage) -> str | None:
"""The key that gives the colour of the sender chip of a group: the key of the name.
+2 -2
View File
@@ -244,7 +244,7 @@ class ContactRow:
a muted ``?``.
node_type: The node type of the contact, for the leading glyph (``None`` = the
plain node ``●``). The row of our node ignores it, and always starts with the
yellow ``★``.
``★`` in our hue.
last_seen: When MeshTerm last heard the contact (aware UTC). It fills the heard
lane, in the colour of the recency heat. ``None`` shows ``never`` in the cold
style.
@@ -361,7 +361,7 @@ def _you_lane(
"""The lane of our node, with the same layout as the usual rows of :func:`_lane`.
It is drawn as the map and the static table draw our node: the ``★`` self marker
(yellow), the name in the pure-white ``you`` style with a muted ``(you)`` tag, and our
(in our hue), the name in the pure-white ``you`` style with a muted ``(you)`` tag, and our
key with its hash lit at the routing width, in the flexible key lane. Our 64-hex key is
longer than any lane, so it shows as many digits as fit, and then an ellipsis. **Each
middle lane shows a faint ``—``**, whatever lanes the list declares. MeshTerm never
+3 -3
View File
@@ -34,7 +34,7 @@ from ..core.models import MapMarker
from ..core.mvt import GEOM_LINE, GEOM_POLYGON, Layer
from ..platforms import Platform, on_platform
from .mapcanvas import MapCanvas, Raster
from .marks import NODE_MARK, REPEATER_MARK, RGB, SELF_MARK, UNKNOWN_MARK, parse_hex
from .marks import NODE_MARK, REPEATER_MARK, RGB, SELF_MARK, UNKNOWN_MARK
from .theme import mark_rgb
# The marker palette, shared across all the app. The canonical tuples are in ui.marks.
@@ -830,7 +830,7 @@ def _draw_nodes(
# node that the user looks for is never hidden below a brighter neighbour.
for marker, ix, iy, matched in sorted(placed, key=lambda p: (p[3], p[0]._rank())):
glyph, color = _marker_style(marker)
rgb = parse_hex(color)
rgb = mark_rgb(color)
if not matched:
rgb = _dimmed(rgb, _FIND_DIM)
else:
@@ -839,7 +839,7 @@ def _draw_nodes(
# A node that the filter removes is context: a bare dim glyph with no label. Labels get
# the name hue of the node, which comes from its key (the colour rule for all the app).
# The label of our node is the pure ``you`` white (the ★ glyph keeps its yellow). An
# The label of our node is the pure ``you`` white (the ★ glyph keeps our hue). An
# active find filter still makes the label of each match full white, so that the node
# that the user looks for is easy to see.
from .widgets import name_rgb # widgets imports this module, so bind late to avoid the cycle
+6 -5
View File
@@ -30,15 +30,16 @@ def parse_hex(color: str) -> RGB:
# The palette of markers, shared in the whole app. The basemap is blue (water), green
# (parks), and warm amber (roads). Thus the markers use the hues that a map never has,
# pink and violet, so that they are easy to see against it. Ordinary nodes are the
# loudest (bright pink). Repeaters are less loud (a calmer violet). Our node is always the
# yellow star. A node that was heard but never identified is a light grey ring. Each glyph
# is in the PicoCalc console font.
# loudest (bright pink). Repeaters are less loud (a calmer violet). Our node is the star,
# in the hue of our key (yellow until the device reports the key). A node that was heard
# but never identified is a light grey ring. Each glyph is in the PicoCalc console font.
#
# The colour of a mark is any value that :func:`~meshterm.ui.theme.mark_rgb` takes: a
# literal hex, or the name of a theme style when the platform must choose the shade. The
# unknown ring has a name (``node.unknown``), so that the ring and the grey label beside
# it can never be different.
SELF_MARK = ("★", "#facc15")
# it can never be different. Our star has a name (``you.mark``), because its colour comes
# from our key at run time (refer to :func:`~meshterm.ui.theme.set_self_key`).
SELF_MARK = ("★", "you.mark")
REPEATER_MARK = ("▲", "#a78bfa")
NODE_MARK = ("●", "#f472b6")
UNKNOWN_MARK = ("○", "node.unknown")
+48 -19
View File
@@ -17,7 +17,8 @@ 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 yellow ``★`` of the map on a neutral dark grey. A faded hop is dark slate. A
node is the ``★`` of the map, in the hue of our key, 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`.)
@@ -87,7 +88,7 @@ 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 us in the ``you`` white.
``dim_self=False`` and keeps our stars in our hue.
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
@@ -140,8 +141,16 @@ POWERLINE_ROUND_CLOSE = ""
#: Our node, where a surface asks for it without its name and hash (``bare_self``): the
#: ``★`` of the app. It is the map's own marker, taken from
#: :data:`~meshterm.ui.marks.SELF_MARK`. Thus the star in a route and the star on the map
#: can never be different, in glyph or in hue.
SELF_GLYPH, _SELF_INK = SELF_MARK
#: can never be different, in glyph or in hue. The hue is a theme name, the hue of our key
#: (refer to :func:`~meshterm.ui.theme.set_self_key`). A chip resolves it at each paint
#: (:func:`_self_ink`).
SELF_GLYPH, _SELF_STYLE = SELF_MARK
def _self_ink() -> str:
"""The colour of our ``★`` as ``#rrggbb``, for the ink of a chip (the hue of our key)."""
return _style_hex(_SELF_STYLE) or "#facc15"
#: The insertion cursor of the composer. It stands in the route as a hop of its own: the
#: empty slot into which the next chosen hop goes. It is a ``+``, because this is what
@@ -198,8 +207,14 @@ _CHIP_FG = "#0f172a"
_KEYLESS_BG = "#64748b"
#: The label of a keyless chip: the same pair as the chip of a node, in grey.
_KEYLESS_FG = _chip_label(_KEYLESS_BG)
#: The chip fill of our node: a neutral dark grey. Thus the yellow ``★`` on it reads as the
#: marker of the map and not as the hue of a node (JP, 2026-08-09). It is *not* a colour
#: The label of a quiet chip (:attr:`PathHop.quiet`): the grey of plain text, the default
#: foreground of the usual dark terminal (Windows Terminal, and the terminal of VS Code).
_QUIET_FG = "#cccccc"
#: The fill of a quiet chip: the grey of its label, darker, by the rule of the chip of a
#: node (:data:`CHIP_LIGHTNESS`).
_QUIET_BG = _chip_fill(_QUIET_FG)
#: The chip fill of our node: a neutral dark grey. Thus the ``★`` on it reads as the
#: marker of the map and not as a chip of a node (JP, 2026-08-09). It is *not* a colour
#: of the spectrum on purpose, and it is not the white that it was before. Our end of a
#: route is a fixture that the user already knows. It stays back and lets the hops that
#: differ carry the colour.
@@ -286,9 +301,9 @@ class PathHop:
comes from the hash (its first byte, so each prefix gives the same hue).
``None`` renders muted and grey, because colour is only for identities that
have a key.
you: Our node. Arrows draw it in the pure-white ``you`` style. Chips draw it in the
own yellow of the map on the neutral dark grey :data:`_YOU_BG`, with padding
like each other chip.
you: Our node. Arrows draw our name in the pure-white ``you`` style, and our ``★``
in the hue of our key. Chips draw the star in that hue on the neutral dark grey
:data:`_YOU_BG`, with padding like each other chip.
annotation: The hash note for traces. It is rendered ``" (3d)"`` after the label in
both modes (pass the bare ``3d``, without parentheses).
lit_bytes: For a hash label: the leading *bytes* that are drawn in the hue (the two
@@ -314,6 +329,13 @@ class PathHop:
:func:`~meshterm.ui.widgets.self_chip`). Arrow mode draws the label in reverse
video, with the padding of a chip. The console of the PicoCalc has no light
background, so there the chip keeps the colours of each other chip.
quiet: A node with no key in a chat: a sender or a mention that nobody can
identify. Its chip is the grey of plain text on a darker fill of that grey
(:data:`_QUIET_FG`), by the same rule as the chip of a node. Thus it does not
stand out among the senders that have a colour (JP, 2026-10-13). A hop with a
key keeps its hue, and a route keeps the keyless grey. Arrow mode keeps the
``node.unknown`` grey. The console of the PicoCalc has no grey background darker
than its plain text, so there the chip keeps the keyless colours.
"""
label: str
@@ -326,6 +348,7 @@ class PathHop:
cursor: bool = False
gap: bool = False
inverse: bool = False
quiet: bool = False
def elision_hop() -> PathHop:
@@ -1012,7 +1035,9 @@ class PathLine:
elif hop.style:
text.append(hop.label, style=hop.style)
elif hop.you:
text.append(hop.label, style="you")
# Our star has the hue of our key, as on the map. Our name stays the white of
# ``you``.
text.append(hop.label, style=_SELF_STYLE if hop.label == SELF_GLYPH else "you")
elif hop.key and hop.lit_bytes > 0:
split = hop.lit_bytes * 2 # the two tones of highlighted_hash, in hex digits
text.append(hop.label[:split], style=node_style(hop.key))
@@ -1169,9 +1194,9 @@ class PathLine:
Each chip has padding on both sides, also the ``★`` (JP, 2026-08-09). The pads are
not there to make room for a long *word*. They are the chip's own shape. A star
that is squeezed between two chevrons reads as a glyph that is wedged into the seam,
and not as a segment of the route. The ink of our chip is the yellow of the map on
the neutral dark grey :data:`_YOU_BG`. Thus the star in the line and the star on the
map read as one mark.
and not as a segment of the route. The ink of our chip is the colour of the star of
the map, the hue of our key, on the neutral dark grey :data:`_YOU_BG`. Thus 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`).
"""
@@ -1207,7 +1232,7 @@ def _spectrum_colours(hop: PathHop) -> _ChipColours:
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 yellow among the greys. Plain mode says the same
that automatic half, and not keep its hue among the greys. Plain mode says the same
thing when it fades the name.
A chip that is turned over (``inverse``, our node in a chat) has the same two colours
@@ -1225,9 +1250,12 @@ def _spectrum_colours(hop: PathHop) -> _ChipColours:
if hop.inverse and hue:
return _ChipColours(hue, _chip_fill(hue), _chip_fill(hue))
if hop.you:
return _ChipColours(_YOU_BG, _SELF_INK, _SELF_INK)
ink = _self_ink()
return _ChipColours(_YOU_BG, ink, ink)
if hue:
return _ChipColours(_chip_fill(hue), hue, hue)
if hop.quiet:
return _ChipColours(_QUIET_BG, _QUIET_FG, _QUIET_FG)
return _ChipColours(_KEYLESS_BG, _KEYLESS_FG, _KEYLESS_FG)
@@ -1243,8 +1271,8 @@ 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 its
yellow, a faded hop in the dark grey, and the slot as a white ``+`` in a break of the
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`).
@@ -1263,7 +1291,8 @@ def _slot_colours(hop: PathHop) -> _ChipColours:
if hop.dim:
return _ChipColours(_SLOT_PAGE, _SLOT_FAINT, _SLOT_FAINT)
if hop.you:
return _ChipColours(_SLOT_PAGE, _SELF_INK, _SELF_INK)
ink = _self_ink()
return _ChipColours(_SLOT_PAGE, ink, 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)
@@ -1326,7 +1355,7 @@ def path_line(
rules). Thus a call site changes builders and does not change what it says. A named hop
reads ``Name`` (with ``show_hash`` it is ``Name (3d)``). An unnamed hop is its hash in
the grey for an unknown node (colour marks an identified node, as in the path graph).
``None`` is our device in white, or the lone ``★`` with ``bare_self``. ``dim_from``
``None`` is our device in white, or the lone ``★`` in our hue with ``bare_self``. ``dim_from``
fades a resolved tail. The plain rendering is identical to ``path_text`` in characters
and in style. What the change gives is the shapes of :class:`PathLine` (ellipsized and
wrapped) and the powerline mode.
+2 -2
View File
@@ -773,7 +773,7 @@ class RecordScreen(Screen):
# a chip), instead of a hanging indent under a lane. Our two ends are the app-wide ★,
# not our name and key again. The graph above already marks our node with the same
# star at the two ends, and the legend under it says that the star is "you". The
# stars keep the ``you`` white, because nothing about a walk that is already done is
# stars keep our hue, because nothing about a walk that is already done is
# ours to compose.
route = path_line(
[None, *record.route, None],
@@ -1043,7 +1043,7 @@ async def open_records(ctx: AppContext) -> dict:
(:data:`~meshterm.ui.pathline.SELF_GLYPH`). A record is a boomerang by
construction. To name our node two times in each row uses more cells than the full
score lane, and tells the user what each other row already told. These stars keep
the ``you`` white (``dim_self=False``). The dim colour means "not yours to
our hue (``dim_self=False``). The dim colour means "not yours to
compose", and nothing is composed on a board of walks that are already done.
The line is composed at its **natural** length and is given whole. Thus each row is
+2 -1
View File
@@ -23,6 +23,7 @@ from .tui.fkeys import FPair, active_deck
from .widgets import (
ORIGIN,
_recency_style,
author_chip,
channel_glyph,
mention_chip,
name_chip,
@@ -155,7 +156,7 @@ def specimen_lines() -> list[RenderableType]:
chips.append(" ")
chips.append_text(self_chip("me", _DEMO_SELF_KEY, role=ORIGIN))
chips.append(" ")
chips.append_text(name_chip("·", role=ORIGIN))
chips.append_text(author_chip("·", None))
chips.append(" ")
name, key = _DEMO_NAMES[2]
chips.append_text(mention_chip(name, key))
+76
View File
@@ -20,6 +20,7 @@ from functools import lru_cache
from rich.cells import cell_len
from rich.console import Console
from rich.style import Style
from rich.theme import Theme
from ..platforms import Platform, on_platform
@@ -79,6 +80,11 @@ MESH_THEME = Theme(
# of the node hues on purpose (refer to node_style). Thus the user can always find
# "you" easily.
"you": "bold #ffffff",
# The ``★`` of our node (``marks.SELF_MARK``): on the map, in a route, in a list. It
# has the hue of our key, the colour of our node everywhere else (JP, 2026-10-13).
# :func:`set_self_key` puts the hue here when the device reports our key. This
# yellow is the star of the map until then, and with no device.
"you.mark": "#facc15",
# The text that the user types in the compose line of a chat: pure white, not bold.
# It is more visible than the transcript above it (JP, 2026-10-13). The ``›`` in
# front of it has the ``cursor`` white, the same as the ``❯`` of a selected
@@ -365,6 +371,7 @@ MESH_THEME_16 = Theme(
"pathline": "none",
"err.reverse": "reverse bold color(9)",
"you": "bold color(15)",
"you.mark": "color(11)",
# Slot 15 is already bright, so it needs no bold to be white.
"compose": "color(15)",
"device.known": "bold color(15)",
@@ -1113,6 +1120,73 @@ _fold_impl: Callable[[str], str] = _no_fold
_STYLE_RGB: dict[str, tuple[int, int, int]] = {}
#: The theme name of the colour of our ``★`` (refer to :func:`set_self_key`).
SELF_MARK_STYLE = "you.mark"
#: The colour of our ``★`` in each theme before our key is known: the yellow of the map.
_SELF_MARK_DEFAULTS: dict[int, Style] = {
id(theme): theme.styles[SELF_MARK_STYLE] for theme in (MESH_THEME, MESH_THEME_16)
}
#: Our public key, which gives the hue of our ``★``. ``None`` before the device reports it.
_SELF_KEY: str | None = None
#: A count that goes up each time that the colour of a theme name changes after the
#: platform was bound (refer to :func:`style_generation`).
_STYLE_GEN = 0
def set_self_key(key: str | None) -> None:
"""Give our ``★`` the hue of our key, on each surface that draws it.
The star of our node is one theme name (:data:`SELF_MARK_STYLE`), which the map, the
routes, the lists, and the chat all draw. Thus this one call changes it everywhere,
and no surface must know our key. The hue is the one that :func:`node_style` gives our
key, on this platform: the colour of our node in the chips and in the names of the
chat (JP, 2026-10-13). A star that a surface fades (a dimmed hop, the grey of a muted
line) keeps its own grey, because it does not use this name.
The app calls this when the device reports our key (refer to
:class:`~meshterm.services.device_state.DeviceState`). A call with the same key does
nothing. ``None`` puts the yellow of the map back.
Args:
key: Our public key, or ``None``.
"""
global _SELF_KEY
key = key or None
if key == _SELF_KEY:
return
_SELF_KEY = key
_apply_self_mark()
def _apply_self_mark() -> None:
"""Write the colour of our ``★`` into the active theme, and make the caches forget it.
The consoles of the render share the dict of styles of the theme. Thus the next render
reads the new colour. The ANSI cache of the render holds the old bytes, so the
generation changes (refer to :func:`style_generation`).
"""
global _STYLE_GEN
theme = _ACTIVE_THEME
if _SELF_KEY:
theme.styles[SELF_MARK_STYLE] = Style.parse(node_style(_SELF_KEY))
else:
theme.styles[SELF_MARK_STYLE] = _SELF_MARK_DEFAULTS[id(theme)]
_STYLE_RGB.pop(SELF_MARK_STYLE, None)
_STYLE_GEN += 1
def style_generation() -> int:
"""The count of the changes to a theme colour at run time (the colour of our ``★``).
A cache of rendered bytes keeps it in its key, so that bytes from before a change are
never used again (refer to :func:`~meshterm.ui.tui.render.render_to_ansi`).
"""
return _STYLE_GEN
@on_platform
def _bind(platform: Platform) -> None:
"""Bind the choices of the theme that depend on the platform (runs now and at each switch)."""
@@ -1134,3 +1208,5 @@ def _bind(platform: Platform) -> None:
# cache, and does not trust old pads.
_FOLD_TABLE = None
_fold_to_font.cache_clear()
# The hue of our star is the hue of the platform: a quantized slot on the console.
_apply_self_mark()
+3 -2
View File
@@ -23,7 +23,7 @@ from rich.text import Span, Text
from ...platforms import Platform, on_platform
from ..pathline import PATH_INK
from ..theme import active_theme, fold_text, is_identity_style
from ..theme import active_theme, fold_text, is_identity_style, style_generation
#: A cache of one headless render console for each width. Consoles are cheap, but a paint
#: occurs at each key press and at each tick of the live-monitor timer. Thus the cache
@@ -84,7 +84,7 @@ def _cache_key(renderable: RenderableType, width: int, no_wrap: bool) -> tuple |
necessary. Thus it reports ``None`` and renders without the cache.
"""
if isinstance(renderable, str):
return ("s", renderable, width, no_wrap, _CACHE_GEN)
return ("s", renderable, width, no_wrap, _CACHE_GEN, style_generation())
if isinstance(renderable, Text):
return (
"t",
@@ -98,6 +98,7 @@ def _cache_key(renderable: RenderableType, width: int, no_wrap: bool) -> tuple |
width,
no_wrap,
_CACHE_GEN,
style_generation(),
)
return None
+2 -2
View File
@@ -1416,7 +1416,7 @@ class WalkScreen(Screen):
"""The Rich style of the type glyph of a node in body text: the colour of its type.
This is the text form of :meth:`_marker_rgb`: the same ``type.*`` entry (or the
yellow of the star). Thus the first glyph of the focus line, the glyphs of the list
hue of our star). Thus the first glyph of the focus line, the glyphs of the list
rows, and the canvas markers are one mark, drawn in three ways.
"""
return self._glyph(node)[1]
@@ -1443,7 +1443,7 @@ class WalkScreen(Screen):
shared ``type.*`` colours for them. Thus a node shows here with exactly the glyph
and colour that the map, the route graph, and the contact list use for it: ``▲``
violet for a repeater, ``■`` for a room, ``◉`` for a sensor, ``●`` pink for a
plain node, the yellow ``★`` for our node, and the grey ``○`` for a node that we
plain node, the ``★`` in our hue for our node, and the grey ``○`` for a node that we
heard of but never identified. The colour and the shape say the same thing. Thus
the legend below the canvas is a real explanation, not a coincidence.
+29 -8
View File
@@ -683,6 +683,24 @@ def name_chip(
return hop_chip(hop, role=role)
def author_chip(name: str, key: str | None) -> Text:
"""The author of a message in a chat: the chip of the node, which closes on the point.
It is :func:`name_chip` with the role of an origin, and one difference: a node that no
key can place has the quiet chip of a chat, the grey of plain text on a darker grey
(refer to :attr:`~meshterm.ui.pathline.PathHop.quiet`). The transcript and the
Messages list draw each sender through this function.
Args:
name: The name as it must read.
key: Any known prefix of the key of the node, or ``None``.
Returns:
The chip as a one-line :class:`Text`.
"""
return hop_chip(PathHop(name, key=key or None, quiet=True), role=ORIGIN)
def self_chip(name: str | None, key: str | None, *, role: str | None = None) -> Text:
"""Our node as the sender in a chat: ``★ Name``, on the chip of our key, turned over.
@@ -725,16 +743,19 @@ def mention_mark(key: str | None) -> Text:
2026-10-13). The hue is the colour of our name, the same colour as the chip that the
mention has in the body, so the eye joins the two. It is the same on each platform, and
with chips or without them. JP tried the yellow star of the map, the notch and the point
of our chip, and the point alone, and kept this one.
of our chip, and the point alone, and kept this one. It is the star of our node in the
rest of the app too (:data:`~meshterm.ui.marks.SELF_MARK`).
Args:
key: Our public key, which gives the hue. With ``None``, the star has the keyless
grey, the same as the chip of the mention.
key: Our public key. With ``None``, the star has the keyless grey, the same as the
chip of the mention.
Returns:
The mark, one cell wide. The caller puts a space after it.
"""
return Text(SELF_GLYPH, style=node_style(key) if key else "node.unknown")
if not key:
return Text(SELF_GLYPH, style="node.unknown")
return Text(SELF_MARK[0], style=SELF_MARK[1])
def mention_hop(name: str, key: str | None, *, you: bool = False) -> PathHop:
@@ -757,7 +778,7 @@ def mention_hop(name: str, key: str | None, *, you: bool = False) -> PathHop:
chip under the cursor in the white of the insertion slot).
"""
label = name if powerline_enabled() else f"@{name}"
return PathHop(label, key=key or None, inverse=you and bool(key))
return PathHop(label, key=key or None, inverse=you and bool(key), quiet=True)
def mention_chip(name: str, key: str | None, *, you: bool = False) -> Text:
@@ -1091,7 +1112,7 @@ def stats_panel(
# The glyphs and colours of the node types. They are the same as the marker palette of the
# map in the whole app (refer to ui.map_render). Our node is the yellow ``★``, plain nodes
# map in the whole app (refer to ui.map_render). Our node is the ``★`` in our hue, plain nodes
# are the loud pink ``●``, and repeaters are the calmer violet ``▲``. The other types have
# hues that are safe on the map and different from those: a white square for rooms (the
# house glyph was difficult to read) and an orange dot with a ring for sensors. All are
@@ -1124,8 +1145,8 @@ def node_marker(node_type: int | None) -> tuple[str, RGB]:
def self_marker() -> tuple[str, RGB]:
"""The map marker of our node, the yellow ``★``, as a ``(glyph, rgb)`` pair."""
return SELF_MARK[0], parse_hex(SELF_MARK[1])
"""The map marker of our node, the ``★`` in our hue, as a ``(glyph, rgb)`` pair."""
return SELF_MARK[0], mark_rgb(SELF_MARK[1])
# A heat-map gradient for the heard age of a node, from the hottest (heard most recently) to
+15
View File
@@ -119,6 +119,21 @@ def not_ignored(paths: Iterable[Path]) -> list[Path]:
return [path for path, name in zip(paths, relative, strict=True) if name not in ignored]
@pytest.fixture(autouse=True)
def _no_self_key() -> Iterator[None]:
"""Start and end each test with no key for our star (the yellow of the map).
The colour of our ``★`` is global: the device-state cache sets it from the self-info
that it reads. A test that opens a mock device sets it. Thus each test starts clean,
and no test sees the hue of an earlier test.
"""
from meshterm.ui.theme import set_self_key
set_self_key(None)
yield
set_self_key(None)
@pytest.fixture(autouse=True)
def _plain_path_rendering(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
monkeypatch.setenv("MESHTERM_POWERLINE", "0")
+1 -1
View File
@@ -1530,7 +1530,7 @@ def test_channel_self_style_keyed_on_concept_not_label() -> None:
turned = f"{node_style(_OUR_KEY)} reverse"
assert _ink(mine._group_header("you", ours), " ★ Me ") == turned # our name, turned over
screen = _channel_screen([])
assert _ink(screen._group_header("you", ours), "★") == "you" # no name: the white star
assert _ink(screen._group_header("you", ours), "★") == "you.mark" # no name: our star
assert _ink(screen._group_header("you", theirs), "you") == "node.unknown" # no key: grey
keyed = _channel_screen([], key_of=_keys_of({"you": "d4" + "0" * 62}))
remote = _ink(keyed._group_header("you", theirs), "you")
+26
View File
@@ -107,6 +107,32 @@ def _devstate(
return DeviceState(ctx) # type: ignore[arg-type]
def test_each_self_info_read_goes_to_the_hook() -> None:
"""The context gives our key to the theme through this hook (our star takes our hue)."""
dev = FakeDevice()
ds = _devstate(dev)
seen: list[dict] = []
ds.on_self_info = seen.append
async def run() -> None:
await ds.self_info()
await ds.self_info() # from the cache: no new read, so no new call
asyncio.run(run())
assert seen == [{"name": "node", "tx_power": 20}]
def test_the_context_publishes_our_key_to_the_theme() -> None:
"""A self-info with a key gives our star the hue of that key."""
from meshterm.context import _publish_self_key
from meshterm.ui.theme import active_theme, node_style
_publish_self_key({"public_key": "5e7f"})
assert active_theme().styles["you.mark"].color.name in node_style("5e7f")
_publish_self_key({}) # no key: the yellow of the map again
assert active_theme().styles["you.mark"].color.name == "#facc15"
def test_stable_facts_are_fetched_once_and_served_from_cache() -> None:
"""MeshTerm reads self-info, the path hash mode, and channels from the radio one time.
+4 -2
View File
@@ -2123,10 +2123,12 @@ def test_map_observation_round_trips_node_type(ctx) -> None:
def test_render_map_labels_take_the_name_hue_ours_white() -> None:
"""A map label takes the hue of its node, and our label takes the white of you.
Our label is white, and the ★ glyph stays yellow. A label with no key gets the muted grey.
Our label is white, and the ★ glyph keeps the colour of our star. A label with no key gets
the muted grey.
"""
from meshterm.ui.map_render import MapMarker, render_map
from meshterm.ui.marks import SELF_MARK
from meshterm.ui.theme import mark_rgb
from meshterm.ui.widgets import name_rgb
vp = Viewport.fit([(45.5, -73.6), (45.4, -73.5)], 120, 80, max_zoom=14)
@@ -2136,7 +2138,7 @@ def test_render_map_labels_take_the_name_hue_ours_white() -> None:
MapMarker("BARE", 45.45, -73.55),
]
lines = render_map(vp, {}, markers)
assert _glyph_color(lines, "★") == parse_hex(SELF_MARK[1]) # the glyph keeps its yellow
assert _glyph_color(lines, "★") == mark_rgb(SELF_MARK[1]) # the colour of our star
assert _glyph_color(lines, "US") == (255, 255, 255) # ...the label is the white of you
assert _glyph_color(lines, "KEYED") == name_rgb("KEYED", "d4" * 32)
assert _glyph_color(lines, "BARE") == (148, 163, 184) # no key: the muted grey
+10 -10
View File
@@ -23,7 +23,6 @@ from meshterm.ui.fontset import FONT_CODEPOINTS
from meshterm.ui.pathline import (
_DIM_BG,
_DIM_FG,
_SELF_INK,
_YOU_BG,
CHIP_LIGHTNESS,
CRACK_HEAD,
@@ -41,6 +40,7 @@ from meshterm.ui.pathline import (
WRAP_OFFSET,
PathHop,
PathLine,
_self_ink,
_style_hex,
cut_mark,
cut_to,
@@ -930,12 +930,12 @@ def test_bare_self_stands_us_on_a_star_and_fades_both_ends() -> None:
assert all(line.cell_len <= 28 for line in lines)
def test_bare_self_keeps_us_in_the_you_white_where_nothing_is_composed() -> None:
def test_bare_self_keeps_us_in_our_hue_where_nothing_is_composed() -> None:
"""``dim_self=False`` makes the stars not muted: the fade means "not for you to compose".
A surface that only *shows* a walk (the boards and the record card of the Trophy case)
composes nothing. Thus there is no "not yours" to say, and our node reads in the ``you``
white of the app. This is the same identity colour that it has in other places. A
composes nothing. Thus there is no "not yours" to say, and our star has the colour of
our star in other places (``you.mark``, the hue of our key). A
``dim_from`` that reaches a star still fades it, so a mirrored return leg keeps its grey
in both cases.
"""
@@ -944,15 +944,15 @@ def test_bare_self_keeps_us_in_the_you_white_where_nothing_is_composed() -> None
hops, prefix_bytes=2, self_name="Me", bare_self=True, dim_self=False, mode="plain"
).text()
stars = [s for s in text.spans if text.plain[s.start : s.end] == SELF_GLYPH]
assert [str(s.style) for s in stars] == ["you", "you"]
# In chips the same identity is the yellow star of the map on the neutral dark grey. It
# is the marker itself and not a hue, because our end is a fixture and not a node that
# the user must tell from other nodes.
assert [str(s.style) for s in stars] == ["you.mark", "you.mark"]
# In chips the same identity is the star of the map on the neutral dark grey. The fill
# is not a hue, because our end is a fixture and not a node that the user must tell
# from other nodes.
chips = path_line(
hops, prefix_bytes=2, self_name="Me", bare_self=True, dim_self=False, mode="powerline"
).text()
fills = [str(s.style) for s in chips.spans if chips.plain[s.start : s.end] == SELF_GLYPH]
assert fills == [f"bold {_SELF_INK} on {_YOU_BG}"] * 2
assert fills == [f"bold {_self_ink()} on {_YOU_BG}"] * 2
# The explicit fade still wins: a star inside a dimmed return leg stays grey.
faded = path_line(
@@ -965,7 +965,7 @@ def test_bare_self_keeps_us_in_the_you_white_where_nothing_is_composed() -> None
mode="plain",
).text()
tail = [s for s in faded.spans if faded.plain[s.start : s.end] == SELF_GLYPH]
assert [str(s.style) for s in tail] == ["you", "faint"]
assert [str(s.style) for s in tail] == ["you.mark", "faint"]
def test_action_mark_rides_outside_the_width_budget() -> None:
+2 -2
View File
@@ -780,10 +780,10 @@ async def test_a_board_row_spends_its_cells_on_the_walk(tui_ctx) -> None:
assert "2 nodes" in row # the score, in the unit of the discipline
assert row.count("★") == 2 # our two ends, bare: the row does not name us twice
assert "Homestead" not in row # …so the device name is not on the line
# Also, those ends are us, in the white of "you". We compose nothing here, so
# Also, those ends are us, in the colour of our star. We compose nothing here, so
# there is no "not yours" fade to use.
stars = [s for s in label.spans if row[s.start : s.end] == "★"]
assert [str(s.style) for s in stars] == ["you", "you"]
assert [str(s.style) for s in stars] == ["you.mark", "you.mark"]
assert "3d" in row and "f2" in row # the hops, at the hash width of the record
# The lanes before the walk keep to what they must show: a score lane with the width
# of the widest score of this board (no dead padding), and the day without the minute
+65 -3
View File
@@ -401,14 +401,42 @@ def test_a_mention_of_us_is_turned_over_with_no_star(powerline) -> None:
@pytest.mark.parametrize("chips", [True, False])
def test_the_mark_of_a_message_that_names_us_is_a_star_in_our_hue(powerline, chips) -> None:
"""The ``★`` in the hue of our key, with chips or without them."""
def test_the_mark_of_a_message_that_names_us_is_our_star(powerline, chips) -> None:
"""The ``★`` of our node, with chips or without them: the theme name of our hue."""
powerline(chips)
mark = mention_mark("5e7f")
assert mark.plain == "★" and str(mark.style) == node_style("5e7f")
assert mark.plain == "★" and str(mark.style) == "you.mark"
assert str(mention_mark(None).style) == "node.unknown" # no key: the keyless grey
def test_our_star_takes_the_hue_of_our_key_everywhere() -> None:
"""One call gives the theme name of our star our hue, and the render cache forgets it.
Before the device reports our key, the star has the yellow of the map. ``None`` puts the
yellow back.
"""
from rich.style import Style
from meshterm.ui.marks import SELF_MARK, parse_hex
from meshterm.ui.theme import active_theme, mark_rgb, set_self_key
from meshterm.ui.tui.render import render_to_ansi
from meshterm.ui.widgets import self_marker
star = Text(SELF_MARK[0], style=SELF_MARK[1])
yellow = render_to_ansi(star, 1)
assert self_marker() == ("★", (0xFA, 0xCC, 0x15))
set_self_key("5e7f")
hue = node_style("5e7f")
assert active_theme().styles["you.mark"] == Style.parse(hue)
assert render_to_ansi(star, 1) != yellow # the cache did not keep the old bytes
assert mark_rgb("you.mark") == parse_hex(hue.split()[-1]) # the map draws the same hue
assert self_marker()[1] == parse_hex(hue.split()[-1])
set_self_key(None)
assert render_to_ansi(star, 1) == yellow
def test_self_chip_keeps_the_bare_star_without_our_name_or_key(powerline) -> None:
"""A device that did not report our name or key leaves the star of a route."""
powerline(True)
@@ -425,6 +453,40 @@ def test_name_chip_greys_a_node_it_cannot_place(powerline) -> None:
assert _span_style(keyless, "·").bgcolor.name == "#64748b" # the keyless grey
def test_a_chat_chip_with_no_key_is_the_grey_of_plain_text(powerline) -> None:
"""In a chat, an unknown sender or mention is plain-text grey on a darker fill of it.
The fill comes from the label by the rule of the chip of a node. A node with a key keeps
its hue, and arrow mode keeps the ``node.unknown`` grey.
"""
from meshterm.ui.pathline import _chip_fill
from meshterm.ui.widgets import author_chip
powerline(True)
for chip, label in ((author_chip("Zed", None), "Zed"), (mention_chip("Zed", None), "Zed")):
style = _span_style(chip, label)
assert (style.color.name, style.bgcolor.name) == ("#cccccc", _chip_fill("#cccccc"))
keyed = _span_style(author_chip("Bob", "d4"), "Bob")
assert keyed.color.name == node_style("d4").split()[-1] # a key keeps its hue
powerline(False)
assert str(author_chip("Zed", None).spans[0].style) == "node.unknown"
def test_a_chat_chip_with_no_key_keeps_the_keyless_colours_on_the_picocalc(powerline) -> None:
"""The console has no grey background darker than its plain text."""
from meshterm.platforms import PICOCALC_LYRA, REGULAR, set_platform
from meshterm.ui.widgets import author_chip
powerline(True)
set_platform(PICOCALC_LYRA)
try:
quiet = _span_style(author_chip("Zed", None), "Zed")
keyless = _span_style(name_chip("Zed"), "Zed")
assert (quiet.color, quiet.bgcolor) == (keyless.color, keyless.bgcolor)
finally:
set_platform(REGULAR)
def test_name_chip_of_an_origin_closes_on_the_point(powerline) -> None:
"""The author of a message is where a route starts, and the route goes on from it."""
powerline(True)