Files
rich 233dfc813c
CI / lint (push) Waiting to run
CI / test macos-latest py3.14 (push) Waiting to run
CI / test ubuntu-24.04 py3.10 (push) Waiting to run
CI / test ubuntu-latest py3.11 (push) Waiting to run
CI / test ubuntu-latest py3.12 (push) Waiting to run
CI / test ubuntu-latest py3.13 (push) Waiting to run
CI / test ubuntu-latest py3.14 (push) Waiting to run
CI / test windows-latest py3.10 (push) Waiting to run
CI / test windows-latest py3.14 (push) Waiting to run
CI / build (push) Waiting to run
Issue trailers / check (push) Waiting to run
Merge upstream MeshTerm 0.11.5
Bring in the 65 upstream commits since 0.11.1, with ^K lock and the self chip.
Both sides kept their features. React gives way to Lock in two places. On the
PicoCalc lane, Lock keeps the Shift half of F2 and React is ^E only. In the
hint, ^E react is the first atom to go when ^K lock has no room.

The long-message code now refills the compose line with MentionEditor, which
replaced LineEditor upstream.
2026-10-22 16:53:37 -04:00

1179 lines
60 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SPDX-License-Identifier: Apache-2.0
"""The shared Rich theme and the console factory, for a consistent, modern look.
There are two palettes and one vocabulary. Each style name here is in both
:data:`MESH_THEME` (the truecolor look of the regular platform) and :data:`MESH_THEME_16`
(the 16-slot look of the PicoCalc console). Thus screens do not know which theme is
active. They ask for ``"warn"`` or ``"snr.good"``, and the platform decides what that
means. The active theme, the rule for the colour of names, the icon funnel, and the fold
at the render boundary are all **bound when the platform switches**, through
:func:`meshterm.platforms.on_platform`. The hot render paths read module globals, and
they never find the platform state again for each call.
"""
from __future__ import annotations
import colorsys
import re as _re
from collections.abc import Callable
from functools import lru_cache
from rich.cells import cell_len
from rich.console import Console
from rich.theme import Theme
from ..platforms import Platform, on_platform
from .fontset import FONT_CODEPOINTS, FONTS
MESH_THEME = Theme(
{
"brand": "bold #5eead4",
"accent": "bold #818cf8",
# The border of each panel and dialog that has no caution tier: the base frame, a
# floating dialog, the title bar of a handheld, and a tab strip. It is the grey of
# ``muted``, and not a hue. A border is chrome, and a blue border was one more colour
# on each screen that had no meaning (JP, 2026-10-14). The two caution tiers keep
# their colours, because there the colour is the meaning: ``warn`` (amber) and
# ``err`` (red). It is darker than ``muted``, so the frame stays behind the text that
# it holds. JP chose it between #a2a2a2 (too light) and #7c7c7c (too dark).
"border": "#8f8f8f",
# The only reverse-video chip: the committing button of a dialog, the Abort of a
# running action, and the insertion slot of the composer where no chip fill carries
# it. It is a *grey* block, and the ink of the page shows through it. It was cyan
# before. That cyan was the teal of the wordmark with a third job (identity, a
# node hue, and now a selection). A chip is chrome. It shows where a press lands,
# and it does not claim a colour. The grey is the grey of ``muted``. It is light
# enough that the reversed ink reads on it, in any background of the terminal. It
# must be a *single* theme name. Rich removes without a message a style string that
# mixes a theme name with an attribute (for example "reverse muted" renders as plain
# text). Thus the reverse is part of the definition here, and the call site does
# not add it.
"selected": "reverse bold #a2a2a2",
# The only ink for "this is the one that the user selected". It is the row that the
# ❯ points at, in each select list and in each screen that draws its own rows
# (feed, routes, records, walk, composer). It is also the lit column heading one
# axis over, where a list is sorted. It is white, and not ``brand`` on purpose. The
# highlight once used the teal of the wordmark. Then the identity ink of the app and
# "the row you are on" had the same hue. Also, teal/cyan is a node hue, so the name
# of a node with a cyan key disappeared into its own highlight. White is outside the
# spectrum of the nodes (refer to node_style), so it can never be the same as an
# identity. It has the same ink as ``you``, and we accept that cost. Spans that set
# their own colour keep it over the base: the heat of an age, an SNR reading, a red
# badge. The highlight exists for one exception. The hue of a node, which comes from
# its key, folds to this white on the cursor row (refer to theme.is_identity_style
# and ui.tui.render._whiten_identities). Thus the row that the user is on reads as
# one thing, and not as a name that competes with its own selection. It is bold, as
# ``brand`` was. Thus the ``not bold`` pins of the dim slots (refer to
# MESH_THEME_16), which protect a span in a highlighted row, keep their meaning.
"cursor": "bold #ffffff",
# The only mark that a path line puts around its own hops (PathLine._render). The
# identity fold of the cursor row (ui.tui.render._whiten_identities) uses it to see
# where a route starts and stops. In a path line, the hue of a node is not a
# decoration on a name. It shows the difference between one hop and the next. The
# graph above the row also cross-references it, because a label of the graph is
# only a marker and one byte of a hash. When the fold made these hues white, a
# selected route looked like one long white smear, and the user could not find the
# way back to the picture. Chips were never affected, because their fills are
# outside the vocabulary of the fold. This mark makes the arrow form say the same
# thing. The arrow form is every path line on the PicoCalc, and every path line on
# a terminal without the powerline glyphs. The mark renders as nothing. The render
# boundary reads it, and the user does not see it.
"pathline": "none",
# Reversed error (the cursor of the text editor on a character over the budget). It
# is part of the definition for the same reason as ``selected``: "reverse err"
# renders as plain text.
"err.reverse": "reverse bold #f87171",
# Our node, in each place where it has a name: pure white, and outside the spectrum
# of the node hues on purpose (refer to node_style). Thus the user can always find
# "you" easily.
"you": "bold #ffffff",
# 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
# message.
"compose": "#ffffff",
# The name of a confirmed companion on the picker for the startup device: pure
# white. Thus the devices that we talked to before are more visible than the ports
# that MeshTerm only detected.
"device.known": "bold #ffffff",
# The modules of a QR code: pure white ink on a pure black field. Both ends have
# names, so no palette can reduce the contrast that a camera reads (refer to
# ui/qr.py). The code is never black on white, because a scanner expects a code
# that is light on dark on a screen.
"qr": "#ffffff on #000000",
# The Bluetooth TYPE badge of the picker: a white rune on the official Bluetooth
# blue (Pantone 300, #0057b8). It is like the real logo, so the user can see BLE
# at a glance.
"bluetooth": "bold #ffffff on #0057b8",
# The tapered edges of the badge: half-block glyphs in the same blue as the
# foreground (over the background of the terminal). Only their inner half fills.
# Thus the badge looks a little wider than the single rune cell, and it has no hard
# rectangle.
"bluetooth.edge": "#0057b8",
"ok": "bold #4ade80",
"warn": "bold #fbbf24",
"err": "bold #f87171",
"muted": "#a2a2a2",
# A node that MeshTerm cannot identify: its ``○`` ring, and its label where a bare
# key or hash is the name (refer to the return of name_style when there is no key).
# It has its own name and does not use ``muted``, because the two styles must not
# change together. ``muted`` is chrome, and it can be a step darker than the body
# text. An unidentified node is *content* on which the user can still act. On the
# console this difference is the whole ladder: chrome takes the dark grey slot,
# and this style takes the light grey slot.
"node.unknown": "#a2a2a2",
# The titles of panels and dialogs: the same hue as the border that they are in,
# one shade brighter. Thus the title reads as part of its frame, and it is still
# different from the frame. There is one entry for each border style that the frame
# compositor receives (refer to theme.title_style).
"title.border": "bold #d4d4d4",
"title.muted": "bold #d4d4d4",
"title.warn": "bold #fcd34d",
"title.err": "bold #fca5a5",
"title.ok": "bold #86efac",
"title.brand": "bold #99f6e4",
# The footer hints of panels and dialogs: the hue of the border, one shade *darker*
# (and not bold). This is the opposite of the brightening of the ``title.*`` styles.
# The hint reads as part of the frame, and it stays behind the frame. There is one
# entry for each border style (refer to theme.hint_style).
"hint.border": "#737373",
"hint.muted": "#737373",
"hint.warn": "#f59e0b",
"hint.err": "#ef4444",
"hint.ok": "#22c55e",
"hint.brand": "#2dd4bf",
# A step darker than ``muted``, for placeholder dashes (the missing packet count or
# age of a node). They must stay behind the real, muted values around them.
"faint": "#737373",
# The ``── Label ──`` of a section heading: in a grouped list, and the day divider
# of a chat. It has the colour of the title of the frame (``title.border``, JP,
# 2026-10-15). It was a dark grey before. It is never the grey of the border itself:
# when the rule of a heading had the colour of the border, it looked like part of
# the frame that it is in (JP, 2026-10-02).
"heading": "bold #d4d4d4",
# A further step darker than ``faint``, for the unlit track of a meter (the SNR
# quality bars). It is dark enough to look like a background, and not like a
# dimmer version of the reading.
"track": "#404040",
"snr.good": "bold #4ade80",
"snr.ok": "bold #fbbf24",
"snr.bad": "bold #f87171",
# The battery gauge of the status bar: a filled block, and not four thin dots. Each
# cell is a lit foreground over its own darker ground of the same hue: light green
# on green above half, yellow on brown above a quarter, light red on red below.
# Thus the gauge looks like a *block* at a glance, and the braille fill in it shows
# how much of the battery is left. ``batt.flash`` and ``batt.flash.off`` are the two
# beats of the alarm for the last percent: black dots on red, then in turn the light
# red on the bare page. When the ground is lost for one beat, the change is bigger
# than a change of hue. Thus the user can see it from across a room.
"batt.full": "bold #4ade80 on #15803d",
"batt.mid": "bold #fbbf24 on #a16207",
"batt.low": "bold #f87171 on #991b1b",
"batt.flash": "not bold #000000 on #dc2626",
"batt.flash.off": "bold #f87171",
# The node-type *marks*: the colours for ● ▲ ■ ◉ where a node with a type is drawn
# (refer to ui.widgets.NODE_GLYPHS), and the markers of the map. All four are white.
# The shape tells the type, so a hue for each type was a colour with no more
# information (JP, 2026-10-14). They were pink, violet, white, and orange. Each type
# keeps its own style name, so that one type can get a colour again in one place.
"type.node": "#ffffff",
"type.repeater": "#ffffff",
"type.room": "#ffffff",
"type.sensor": "#ffffff",
# A region name (the scope of a flood). It is not a node, so it has no hue from the
# node wheel. It is a light grey. Its slant makes it different from prose, as a
# tag is different from a sentence.
"scope": "italic #d4d4d4",
# The features of the basemap for which the *hue* is the information: water is
# blue, parks are green, and a highway is the warm colour. A naive downsample
# damages them (the dark blue becomes grey, the dark green becomes black, and the
# amber becomes bright red). They have names for the same reason as ``type.*``: the
# console chooses the slot itself. The rest of the basemap is grey by design, and
# it quantizes correctly. Thus minor roads, rail, boundaries, and labels stay
# literal hex in ui.map_render.
"map.water": "#153b56",
"map.river": "#49b0ec",
"map.stream": "#3f8fbf",
"map.ditch": "#3a7ba6",
"map.park": "#173a29",
"map.highway": "#f2a13d",
# The steps of the heat scale for the heard age. Each step has the name of the age
# of the node that it colours: a node that MeshTerm heard eight minutes ago is
# ``heat.minutes``, and a node that it heard eight days ago is ``heat.days``. There
# are seven steps, from white-hot to cold ash (JP's spec). It is a cooling ember,
# and then what is left of it. ``heat.never`` is both "over a year" and "never
# heard", because after one year a different colour is not worth the difference.
# The regular platform interpolates a continuous gradient over the same anchors
# and does not use steps (refer to ui.widgets._recency_style). Thus these styles
# are its ladder, written out. Each style name resolves on both platforms in
# both cases.
"heat.now": "#ffffff", # under 5 minutes
"heat.minutes": "#facc15", # 5 minutes
"heat.hours": "#f87171", # 1 hour
"heat.days": "#b45309", # 1 day
"heat.weeks": "#dc2626", # 1 week
"heat.months": "#a2a2a2", # 1 month
"heat.never": "#737373", # 1 year, and never heard
# The chip fills of the F-key lane of the PicoCalc (refer to ui.tui.fkeys): grey for
# the plain F1-F5 bank, and green while the Shift watcher reports F6-F10. The text
# is white on both. They are also defined here so that the style names resolve on
# each platform, although only footer_fkeys of the PicoCalc asks for them.
"fkey.chip": "bold #ffffff on #545454",
"fkey.chip.shift": "bold #ffffff on #16a34a",
# The fills of the Cardputer Zero deck, taken from its own keyboard (JP,
# 2026-09-30). The normal fill is the orange-red in which the fn key and its F4–F8
# legends are printed. While Shift is held, the fill is the dark blue of the Shift
# key in the product photo of M5. White text on the #ff6633 of the print has a
# contrast of only 2.9:1. Thus the chip is that colour, a step darker in OKLab at
# the same hue and chroma (L 0.70 → 0.62, h 38°). The contrast is 4.0:1, and the
# colour is still a vermilion. The chroma is in the gamut all the way down, so
# nothing makes it a grey brown.
"fkey.chip.cardputer_zero": "bold #ffffff on #e34b0f",
"fkey.chip.cardputer_zero.shift": "bold #ffffff on #0f72bd",
# The prose voices: the styles of the inline marks of a markdown page (refer to
# ui.markdown). Headings use the styles that the rest of the app already uses for
# section headings (``brand``, ``accent``). Thus only the *body* marks need their own
# names. Emphasis is brighter than the page. An aside is dimmer than the page. Code
# and links have the two hues that nothing else in a body uses. A struck-out run
# goes back to the placeholder grey. The bullet and the rail are the chrome on which
# a list or a quote hangs.
"md.strong": "bold #e7e7e7",
"md.em": "italic #d4d4d4",
"md.code": "#7dd3fc",
"md.link": "underline #a5b4fc",
"md.quote": "italic #d4d4d4",
"md.strike": "strike #737373",
"md.bullet": "#818cf8",
"md.rail": "#545454",
}
)
#: The 16 palette slots of the PicoCalc console. They are the **standard kernel VT
#: palette**, by the decision of JP (2026-08-01): the console is not remapped, so other
#: software looks stock and the black background stays black. :data:`MESH_THEME_16` is
#: designed for these RGB values, and the quantizer of the fold for stray truecolor
#: matches against them. These rules still apply:
#:
#: * The kernel VT renders **bold as brightness**. ``bold`` on a foreground in slots 0–7
#: moves it to slot N+8. Thus a style for a dim slot must say what it means about
#: bold. It cannot leave the question open, because Rich merges a *base* style into
#: each span that it wraps. Then the ``bold`` of a selected row changes the colour of
#: the span completely (a light-grey unknown hash becomes the white "you", and a brown
#: highway becomes yellow). Thus each style for a dim slot is explicitly ``not bold``
#: (the colour is necessary, so keep it) or explicitly ``bold`` (the promotion is the
#: intent, and only ``title.muted``, ``title.border``, and ``heading`` have it). A
#: style is never silent. Never use bold on 5 and 6, because *different* meanings use
#: their partners here (5 purple = the chip fill under a pink name, 13 pink = a node
#: hue. 6 cyan = hint.brand, 14 bright cyan = brand).
#: * Backgrounds can use only slots 0–7 (SGR 40–47).
#: * The six *chromatic bright* slots (9-14) are the place where the spectrum of node
#: names lands. They are the console rendering of the hue wheel that comes from the key
#: (refer to :data:`_NODE_SLOT_HEXES`).
_VT_SLOTS: tuple[tuple[int, str, str], ...] = (
(0, "background (black)", "#000000"),
(1, "red (hint.err)", "#aa0000"),
(2, "green (hint.ok)", "#00aa00"),
(3, "brown/orange (hint.warn, batt.mid, heat.cool, the sensor mark)", "#aa5500"),
(4, "blue (bluetooth bg, map.water)", "#0000aa"),
(5, "purple (the chip fill of node hue 300°)", "#aa00aa"),
(6, "cyan (hint.brand)", "#00aaaa"),
(7, "light grey (default text, heat.cold)", "#aaaaaa"),
(8, "dark grey (muted, faint, track)", "#555555"),
(9, "bright red (err; node hue 0°)", "#ff5555"),
(10, "bright green (ok; node hue 120°)", "#55ff55"),
(11, "bright yellow (warn, heat.warm; node hue 60°)", "#ffff55"),
(12, "bright blue (accent; node hue 240°)", "#5555ff"),
(13, "bright pink (node hue 300°)", "#ff55ff"),
(14, "bright cyan (brand; node hue 180°)", "#55ffff"),
(15, "white (you, heat.hot, the node-type marks)", "#ffffff"),
)
def slot_hex(slot: int) -> str:
"""The ``#rrggbb`` of one console palette slot (:data:`_VT_SLOTS`)."""
return _VT_SLOTS[slot][2]
#: Each bright console slot and its twin in the dim bank: slot ``n + 8`` and slot ``n``.
#: The VT draws a background only from slots 0 to 7. Thus, where a bright colour must
#: become a fill, its dim twin is the only background of the same hue. The path chips
#: use this pair on the console (:mod:`~meshterm.ui.pathline`): the dim twin is the fill,
#: and the bright slot is the text on it.
DIM_TWIN: dict[str, str] = {slot_hex(n + 8): slot_hex(n) for n in range(8)}
#: The custom 16-slot remap that P3 shipped first (tailwind-family RGB values that
#: ``setvtrgb`` programs). It is **archived and not installed**. JP chose the standard
#: palette, but he asked us to keep this remap in case he changes his mind. To install it
#: again, run
#: ``MESHTERM_CUSTOM_PALETTE=1 sh scripts/picocalc-lyra/calculinux-console-font-6x12.sh``
#: (a test makes sure that its opt-in block is byte-identical to :func:`vtrgb_lines`).
#: Then MESH_THEME_16 needs a new adjustment for this remap (refer to the git history at
#: c6485c6 for the matching theme).
_VT_SLOTS_CUSTOM: tuple[tuple[int, str, str], ...] = (
(0, "background slate", "#0f172a"),
(1, "red (hint.err)", "#ef4444"),
(2, "green (hint.ok)", "#22c55e"),
(3, "orange", "#f59e0b"),
(4, "indigo (hint.accent)", "#6366f1"),
(5, "track slate (was magenta)", "#334155"),
(6, "faint slate (was dim cyan)", "#64748b"),
(7, "light grey", "#cbd5e1"),
(8, "muted grey", "#94a3b8"),
(9, "err red", "#f87171"),
(10, "ok green", "#4ade80"),
(11, "warn amber", "#fbbf24"),
(12, "accent indigo", "#818cf8"),
(13, "lavender", "#a5b4fc"),
(14, "brand teal", "#5eead4"),
(15, "white", "#ffffff"),
)
def vtrgb_lines() -> str:
"""The ``setvtrgb`` file content for the **archived custom** palette (three CSV lines).
MeshTerm does not install it by default (refer to :data:`_VT_SLOTS_CUSTOM`). The
opt-in block of the deploy script has this same content as literal text (a test makes
sure that the two are the same). Thus the remap needs only one environment variable,
and it does not need Python.
"""
channels = []
for shift in (16, 8, 0):
values = [(int(hex_.lstrip("#"), 16) >> shift) & 0xFF for _, _, hex_ in _VT_SLOTS_CUSTOM]
channels.append(",".join(str(v) for v in values))
return "\n".join(channels) + "\n"
#: The 16-slot palette theme. It has the same style names as :data:`MESH_THEME`, written
#: as ``color(N)`` references into :data:`_VT_SLOTS`, which is the **standard** VT
#: palette. The theme is literal and not derived, so a reviewer can see each slot choice
#: next to its meaning. The rules for bold-brightness and for backgrounds, which the
#: theme must obey, are in the documentation of :data:`_VT_SLOTS`, and tests pin them.
#: The stock palette has a cost: the three greys (muted, faint, track) all become slot 8,
#: because the stock palette has only one dark grey.
MESH_THEME_16 = Theme(
{
"brand": "bold color(14)",
"accent": "bold color(12)",
# The border: the light grey, which is the nearest slot to the grey of the regular
# theme. It is ``not bold`` because 7 is a dim slot (refer to the bold rule).
"border": "not bold color(7)",
# Slot 7 is the only grey that the VT can put *behind* a reverse. Backgrounds stop at
# the dim bank, and the dark grey of slot 8 becomes black there. It is the same
# light grey that the F-key lane already uses to fill its chips. This is the
# purpose: the handheld has one chip look. It is ``not bold`` because 7 is a dim
# slot (refer to the bold rule).
"selected": "reverse not bold color(7)",
# Slot 15 is the top of the bright bank. Thus the bold of the row cannot promote it
# more, and each span for a dim slot in the row behaves as it did under the old
# highlight, which was bold slot 14.
"cursor": "bold color(15)",
# The mark for the extent of a path line (refer to the regular theme). It draws
# nothing. Both themes have the same style names, and the fold that reads it runs on
# both.
"pathline": "none",
"err.reverse": "reverse bold color(9)",
"you": "bold color(15)",
# Slot 15 is already bright, so it needs no bold to be white.
"compose": "color(15)",
"device.known": "bold color(15)",
"qr": "bold color(15) on color(0)",
"bluetooth": "bold color(15) on color(4)",
"bluetooth.edge": "not bold color(4)",
"ok": "bold color(10)",
"warn": "bold color(11)",
"err": "bold color(9)",
"muted": "color(8)",
# Light grey, a step *above* the dark grey of muted. The console has only two greys,
# and the hash of an unidentified node is content, not chrome.
"node.unknown": "not bold color(7)",
# Two of the promotions on purpose (``heading`` is the third): bold moves slot 7 to
# 15. These titles need a colour that is almost white (the regular theme writes it
# as #d4d4d4).
"title.border": "bold color(7)",
"title.muted": "bold color(7)",
"title.warn": "bold color(11)",
"title.err": "bold color(9)",
"title.ok": "bold color(10)",
"title.brand": "bold color(14)",
"hint.border": "color(8)",
"hint.muted": "color(8)",
"hint.warn": "not bold color(3)",
"hint.err": "not bold color(1)",
"hint.ok": "not bold color(2)",
"hint.brand": "not bold color(6)",
"faint": "color(8)",
# The colour of the title of the frame (``title.border``): bold moves slot 7 to 15.
"heading": "bold color(7)",
"track": "color(8)",
"snr.good": "bold color(10)",
"snr.ok": "bold color(11)",
"snr.bad": "bold color(9)",
# Each band is its bright slot over its own dim slot. The console has only one pair
# for each hue, and backgrounds stop at the dim bank. Thus the block look is the
# natural shape of the palette, and it is not a compromise.
"batt.full": "bold color(10) on color(2)",
"batt.mid": "bold color(11) on color(3)",
"batt.low": "bold color(9) on color(1)",
"batt.flash": "not bold color(0) on color(1)",
"batt.flash.off": "bold color(9)",
"type.node": "color(15)",
"type.repeater": "color(15)",
"type.room": "color(15)",
"type.sensor": "color(15)",
# A region name: the VT has no italic, and each chromatic slot is a node hue. Thus
# the style is the plain light grey. The word "scope" in front of it makes it
# different from other text.
"scope": "not bold color(7)",
# The basemap on the console: a body of water is the dim blue, and each watercourse
# that crosses it is the bright blue, one step up. (The palette has only one of
# each. A depth of shade for a river is a luxury of truecolour, and all three
# shades of waterway are the same step here.) Parks are the dim green, and highways
# are brown. Backgrounds are not used here, because the canvas paints these as
# braille dots in the foreground.
"map.water": "not bold color(4)",
"map.river": "color(12)",
"map.stream": "color(12)",
"map.ditch": "color(12)",
"map.park": "not bold color(2)",
"map.highway": "not bold color(3)",
# The ladder of JP, slot by slot: white-hot, yellow, the light red of the ember,
# brown as it chars, dark red as it dies, then ash (light grey), and cold grey for
# a node that is the same as never heard. Six of the sixteen slots make the whole
# scale. The three styles in the dim bank say ``not bold``. If they do not, a
# selected row moves them up one step.
"heat.now": "bold color(15)",
"heat.minutes": "color(11)",
"heat.hours": "color(9)",
"heat.days": "not bold color(3)",
"heat.weeks": "not bold color(1)",
"heat.months": "not bold color(7)",
"heat.never": "color(8)",
# The chip fills of the F-key lane: a light-grey background for the plain bank (the
# only grey in the palette that a background can use, refer to _VT_SLOTS), green for
# the Shift bank, and white text on both.
"fkey.chip": "bold color(15) on color(7)",
"fkey.chip.shift": "bold color(15) on color(2)",
# The fills of the Cardputer deck, so that the names are the same on both. That
# display is truecolor, so these are only the nearest slots to its colours: red for
# the orange of the fn key, and blue for the blue of Shift.
"fkey.chip.cardputer_zero": "bold color(15) on color(1)",
"fkey.chip.cardputer_zero.shift": "bold color(15) on color(4)",
# Prose on the console. The body text of the page is the default light grey (slot
# 7). Thus emphasis is the one place where the rule of the VT, bold is brightness,
# is the design. ``md.strong`` says bold on purpose and becomes white. This is the
# same step up that the truecolor theme writes out. Its opposite, ``md.em``, keeps
# slot 7 because it says so. Code uses the dim cyan (never bold, because the bright
# partner of slot 6 is the brand). Links use the bright blue. The chrome (bullets,
# rails, a struck-out run) uses the two greys. Thus a page reads as text with
# landmarks and not as a colour chart. There is no underline anywhere. The VT
# renders it with the console's own underline colour, and this removes the hue
# of the link.
"md.strong": "bold color(7)",
"md.em": "not bold color(7)",
"md.code": "not bold color(6)",
"md.link": "color(12)",
"md.quote": "not bold color(7)",
"md.strike": "color(8)",
"md.bullet": "color(12)",
"md.rail": "color(8)",
}
)
def active_theme() -> Theme:
"""The theme of the platform: :data:`MESH_THEME`, or :data:`MESH_THEME_16` on the PicoCalc."""
return _ACTIVE_THEME
def mark_rgb(colour: str) -> tuple[int, int, int]:
"""A marker colour as an RGB triple, for the braille rasters.
A canvas paints in RGB and not in styles (refer to :mod:`~meshterm.ui.mapcanvas`). A
marker that must agree with its twin that Rich draws gets the *same colour* through
this function. The function accepts both encodings that the marker language uses. One
is a literal ``#rrggbb`` (for example a grey of the basemap in
:mod:`~meshterm.ui.map_render`). The other is a theme style name (the marks in
:mod:`~meshterm.ui.marks` and the node-type marks, which have names so that each
platform chooses its own colour, refer to ``type.*`` of the theme).
On the 16-slot theme, a ``color(N)`` entry gives the :data:`_VT_SLOTS` RGB of that
slot, and not the stock triple of Rich. The palette of Rich is a shade different from
ours, and the quantizer of the fold matches against ours. If the function used the
stock triple, the round trip would put the marker on a neighbouring slot. The
function caches its results (a platform switch clears the cache). A raster asks for
each node.
Args:
colour: ``#rrggbb``, or a style name that both themes define.
Returns:
The ``(r, g, b)`` triple. For a style that has no colour of its own, it is mid-grey.
"""
cached = _STYLE_RGB.get(colour)
if cached is None:
if colour.startswith("#"):
cached = (int(colour[1:3], 16), int(colour[3:5], 16), int(colour[5:7], 16))
else:
color = _ACTIVE_THEME.styles[colour].color
if color is None:
cached = (0xAA, 0xAA, 0xAA)
elif color.number is not None and color.number < len(_SLOT_RGBS):
cached = _SLOT_RGBS[color.number]
else:
triplet = color.get_truecolor()
cached = (triplet.red, triplet.green, triplet.blue)
_STYLE_RGB[colour] = cached
return cached
def make_console() -> Console:
"""Create the Rich console of the application, with its theme.
On legacy Windows consoles, the standard streams use ``cp1252`` by default. It cannot
encode the box-drawing and marker glyphs (``◆ ● ★``) that the UI uses. This function
sets the streams to UTF-8 where the runtime lets it, so that the output never raises
``UnicodeEncodeError``.
Returns:
A :class:`rich.console.Console` configured with the platform's theme.
"""
import sys
for stream in (sys.stdout, sys.stderr):
reconfigure = getattr(stream, "reconfigure", None)
if reconfigure is not None:
try:
reconfigure(encoding="utf-8")
except (ValueError, OSError): # pragma: no cover - the stream cannot be set again
pass
return Console(theme=_ACTIVE_THEME)
def title_style(border_style: str) -> str:
"""Return the title style that matches the border of a panel: the same hue, brighter.
Args:
border_style: The theme name in which the border of the panel is drawn
(``"border"``, ``"warn"``, and other names).
Returns:
The matching ``title.*`` theme name. If no brighter variant is defined, it is
``border_style`` itself. Thus an unknown border still gets a title with a
consistent tint.
"""
name = f"title.{border_style}"
return name if name in MESH_THEME.styles else border_style
def hint_style(border_style: str) -> str:
"""Return the footer-hint style that matches the border of a panel: the same hue, muted.
This is the counterpart of :func:`title_style` for the bottom border. The title
brightens the hue of the border, and the hint darkens it. Thus both read as part of
the frame, with the correct emphasis.
Args:
border_style: The theme name in which the border of the panel is drawn
(``"border"``, ``"warn"``, and other names).
Returns:
The matching ``hint.*`` theme name. If no variant is defined, it is ``"muted"``.
Thus an unknown border keeps the old neutral hint, and not a loud hint.
"""
name = f"hint.{border_style}"
return name if name in MESH_THEME.styles else "muted"
#: The per-node hue *spectrum*. The first key byte of a node maps directly onto the HSV
#: colour wheel. ``0x00`` is red, and the full spectrum goes round to ``0xff``. The
#: saturation and the value are fixed, and they are tuned to stay vivid and readable on
#: the dark theme in all hues. Thus the 256 values of the first byte give 256 different
#: hues on a truecolor terminal. The rule of the whole app is the same on each platform:
#: the *name* of a node always has a colour. The colour comes from this wheel, and the key
#: of the node selects it (refer to :func:`node_style`). Thus the colour is the identity
#: of the node. It stays the same after a rename, and it is the same on each surface that
#: knows any prefix of the key. Our node always has the pure-white ``you`` style instead,
#: so "us" never merges into the crowd. The node lists, the chat transcript, the feed of
#: the dashboard, and the packet viewer share this wheel. Thus one node has one colour
#: everywhere.
_NODE_HUE_SAT = 0.65
_NODE_HUE_VAL = 0.95
#: The same wheel at the resolution of the PicoCalc console: the six *chromatic bright*
#: palette slots. They are in hue order, from red. Thus the index ``round(byte / 256 * 6)
#: % 6`` is the sector in which the hue of the byte is. The sectors are 60° apart, and
#: each is an equal sixth of the wheel. They are written as the :data:`_VT_SLOTS` RGB
#: values of the slots and not as ``color(N)``. Thus the value is still a hex that a
#: caller can parse into an RGB (the map canvas and the mesh walk read the hue from the
#: style string). Also, each downsample on the way out (the downsample of Rich, and
#: :func:`_quantize_sgr` of the fold) gives exactly that slot, and does not guess.
# fmt: off
_NODE_SLOT_HEXES: tuple[str, ...] = (
"#ff5555", # 9 red : 0°
"#ffff55", # 11 yellow : 60°
"#55ff55", # 10 green : 120°
"#55ffff", # 14 cyan : 180°
"#5555ff", # 12 blue : 240°
"#ff55ff", # 13 magenta : 300°
)
# fmt: on
def node_style(key: str) -> str:
"""The stable spectrum hue that the *key* of a node selects: the node colour from its hash.
The first key byte of the node maps directly onto the HSV colour wheel (``0x00`` is
red, and the wheel goes round to ``0xff``). Thus the colour is the identity of the
node. It stays the same after a rename. Only the first byte selects it, so each prefix
that a surface has gives the same hue. A prefix can be a 2-hex path hop, the stored
12-hex id, or the full 64-hex public key. One node has one colour, in whatever way
MeshTerm learned it.
The *rule* is the only constant of the platform here, but the resolution is not. The
regular platform uses the full spectrum (:func:`_node_style_spectrum`, 256 different
hues at a fixed saturation and value). The PicoCalc snaps the same hue to the nearest
of the six chromatic slots of the console (:func:`_node_style_quantized`). The heat
scale of the heard age quantizes its gradient in the same way there. MeshTerm binds
this function when the platform switches.
Args:
key: The key or hash of the node as hex (any length of 1 byte or more. The
function accepts ``0x`` and mixed case).
Returns:
A ``"bold #rrggbb"`` style string.
"""
return _node_impl(key)
def _key_byte(key: str) -> int:
"""The first key byte of the node: the only slice from which each hue comes."""
raw = key.lower().removeprefix("0x")
try:
return int(raw[:2], 16)
except ValueError: # not hex: use the sum of the characters, so that a stable colour shows
return sum(map(ord, raw)) % 256
def _node_style_spectrum(key: str) -> str:
"""The regular platform: the full 256-hue wheel at :data:`_NODE_HUE_SAT`/``_VAL``."""
r, g, b = colorsys.hsv_to_rgb(_key_byte(key) / 256, _NODE_HUE_SAT, _NODE_HUE_VAL)
return f"bold #{round(r * 255):02x}{round(g * 255):02x}{round(b * 255):02x}"
def _node_style_quantized(key: str) -> str:
"""The PicoCalc: the same hue, snapped to its sixth of the wheel (:data:`_NODE_SLOT_HEXES`).
This is on purpose, and a downsample to the nearest RGB does not do it. The pastels of
the spectrum are near white. A naive match moves the loudest hues toward grey, and
grey is the colour of ``muted``, which is for a sender without a key. A snap by hue
keeps all six families saturated and evenly populated (approximately 43 of the 256
first bytes for each family).
"""
sector = round(_key_byte(key) / 256 * len(_NODE_SLOT_HEXES)) % len(_NODE_SLOT_HEXES)
return f"bold {_NODE_SLOT_HEXES[sector]}"
#: All the style strings that the key of a node can make, on *each* platform: the 256-hue
#: spectrum and the six chromatic slots of the console. A surface that must *recognize*
#: identity ink, and does not make it, matches against this set. Today this is the cursor
#: row, where a name gives way to the white of the highlight. Both vocabularies are in the
#: one set. Thus the answer does not depend on the platform that is bound, and the two
#: vocabularies cannot collide: the channels of the spectrum have a maximum of ``0xf2``
#: (value 0.95), and each console slot has ``0xff``.
_IDENTITY_STYLES: frozenset[str] = frozenset(
[_node_style_spectrum(f"{byte:02x}") for byte in range(256)]
+ [f"bold {hex_}" for hex_ in _NODE_SLOT_HEXES]
)
def is_identity_style(style: str) -> bool:
"""Check if ``style`` is a hue that the key of a node made (the ink of a *name*).
This function is :func:`node_style` read in reverse. One surface needs the vocabulary
as a question: the cursor row, which draws node names in its white and not in their
own hue (refer to :func:`~meshterm.ui.tui.render.render_to_ansi`). The check is narrow
on purpose. Only a hue that comes from a key gives ``True``. Thus the grey of
``node.unknown`` (a node that MeshTerm *cannot* identify, and the highlight must not
claim to know it), the white of ``you``, and each colouring for a context keep the
colour that they have.
Args:
style: A style string as it appears on a span.
Returns:
``True`` if it is one of the hues that :func:`node_style` makes.
"""
return style in _IDENTITY_STYLES
def name_style(name: str, key: str | None = None) -> str:
"""The stable colour of the name of a node or sender. The key of the node selects it.
The rule is the same on each platform. The hue is the spectrum of :func:`node_style`
that comes from the key. Thus a rename keeps the colour, and each surface that knows
any prefix of the key gives the same colour. Only the *resolution* changes with the
platform (refer to :func:`node_style`).
Args:
name: The display name. It does not change the hue. It is a parameter so that each
call site reads ``name_style(name, key)``, and a search finds the pair.
key: Any known prefix of the key or hash of the node. ``None`` or an empty value
marks a sender whose key MeshTerm could not resolve. The name is drawn as
``node.unknown``, because colour is only for identities that have a key.
(A caller that has only a name must first resolve it with
:func:`~meshterm.services.trace_runner.make_name_key_resolver`.)
Returns:
A style for the name. On one platform, the same node always has the same style.
Thus it keeps its colour across screens and sessions.
"""
return node_style(key) if key else "node.unknown"
# -- the compact icon language (PicoCalc) ---------------------------------------------
#: The map from an emoji to a single console-font character. It is the icon language of
#: the PicoCalc. There is one glyph for each concept, chosen from the glyphs that the
#: 512-glyph font has (refer to ui.fontset). Concepts that only share a *family* (outbound
#: ↑, route ∟) share a glyph on purpose. The node-type marks (★●▲■◉○) and the status marks
#: (✓ ✗ ⚠ ● ○) are already native to the font, and they are never in this table.
#: ``glyph()`` uses this table at the explicit icon call sites (a lane of 1 cell that the
#: screen composes). The fold at the render boundary uses it for all other cases. It adds
#: padding to the measured width of the emoji, so that the layout stays correct.
# fmt: off
_GLYPH_MAP: dict[str, str] = {
# Packet classes (KIND_ICONS)
"📢": "☼", # advert: a node that radiates its presence
"📊": "≈", # telemetry: a waveform of readings
"📦": "▬", # packet: a plain slab of payload
"💬": "¶", # message: text
"✅": "✓", # ack (the ok-family check)
"❔": "·", # unknown class: a neutral dot
# Raw payload classes (PAYLOAD_ICONS). Raw ADVERT and ACK use ☼ and ✓ above
"📥": "↑", # REQ: a question that goes out
"📮": "↓", # RESPONSE: the answer that comes back
"📩": "→", # TEXT_MSG (an overheard direct message): text in flight
"📻": "#", # GRP_TXT: channel text (the # channel mark)
"💽": "§", # GRP_DATA: a data section on a channel
"🎭": "?", # ANON_REQ: a request from an identity that is not proven
"🧭": "∟", # PATH: a route with a bend in it
"🎯": "⌖", # TRACE: the crosshair (also the position mark of the map)
"🧩": "▒", # MULTIPART: a packet in fragments
"🧰": "↨", # CONTROL: adjustment up and down
# Channel openness (widgets.channel_glyph)
"#": "#", # name-derived channel
"🌐": "@", # well-known public channel
"🔒": "⚿", # private channel, locked contact (the padlock mark)
"🔓": "⚿", # unlock: the same padlock. The word of the row says which way it turns
# Concept icons (menu/list rows)
"📡": "☼", # advert tool: the same concept as the advert class
"🕒": "◷", # clock/sync (the clock-face mark)
"🔄": "°", # reboot: the power dot
"💾": "⌂", # backup: put it in a safe place
"📂": "^", # restore: bring it back up
"🔑": "*", # channel/credential key: the asterisk of a masked secret
"🔐": "*", # identity/auth secret: the same mark for secret material
"🗑": "✗", # clear/delete: the destructive mark
"✎": "~", # compose/edit: a scribble
"⚡": "!", # explore/probe
"⭐": "+", # watch: added to the watchlist
"📤": "↑", # send now (outbound family)
"📨": "=", # courier/queue: stacked letters
"🔔": "•", # notify: the badge dot
"🔕": "·", # mute: the hollowed-out dot
"📱": "▓", # QR: a dense block
"🔗": "&", # link: the joining glyph
"🏆": "★", # trophy case: the best/winner star
"⌨": "❯", # command line: the prompt pointer
"📖": "¶", # read/about: a page of prose (the text mark, as ¶ is for a message)
"💰": "$", # support/donate: the plainest money mark
"🚪": "", # quit: no icon, because the word carries it
"🌍": "@", # map/world (globe family)
"🕸": "∟", # mesh walk (route family)
"🚨": "⚠", # watchtower alert
"🛣": "∟", # longest-haul route (route family)
"🏹": "►", # longest leg: the arrow in flight (▶ is the run *action*, not this)
"🧳": "→", # trip/journey
"🔆": "°", # the brightest reception
"📶": "≥", # TX power sweep: the power ramp
"🔧": "⚙", # config (parameter concept)
"📋": "i", # info
"🩺": "?", # diagnostics: the question that a diagnosis answers (🎭 has the same mark)
"📰": "…", # live feed: a stream of items
"🎧": "≈", # monitor: listening to the waveform
"🗼": "▲", # repeater admin: the repeater mark itself
"📌": "■", # rooms: the room server mark itself
"🔌": "~", # serial port: the cable
"📍": "╨", # a radio on the SPI bus of the host: the antenna on its board
"🔖": "╬", # region/scope: the label of a flood. It is the grid square of an area
"👤": "%", # a person (two-circle silhouette)
"👥": "%", # contacts: people
"👋": "", # a wave in prose: the words carry it
"⏳": "…", # pending/waiting
"+": "+", # fullwidth plus (the add row of the channels)
}
# fmt: on
def glyph(icon: str) -> str:
"""The rendering of an icon on the platform: the emoji itself, or its compact glyph.
On the regular platform, this function returns the icon unchanged, and emoji icons
render as themselves. On the PicoCalc, each icon goes through :data:`_GLYPH_MAP` to a
single console-font character. An icon that is not in the map passes through. The test
for the glyph whitelist or the fold at the render boundary finds it. This function
does not invent a glyph without a message. The compact form is *one cell*, where the
emoji was two cells. Call sites compose their lanes from the returned glyph, so the
lane becomes narrower on the PicoCalc.
Args:
icon: An emoji, a node/status mark, or any literal character.
Returns:
The characters to render for it on the active platform.
"""
return _glyph_impl(icon)
def _glyph_identity(icon: str) -> str:
"""The regular platform: icons render as themselves."""
return icon
#: The emoticons that stand in for the default reactions where the font has no emoji (the
#: PicoCalc and the Cardputer). Each default reaction is two cells wide, and so is each
#: emoticon. Thus a badge keeps its width. A reaction that is not here goes through the fold
#: at the render boundary, as each other emoji does.
# fmt: off
_EMOTICONS: dict[str, str] = {
"👍": "+1", "👎": "-1", "❤️": "<3", "❤": "<3", "😂": ":D", "😮": ":O", "😢": ":(",
}
# fmt: on
def emoticon(emoji: str) -> str:
"""The rendering of a reaction on the platform: the emoji itself, or its emoticon.
Args:
emoji: The emoji of a reaction (one glyph, refer to
:func:`~meshterm.core.reactions.is_reaction_emoji`).
Returns:
The emoji on a platform that draws emoji. Else its emoticon from
:data:`_EMOTICONS`, or the emoji unchanged when it has none.
"""
return _emoticon_impl(emoji)
def _emoticon_compact(emoji: str) -> str:
"""A platform with no emoji: the emoticon of a reaction, when it has one."""
return _EMOTICONS.get(emoji, emoji)
def _glyph_compact(icon: str) -> str:
"""The PicoCalc: icons become their single-cell console-font glyph."""
return _GLYPH_MAP.get(icon, icon)
# -- the render-boundary fold (the handhelds) ------------------------------------------
#: The glyph inventory of the active platform (``Platform.font``). :func:`_bind` binds it.
#: It is the 512-glyph console font of the PicoCalc, or what the emulator of the Cardputer
#: draws.
_FOLD_FONT: frozenset[int] = FONT_CODEPOINTS
#: The characters that the fold lets stay: each font codepoint, and the C0 controls from
#: which the rendered ANSI is made (ESC in its sequences, and the newlines between lines).
_FOLD_ALLOWED: frozenset[int] = _FOLD_FONT.union(range(0x00, 0x20))
#: Shows if the fold also quantizes embedded truecolor and 256-colour SGR to the 16 slots.
#: It does this for a console that has no more colours (the PicoCalc). It never does this
#: for a display that the host paints in 24 bits.
_FOLD_QUANTIZE = True
#: The characters of width 1 that are not in the font and have a natural stand-in of width
#: 1. The translation table of the fold applies them (MeshTerm never changes the stored
#: data). The characters that are *in* the font are never here, and they pass through
#: without a change. These are ``— … ⋯ ⚠ ⌫ ⇧ ⚙ ↻ ◷ ⌖ ⚿ ← ↑ → ↓ ↔ ↕ • ·`` and the Cyrillic
#: block.
# fmt: off
_FOLD_SINGLES: dict[str, str] = {
"–": "-", "−": "-", "‒": "-", "―": "—",
"‘": "'", "’": "'", "‚": "'", "“": '"', "”": '"', "„": '"',
"‹": "<", "›": ">", "«": "<", "»": ">",
"×": "x", "÷": "/", "⁄": "/", "∙": "·", "∘": "·",
"⇒": "→", "⇐": "←", "⇣": "↓", "⇡": "↑", "↩": "←", "↪": "→",
"⇄": "↔", "⟷": "↔", "⟺": "↔", "⟲": "↻", "⟳": "↻",
"✕": "✗", "✖": "✗", "✔": "✓",
"ᛒ": "B", # the Bluetooth badge rune
"œ": "o", "Œ": "O", "æ": "a", "Æ": "A", "ø": "o", "Ø": "O",
"ß": "s", "þ": "p", "Þ": "P", "ð": "d", "Ð": "D", "đ": "d", "Đ": "D",
"ł": "l", "Ł": "L", "ı": "i",
# Chrome of the powerline path pills (private use): the caps become half-blocks, and
# the separator becomes a plain wedge.
"": ">", "": "▌", "": "▐",
# The zero-width characters fold away completely (width 0 becomes empty, and the
# arithmetic of the cells stays exact): VS16, ZWJ, ZWSP.
"️": "", "‍": "", "​": "",
}
# fmt: on
#: MeshTerm builds it lazily, at the first fold. It is the ``str.translate`` table. It has
#: the accent folds (NFKD, computed one time over the Latin ranges), :data:`_FOLD_SINGLES`,
#: and the emoji map padded to the measured cell width of each emoji.
_FOLD_TABLE: dict[int, str] | None = None
#: The SGR sequences for truecolor and 256 colours that are embedded in *pre-rendered*
#: ANSI. The rasterizer console downsamples all that it renders itself. But the braille
#: canvases (``ui.mapcanvas``) emit their own truecolor escapes, which pass through Rich
#: without a change. The fold quantizes those to the 16 slots, so that the contract holds
#: for each byte that goes out. The pattern matches any SGR sequence, and it captures its
#: whole parameter list. The quantizer walks the list and does not match a colour that
#: stands alone. Rich writes the foreground and the background of a style as *one*
#: sequence (``ESC[38;2;255;255;255;48;2;0;0;0m``, a QR module). A colour in the middle of
#: such a list is a colour also when other parameters are with it.
_SGR = _re.compile(r"\x1b\[([\d;]*)m")
_SLOT_RGBS: tuple[tuple[int, int, int], ...] = tuple(
(int(h.lstrip("#")[0:2], 16), int(h.lstrip("#")[2:4], 16), int(h.lstrip("#")[4:6], 16))
for _, _, h in _VT_SLOTS
)
#: The map from (is_background, r, g, b) to the replacement SGR string. The app uses a few
#: dozen different colours, so this cache stays very small.
_SLOT_CACHE: dict[tuple[bool, int, int, int], str] = {}
def _nearest_slot_params(background: bool, r: int, g: int, b: int) -> str:
"""The 16-colour SGR *parameters* closest to ``(r, g, b)`` in :data:`_VT_SLOTS`.
Examples are ``22;31``, ``91``, and ``40``. They do not have the ``ESC[…m`` around
them. Thus the caller can splice the answer into a sequence that also has other
parameters (:func:`_quantize_sgr`).
A foreground can be in any slot (30–37 and 90–97). A background can be only in slots
0–7 (the VT has no bright backgrounds). Thus a bright colour that is used as a fill
gets the related colour in the dim bank.
A **foreground in the dim bank states its intent** (``22;3N``, normal intensity), and
does not emit a bare ``3N``. Bold is brightness on the VT, and ``9N`` is the way that
the console spells bright. Thus a bare ``31`` immediately after a ``90`` inherits the
intensity bit, and renders as ``91`` without a message. Quantized art is a run of
colour spans that are next to each other, and no style boundary between them resets
the intensity (the bevel of the wordmark has a grey span in every other span, and a
map raster has neighbouring cells). Thus the promotion happens in the middle of a row
and gives the wrong colour to half a row. The fourth row of the narrow wordmark (its
only row with a dim slot) was part red and part light red. The split was at each span
that followed the bevel.
"""
key = (background, r, g, b)
cached = _SLOT_CACHE.get(key)
if cached is None:
candidates = _SLOT_RGBS[:8] if background else _SLOT_RGBS
slot = min(
range(len(candidates)),
key=lambda i: (
(candidates[i][0] - r) ** 2
+ (candidates[i][1] - g) ** 2
+ (candidates[i][2] - b) ** 2
),
)
if background:
sgr = f"{40 + slot}"
elif slot < 8:
sgr = f"22;{30 + slot}"
else:
sgr = f"{90 + slot - 8}"
cached = _SLOT_CACHE[key] = sgr
return cached
def _rgb_of_256(index: int) -> tuple[int, int, int]:
"""The canonical RGB of xterm-256 ``index`` (the cube and the ramps of grey)."""
if index < 16:
return _SLOT_RGBS[index]
if index < 232:
index -= 16
steps = (0, 95, 135, 175, 215, 255)
return (steps[index // 36], steps[index // 6 % 6], steps[index % 6])
grey = 8 + (index - 232) * 10
return (grey, grey, grey)
def _quantize_sgr(text: str) -> str:
"""Fold each embedded truecolor or 256-colour SGR down to the 16 palette slots.
The function walks the parameter list of each sequence (refer to :data:`_SGR`). Thus a
colour folds when it stands alone, and when it shares its sequence with a second
colour or an attribute.
"""
if "8;2;" not in text and "8;5;" not in text:
return text
return _SGR.sub(_quantize_params, text)
def _quantize_params(match: _re.Match[str]) -> str:
"""One SGR sequence, in which each ``38/48;2;r;g;b`` and ``38/48;5;n`` is folded to a slot."""
params = match.group(1).split(";")
out: list[str] = []
i = 0
while i < len(params):
head = params[i]
if head in ("38", "48") and i + 1 < len(params):
background = head == "48"
mode = params[i + 1]
rgb = params[i + 2 : i + 5]
if mode == "2" and len(rgb) == 3 and all(x.isdigit() for x in rgb):
out.append(_nearest_slot_params(background, *(int(x) for x in rgb)))
i += 5
continue
if mode == "5" and i + 2 < len(params) and params[i + 2].isdigit():
out.append(_nearest_slot_params(background, *_rgb_of_256(int(params[i + 2]))))
i += 3
continue
out.append(head)
i += 1
return f"\x1b[{';'.join(out)}m"
def _build_fold_table() -> dict[int, str]:
"""Compose the full translation table (refer to :data:`_FOLD_TABLE`).
The table has only what the active font does not have. A character that the font
draws passes as itself. Thus the Cardputer keeps the accents, the chevrons, and the
other glyphs that Terminus has and the 512 glyphs of the PicoCalc do not have.
"""
import unicodedata
table: dict[int, str] = {}
# Accented Latin (the base table of the font is Terminus with Cyrillic coverage, with
# *no* accented Latin at all) and the fullwidth forms. Decompose with NFKD, remove the
# combining marks, and keep one clean ASCII character, if there is one. é→e, Å→A, #→#,
# fi→(skipped, because it is two characters).
for first, last in ((0x00A1, 0x024F), (0x1E00, 0x1EFF), (0xFF01, 0xFF5E)):
for cp in range(first, last + 1):
if cp in _FOLD_FONT:
continue
decomposed = unicodedata.normalize("NFKD", chr(cp))
base = "".join(c for c in decomposed if unicodedata.category(c) != "Mn")
if len(base) == 1 and base.isascii() and base.isprintable():
table[cp] = base
for char, replacement in _FOLD_SINGLES.items():
if ord(char) not in _FOLD_FONT:
table[ord(char)] = replacement
for emoji, compact in _GLYPH_MAP.items():
if ord(emoji) not in _FOLD_FONT:
pad = max(0, cell_len(emoji) - cell_len(compact))
table[ord(emoji)] = compact + " " * pad
return table
@lru_cache(maxsize=4096)
def _fold_to_font(text: str) -> str:
"""Fold ``text`` down to the inventory of the console font. The cell widths stay the same.
There are three stages, and the cheapest is first. The first stage is the translation
table (accents, symbol stand-ins, and the emoji map), in one pass at the C level. The
second stage is only for text in which a non-ASCII character is still present. It is
a sweep of each character. It replaces each character that is still not in the font
with ``?`` at the cell width of the character. The sweep is the safety net. It makes
the guarantee of the platform, that there are no wide glyphs (refer to
``session._has_wide_glyph``), true *by construction*. An emoji that this module does
not know still leaves as narrow ``?`` characters, and never as a tofu box that breaks
the cell arithmetic of the frame. The function is cached, because the render output
repeats much from one frame to the next. When this implementation is bound, the fold
does not depend on the platform.
"""
global _FOLD_TABLE
if _FOLD_TABLE is None:
_FOLD_TABLE = _build_fold_table()
folded = (_quantize_sgr(text) if _FOLD_QUANTIZE else text).translate(_FOLD_TABLE)
if folded.isascii():
return folded
if all(ord(ch) in _FOLD_ALLOWED for ch in folded):
return folded
return "".join(ch if ord(ch) in _FOLD_ALLOWED else "?" * max(0, cell_len(ch)) for ch in folded)
def fold_text(text: str) -> str:
"""The text filter at the render boundary for the active platform.
On the regular platform, it returns the text unchanged. On the PicoCalc, it folds each
string that MeshTerm is about to draw, down to the characters that the console font
can show (refer to :func:`_fold_to_font`). The strings are names, message bodies, and
whole rendered ANSI lines. MeshTerm never changes the stored data. ANSI escape
sequences pass through without a change, because they are pure ASCII and the fold
never changes ASCII. The function is applied one time, in one central place, in
:func:`meshterm.ui.tui.render.render_to_ansi`. A screen does not need to call it.
Args:
text: The text to fold (or to pass through).
Returns:
The folded text. The cell widths stay the same (a wide emoji becomes a glyph and
padding).
"""
return _fold_impl(text)
def _no_fold(text: str) -> str:
"""The regular platform: text renders as it is stored."""
return text
def snr_style(snr: float | None) -> str:
"""Return a theme style name that shows the quality of an SNR value.
Args:
snr: An SNR reading in dB, or ``None``.
Returns:
``"snr.good"``, ``"snr.ok"``, ``"snr.bad"``, or ``"muted"`` for ``None``.
"""
if snr is None:
return "muted"
if snr >= 5:
return "snr.good"
if snr >= -5:
return "snr.ok"
return "snr.bad"
# -- platform binding ------------------------------------------------------------------
_ACTIVE_THEME: Theme = MESH_THEME
_node_impl: Callable[[str], str] = _node_style_spectrum
_glyph_impl: Callable[[str], str] = _glyph_identity
_emoticon_impl: Callable[[str], str] = _glyph_identity
_fold_impl: Callable[[str], str] = _no_fold
#: The map from a marker colour to RGB. :func:`mark_rgb` fills it, and a platform switch
#: clears it (the answer is from the *active* theme).
_STYLE_RGB: dict[str, tuple[int, int, int]] = {}
@on_platform
def _bind(platform: Platform) -> None:
"""Bind the choices of the theme that depend on the platform (runs now and at each switch)."""
global _ACTIVE_THEME, _node_impl, _glyph_impl, _emoticon_impl, _fold_impl, _FOLD_TABLE
global _FOLD_FONT, _FOLD_ALLOWED, _FOLD_QUANTIZE
_ACTIVE_THEME = MESH_THEME if platform.truecolor else MESH_THEME_16
_node_impl = _node_style_spectrum if platform.truecolor else _node_style_quantized
_glyph_impl = _glyph_identity if platform.emoji else _glyph_compact
_emoticon_impl = _glyph_identity if platform.emoji else _emoticon_compact
font = FONTS.get(platform.font)
if font is not None:
_FOLD_FONT = font
_FOLD_ALLOWED = font.union(range(0x00, 0x20))
_FOLD_QUANTIZE = not platform.truecolor
_fold_impl = _fold_to_font if font is not None else _no_fold
_STYLE_RGB.clear()
# MeshTerm computes the emoji pads of the fold table with cell_len when it builds the
# table. The cell widths can be measured again or patched (the calibration of the emoji
# width on the regular platform). Thus a platform switch clears the table and the
# cache, and does not trust old pads.
_FOLD_TABLE = None
_fold_to_font.cache_clear()