Files
MeshTerm/meshterm/core/preferences.py
T
Jean-Pierre MartineauandClaude Opus 5 c008aa5f01 Windows got sixteen million colours, everyone else got 256
prompt_toolkit picks the colour depth per output class, and its two classes
disagree: Windows10_Output returns TRUE_COLOR outright, Vt100_Output returns
DEPTH_8_BIT for every TERM but linux. Nothing ever passed color_depth, so the
same build drawing the same theme was 24-bit on Windows and quantized to the
216-colour cube on macOS and Linux — silently, which is why it went unnoticed:
the app looks fine, just flatter.

It costs exactly what this palette is made of. The seven heat.* steps and the
per-node hue wheel are close pastels chosen to be told apart; snapped to the
cube, neighbours collide and an identity stops being distinguishable by hue.
COLORTERM is not consulted by prompt_toolkit at all — only its own
PROMPT_TOOLKIT_COLOR_DEPTH is — so a terminal announcing truecolor the
conventional way was still handed 256.

The resolver only ever raises the verdict. A terminal that cannot be shown to
do better returns None and keeps precisely the depth it had, so a host this has
never heard of cannot be made worse by it: guessing 24-bit at a terminal
without it costs not a duller palette but the colour entirely. A 16-slot
console is never promoted — the theme addresses its palette by index, and RGB
has nowhere to land there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 18:35:32 -04:00

739 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
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.
"""Declarative registry of MeshTerm's own preferences, and the YAML file they live in.
The distinction this module draws is between the three kinds of "setting" the app already
had and never named apart:
* :mod:`meshterm.core.device_config` describes the **radio's** settings — they live in
the companion's firmware, are read live each session, and are edited through the Device
config screen.
* :mod:`meshterm.core.config` (``config.toml``) describes **where things are and which
device to talk to** — the config directory, the database, the named device profiles.
It is machine setup, edited in a text editor, and has no place in a full-screen page.
* This module describes **how MeshTerm itself behaves** — whether it opens the link at
launch, how long it waits between transmissions, how much history it keeps, how a map
frames itself. Those values were scattered through the code as module constants and a
handful of undocumented ``config.toml`` keys with no screen behind them; they are
gathered here, given one place to be changed (the Preferences page), and one file to be
remembered in.
Each :class:`PrefSpec` states a preference's group, type, bounds, and — the point of the
whole exercise — its **default**. A preference is only written to disk once it is changed
away from that default, so the file is a short list of your disagreements with the
built-in behaviour rather than a snapshot that silently pins every value forever. Delete a
key (or use *Reset to defaults*) and the code's default takes over again.
The file is ``<config_dir>/preferences.yaml``, written grouped and commented so it reads
the way the page does. Reads are tolerant: an unknown key, a malformed value, or an
unparseable file costs the override, never the session.
Two access paths, deliberately:
* :attr:`~meshterm.context.AppContext.preferences` is the explicit one, and what every
tool, service, and screen should use.
* :func:`current` is for the render and session layer, which has no context to reach
through — the same reason :func:`meshterm.platforms.get_platform` exists. The context
installs itself there as it is built.
"""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from .atomicwrite import write_atomically
from .watch_store import DEFAULT_SILENCE_HOURS, OFF, SILENCE_CHOICES_H
#: Display groups, in the order the page and the file present them. The order is the
#: order a session happens in — what MeshTerm puts on the air, how loud, what it watches
#: for, how it draws the world, what it keeps, and how it paints. Nothing sorts
#: alphabetically: a reader looking for "how long before I give up on a message" should
#: not have to know it starts with a D.
GROUPS: tuple[str, ...] = (
"Sending",
"TX optimize",
"Watchtower",
"Map",
"History",
"Display",
"Diagnostics",
)
class PreferenceError(ValueError):
"""Raised when a preference value cannot be parsed or fails validation.
The message is user-facing — the CLI prints it, and the editor's typed prompt shows
it under the input as the reason the value was refused.
"""
@dataclass(frozen=True, slots=True)
class PrefSpec:
"""Specification for one preference.
Attributes:
key: Canonical key — the attribute name on :class:`Preferences`, and the key in
the YAML file.
label: Human-friendly name, as the page's SETTING lane shows it.
help: One-line description, as the page's DESCRIPTION lane shows it.
group: One of :data:`GROUPS`.
value_type: ``"str" | "int" | "float" | "bool" | "enum"``.
default: The built-in value, used whenever the file names no override.
choices: For ``enum``, a mapping of allowed value to label.
minimum: Inclusive lower bound for numeric types, if any.
maximum: Inclusive upper bound for numeric types, if any.
unit: Short suffix appended when the value is formatted (``"s"``, ``"dBm"``,
``"days"``) so a bare number in the VALUE lane still says what it counts.
relaunch: Whether the new value only takes hold next launch. Woven into the
description rather than shown as its own mark — it is a caveat about one
preference, not a status the reader scans a column for.
"""
key: str
label: str
help: str
group: str
value_type: str
default: Any
choices: dict[Any, str] | None = None
minimum: float | None = None
maximum: float | None = None
unit: str = ""
relaunch: bool = False
@property
def description(self) -> str:
"""The DESCRIPTION lane's text: the help, plus the relaunch caveat where it applies."""
return f"{self.help} (next launch)" if self.relaunch else self.help
#: The two languages the plain CLI can print a time in. Named for what the reader sees
#: rather than for the mechanism: an *age* is how long ago, a *timestamp* is when.
_CLI_TIME_CHOICES: dict[str, str] = {"relative": "ages", "absolute": "timestamps"}
#: The silence-rule choices, drawn from the Watchtower's own ring so the two never drift.
_SILENCE_CHOICES: dict[int, str] = {
**{h: f"{h} h" for h in SILENCE_CHOICES_H},
OFF: "off",
}
#: How the terminal-width reclaim can be asked for: follow the platform's own verdict, or
#: overrule it in either direction. The row's description asks a yes/no question ("use the
#: column ... ?"), so the values answer it in those words rather than naming the mechanism
#: twice. See :func:`meshterm.ui.tui.session._reclaim_last_column` for why a terminal may
#: need to disagree with its platform.
_WIDTH_CHOICES: dict[str, str] = {"auto": "auto", "yes": "yes", "no": "no"}
#: How many colours to send the terminal. ``auto`` is the honest default and the only
#: value that is not itself a depth: it means "take prompt_toolkit's verdict unless we can
#: positively establish better", which is the difference between fixing a host and breaking
#: one. The rest are named for the count a reader can see rather than for the bit depth,
#: because "24-bit" is a fact about the wire and "16 million" is a fact about the screen.
#: See :func:`meshterm.ui.tui.session._color_depth` for why auto is not simply "the most
#: this terminal can do".
_COLOR_DEPTH_CHOICES: dict[str, str] = {
"auto": "auto",
"truecolor": "16 million",
"256": "256",
"16": "16",
}
#: Every preference MeshTerm has, in page order. Adding one here gives it a row on the
#: Preferences page, a key in the YAML file, a ``preferences get``/``set`` CLI face, and a
#: default — nothing else has to follow.
#: What the log file keeps. The plain level names, which are what every other tool calls
#: these — someone being talked through a problem is being told "set it to debug", not
#: "set it to everything".
#: What MeshTerm may offer when it lands in a console that cannot draw it. Only ever
#: consulted on the classic Windows console, the one host with no font fallback of its own.
_CONSOLE_SETUP_CHOICES: dict[str, str] = {
"auto": "Automatic",
"off": "Leave it alone",
}
_LOG_LEVEL_CHOICES: dict[str, str] = {
"ERROR": "Error",
"WARNING": "Warning",
"INFO": "Info",
"DEBUG": "Debug",
}
PREFERENCES: tuple[PrefSpec, ...] = (
# --- Sending -----------------------------------------------------------------
PrefSpec(
key="trace_cooldown_s",
label="Transmit cooldown",
help="Wait this long before transmitting again",
group="Sending",
value_type="float",
default=5.0,
minimum=0.1, # a floor, not a switch: there is no "off" for duty-cycle courtesy
maximum=60.0,
unit="s",
),
PrefSpec(
key="flood_advert_cooldown_s",
label="Flood cooldown",
help="Extra wait before another mesh-wide advert",
group="Sending",
value_type="float",
default=60.0,
minimum=5.0, # every repeater in range rebroadcasts one; five seconds is the floor
maximum=3600.0,
unit="s",
),
PrefSpec(
key="direct_message_soft_retries",
label="Message retries",
help="Times to resend a message that gets no reply",
group="Sending",
value_type="int",
default=0, # one shot: a re-send is a second transmission on a shared mesh
minimum=0,
maximum=2,
),
# --- TX optimize -------------------------------------------------------------
PrefSpec(
key="tx_opt_min",
label="Range min",
help="Weakest power it will try",
group="TX optimize",
value_type="int",
default=18,
minimum=1,
maximum=30,
unit="dBm",
),
PrefSpec(
key="tx_opt_max",
label="Range max",
help="Strongest power it will try",
group="TX optimize",
value_type="int",
default=28,
minimum=1,
maximum=30,
unit="dBm",
),
PrefSpec(
key="tx_snr_tolerance_db",
label="Tie margin",
help="Signals this close are a tie, so less power wins",
group="TX optimize",
value_type="float",
default=1.0,
minimum=0.0,
maximum=10.0,
unit="dB",
),
# --- Watchtower --------------------------------------------------------------
PrefSpec(
key="watch_silence_hours",
label="Alert after",
help="Warn when a watched node goes quiet this long",
group="Watchtower",
value_type="enum",
default=DEFAULT_SILENCE_HOURS,
choices=_SILENCE_CHOICES,
),
PrefSpec(
key="watch_alerts_kept",
label="Alerts kept",
help="How many past alerts to keep",
group="Watchtower",
value_type="int",
default=200,
minimum=10,
maximum=5000,
),
# --- Map ---------------------------------------------------------------------
PrefSpec(
key="map_view_fraction",
label="Map fit",
help="Share of nodes to fit on screen; 1 shows them all",
group="Map",
value_type="float",
default=0.5,
minimum=0.1,
maximum=1.0,
),
PrefSpec(
key="basemap_tilejson_url",
label="Map tiles",
help="Where map images come from",
group="Map",
value_type="str",
default="https://tiles.openfreemap.org/planet",
relaunch=True,
),
# --- History kept ------------------------------------------------------------
PrefSpec(
key="history_days",
label="Keep packets for",
help="Older ones are deleted; 0 keeps everything",
group="History",
value_type="int",
default=365,
minimum=0,
maximum=36500,
unit="days",
),
PrefSpec(
key="chat_history_limit",
label="Chat history",
help="Old messages to load when you open a chat",
group="History",
value_type="int",
default=200,
minimum=20,
maximum=5000,
),
PrefSpec(
key="courier_history_kept",
label="Courier history",
help="Finished outbox messages to keep",
group="History",
value_type="int",
default=100,
minimum=10,
maximum=5000,
),
# --- Display -----------------------------------------------------------------
PrefSpec(
key="cli_time_format",
label="Command-line times",
help="Whether the command line prints ages or timestamps",
group="Display",
value_type="enum",
# Relative, because a person at a prompt asking "heard recently?" should not have
# to subtract an ISO instant from `date` to find out. `--absolute` overrides this
# for one run, and the JSON face is UTC either way — it is read somewhere else and
# often later, where a relative age has nothing to be relative to.
default="relative",
choices=_CLI_TIME_CHOICES,
),
PrefSpec(
key="fast_render",
label="Fast redraw",
help="Faster screen updates; turn off if it looks wrong",
group="Display",
value_type="bool",
default=True,
relaunch=True,
),
PrefSpec(
key="full_width",
label="Last column",
help="Use the extra column some terminals hide",
group="Display",
value_type="enum",
default="auto",
choices=_WIDTH_CHOICES,
),
PrefSpec(
key="color_depth",
label="Colours",
help="How many colours to send this terminal",
group="Display",
value_type="enum",
# Auto, because the count a terminal accepts is a fact about the terminal and the
# reader should not have to know it. It is only ever consulted to *raise* the
# verdict prompt_toolkit already reached, never to lower it — a terminal that
# cannot be shown to do better keeps exactly what it had.
default="auto",
choices=_COLOR_DEPTH_CHOICES,
relaunch=True,
),
PrefSpec(
key="console_setup",
label="Console setup",
help="Move to a better terminal when this console can't draw MeshTerm",
group="Display",
value_type="enum",
# Reopening in Windows Terminal is done, not asked: the classic console cannot
# draw a single icon whatever font it is given, so there is only one sensible
# answer and the reader has not seen the app yet to judge it. Installing a *font*
# still asks, because that writes to their machine. Declining that twice writes
# "off" here, which turns both off for good.
default="auto",
choices=_CONSOLE_SETUP_CHOICES,
),
# --- Diagnostics ---------------------------------------------------------------
PrefSpec(
key="log_level",
label="Log detail",
help="How much MeshTerm writes to its log file",
group="Diagnostics",
value_type="enum",
# Problems, not a narration of a working session. The file used to take
# everything, which buried the dozen interesting lines under twenty thousand
# dull ones and made it awkward to attach to a bug report.
default="WARNING",
choices=_LOG_LEVEL_CHOICES,
relaunch=True,
),
)
_BY_KEY: dict[str, PrefSpec] = {spec.key: spec for spec in PREFERENCES}
def get_spec(key: str) -> PrefSpec:
"""Look up one preference's spec.
Args:
key: The preference key.
Returns:
Its :class:`PrefSpec`.
Raises:
PreferenceError: If no preference is registered under ``key``.
"""
try:
return _BY_KEY[key]
except KeyError:
raise PreferenceError(f"unknown preference: {key!r}") from None
def by_group() -> list[tuple[str, list[PrefSpec]]]:
"""Every preference grouped for display, in :data:`GROUPS` order.
Returns:
``(group, specs)`` pairs; a group holding no preferences is omitted.
"""
grouped = [(g, [s for s in PREFERENCES if s.group == g]) for g in GROUPS]
return [(g, specs) for g, specs in grouped if specs]
# --- value parsing / formatting -----------------------------------------------
_TRUE = {"1", "true", "yes", "on", "y"}
_FALSE = {"0", "false", "no", "off", "n"}
def parse_value(spec: PrefSpec, raw: Any) -> Any:
"""Parse and validate a raw value (a typed scalar, or text from the CLI/file/prompt).
Args:
spec: The target preference.
raw: The value to coerce.
Returns:
The typed, range-checked value.
Raises:
PreferenceError: If the value is the wrong type, out of range, or not a choice.
"""
text = str(raw).strip()
if spec.value_type == "bool":
low = text.lower()
if low in _TRUE:
return True
if low in _FALSE:
return False
raise PreferenceError(f"{spec.key}: expected on or off, got {raw!r}")
if spec.value_type == "str":
return text
if spec.value_type == "enum":
choices = spec.choices or {}
if raw in choices and not isinstance(raw, bool):
return raw
if isinstance(raw, bool):
# YAML 1.1 reads a bare ``yes``/``no`` (and ``on``/``off``) as a *boolean*, so a
# hand-edited file hands us ``True`` where the choice is spelled "yes". We quote
# ours on the way out, but someone typing the obvious thing should still be
# understood: map the boolean back to whichever spelling this spec offers.
for choice in choices:
if str(choice).lower() in (_TRUE if raw else _FALSE):
return choice
# Text arriving from the CLI or a hand-edited file: match a choice by its own
# spelling, so `preferences set watch_silence_hours 6` and a YAML `6` land alike.
for choice in choices:
if text.lower() == str(choice).lower():
return choice
allowed = ", ".join(str(c) for c in choices)
raise PreferenceError(f"{spec.key}: must be one of {allowed}, got {raw!r}")
try:
value: Any = float(text) if spec.value_type == "float" else int(text, 0)
except (ValueError, TypeError) as exc:
raise PreferenceError(f"{spec.key}: invalid {spec.value_type} value {raw!r}") from exc
if spec.minimum is not None and value < spec.minimum:
raise PreferenceError(f"{spec.key}: must be >= {spec.minimum:g}, got {value:g}")
if spec.maximum is not None and value > spec.maximum:
raise PreferenceError(f"{spec.key}: must be <= {spec.maximum:g}, got {value:g}")
return value
def format_value(spec: PrefSpec, value: Any) -> str:
"""Render one preference's value for display.
Booleans read ``on``/``off`` (the words the editor's button pair offers), an enum
reads its own label, and a number carries its unit so a bare figure in the VALUE lane
still says what it counts.
Args:
spec: The preference the value belongs to.
value: The value to render.
Returns:
The display string.
"""
if spec.value_type == "bool":
return "on" if value else "off"
if spec.value_type == "enum" and spec.choices is not None:
return str(spec.choices.get(value, value))
if spec.value_type in ("int", "float"):
text = f"{value:g}"
return f"{text} {spec.unit}" if spec.unit else text
return str(value)
def range_hint(spec: PrefSpec) -> str:
"""A muted "allowed values" hint for a typed prompt, or ``""`` where nothing bounds it."""
unit = f" {spec.unit}" if spec.unit else ""
if spec.minimum is not None and spec.maximum is not None:
return f"Allowed: {spec.minimum:g} – {spec.maximum:g}{unit}"
if spec.minimum is not None:
return f"Allowed: >= {spec.minimum:g}{unit}"
if spec.maximum is not None:
return f"Allowed: <= {spec.maximum:g}{unit}"
return ""
# --- the store ----------------------------------------------------------------
#: The file's opening comment. It says the one thing a reader hand-editing the file needs
#: to know — that absence means "use the default", so deleting a line is how a preference
#: is undone — and points at the two neighbouring kinds of setting so nobody looks for a
#: device's radio parameters in here.
_HEADER = """\
# MeshTerm preferences -- how the app itself behaves.
#
# Every preference has a built-in default. A key is listed here only while you have
# changed it away from that default, so deleting a line (or "Reset to defaults" on the
# Preferences page) hands the value back to the code. `meshterm preferences show` prints
# every preference, what it is set to, and what it defaults to.
#
# Device profiles, the database location, and the rest of the machine setup stay in
# config.toml; the radio's own settings live on the radio, under Device config.
"""
class Preferences:
"""The effective preferences: the built-in defaults, with the file's overrides on top.
Read a preference as an attribute — ``preferences.trace_cooldown_s`` — which resolves
through the registry, so a typo raises :class:`AttributeError` at the call site
instead of quietly reading ``None``. :meth:`set` takes a raw value, validates it
through the key's spec, and records it only while it differs from the default;
:meth:`save` writes the result.
Nothing here writes on its own: the Preferences page stages changes and saves them on
*Apply*, which — with the CLI — is the only path that reaches :meth:`save`.
"""
def __init__(self, path: Path | None = None, values: Mapping[str, Any] | None = None) -> None:
"""Build a preferences view over an optional file and an optional set of overrides.
Args:
path: Where :meth:`save` writes. ``None`` for an in-memory set (tests, and
the fallback :func:`current` hands out before a context is built).
values: Overrides to start from; invalid ones are dropped, and a value equal
to its default is not recorded as an override at all.
"""
self._path = path
self._values: dict[str, Any] = {}
if values:
self.update(values)
@classmethod
def load(cls, path: Path) -> Preferences:
"""Read ``path``, returning all-defaults for a missing, empty, or corrupt file.
Args:
path: The YAML file to read.
Returns:
The loaded preferences, bound to ``path`` for a later :meth:`save`.
"""
import yaml
try:
data = yaml.safe_load(path.read_text(encoding="utf-8"))
except (OSError, ValueError, yaml.YAMLError):
return cls(path)
return cls(path, data if isinstance(data, Mapping) else None)
@property
def path(self) -> Path | None:
"""Where :meth:`save` writes, or ``None`` for an in-memory set."""
return self._path
def __getattr__(self, name: str) -> Any:
"""Resolve a registered preference key as an attribute.
Raises:
AttributeError: For any name that is not a preference key, so a mistyped
``preferences.trace_cooldwn_s`` fails where it is written rather than
quietly reading nothing.
"""
if name.startswith("_") or name not in _BY_KEY:
raise AttributeError(name)
return self.get(name)
def get(self, key: str) -> Any:
"""The effective value for ``key``: the override where there is one, else the default.
Raises:
PreferenceError: If ``key`` is not a registered preference.
"""
spec = get_spec(key)
return self._values.get(key, spec.default)
def is_overridden(self, key: str) -> bool:
"""Whether ``key`` currently differs from its built-in default."""
return key in self._values
def overrides(self) -> dict[str, Any]:
"""The values that differ from their defaults, in registry order (a copy)."""
return {s.key: self._values[s.key] for s in PREFERENCES if s.key in self._values}
def set(self, key: str, raw: Any) -> Any:
"""Validate ``raw`` for ``key`` and record it, dropping it when it *is* the default.
An override is only ever a disagreement with the default, so setting a value back
to its default clears the entry rather than pinning it. That is what lets a later
change to the code's default reach an install that never asked to be exempt.
Args:
key: The preference key.
raw: The new value, typed or as text.
Returns:
The parsed value now in force.
Raises:
PreferenceError: If ``key`` is unknown or ``raw`` fails its spec.
"""
spec = get_spec(key)
value = parse_value(spec, raw)
if value == spec.default:
self._values.pop(key, None)
else:
self._values[key] = value
return value
def update(self, values: Mapping[str, Any]) -> int:
"""Apply several changes at once, skipping any the registry or a spec refuses.
Tolerant by design: this is the path a hand-edited file arrives through, and one
bad line should cost that line, not the file.
Args:
values: ``key -> value`` pairs.
Returns:
How many were accepted.
"""
applied = 0
for key, value in values.items():
try:
self.set(str(key), value)
except PreferenceError:
continue
applied += 1
return applied
def reset(self) -> int:
"""Drop every override, returning how many there were."""
count = len(self._values)
self._values.clear()
return count
def as_yaml(self) -> str:
"""Render the current overrides as the file's text: header, then grouped entries.
Each group holding an override gets its own comment heading, and each entry its
help line and its default above it, so the file reads the way the page does
rather than as a bare map someone has to look up elsewhere.
"""
import yaml
lines = [_HEADER]
for group, specs in by_group():
present = [s for s in specs if s.key in self._values]
if not present:
continue
lines.append(f"# --- {group} ---")
for spec in present:
entry = yaml.safe_dump(
{spec.key: self._values[spec.key]},
default_flow_style=False,
sort_keys=False,
allow_unicode=True,
).rstrip("\n")
lines.append(f"# {spec.help}")
lines.append(f"# default: {format_value(spec, spec.default)}")
lines.append(entry)
lines.append("")
if len(lines) == 1:
lines.append("# (nothing overridden -- every preference is at its default)")
lines.append("")
return "\n".join(lines)
def save(self) -> None:
"""Write the overrides to :attr:`path` atomically (a crash mid-write keeps the old file).
Raises:
RuntimeError: If this set was built without a path (an in-memory set).
"""
if self._path is None:
raise RuntimeError("these preferences have no file to save to")
write_atomically(self._path, self.as_yaml())
#: The set installed for this process. Starts as a defaults-only, file-less instance so
#: the render layer works before (and without) an :class:`~meshterm.context.AppContext` —
#: a specimen render, a unit test, the CLI's early boot.
_current: Preferences = Preferences()
def current() -> Preferences:
"""The preferences in force for this process.
For the layers that have no context to reach through — the session's renderer, the
stores constructed without one — mirroring
:func:`meshterm.platforms.get_platform`. Anything holding an
:class:`~meshterm.context.AppContext` reads
:attr:`~meshterm.context.AppContext.preferences` instead.
"""
return _current
def install(preferences: Preferences) -> None:
"""Make ``preferences`` the set :func:`current` returns (the context does this at boot)."""
global _current
_current = preferences
__all__ = [
"GROUPS",
"PREFERENCES",
"PrefSpec",
"PreferenceError",
"Preferences",
"by_group",
"current",
"format_value",
"get_spec",
"install",
"parse_value",
"range_hint",
]