Files
MeshTerm/meshterm/tools/preferences.py
T
Jean-Pierre MartineauandClaude Fable 5.1 c9f5a71ce7 Every Python file names its licence with an SPDX identifier
Apache-2.0 does not require per-file headers, but a file lifted out of the tree carried no marking at all. One comment line, first (or right after a shebang), on every .py under meshterm/, tests/, packaging/ and scripts/, plus the extensionless SPI bridge; tests/test_spdx.py keeps a new file from landing without one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 13:09:18 -04:00

325 lines
12 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""The ``preferences`` tool: how MeshTerm itself behaves.
Interactively it opens the Preferences page (see :mod:`meshterm.ui.preferences`), which
stages changes and hands them back here to write. On the CLI it exposes the same
preferences as ``show`` / ``get`` / ``set`` / ``reset``, so a value can be changed from a
shell — or from a script setting a machine up — without launching the full-screen session.
Everything funnels through :meth:`PreferencesTool.run`, which applies a list of operations
and saves the file once at the end: one write per invocation, whether it came from the page
staging eleven changes or from a single ``preferences set``.
This is deliberately the *app's* tool, not the radio's. It reads nothing from the
companion, transmits nothing, and works with no device attached at all — which is why it
leads the **This app** section (the third scope, after this node and other nodes) rather
than sitting beside Device config.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
import typer
from ..context import AppContext
from ..core.preferences import (
PREFERENCES,
PreferenceError,
Preferences,
PrefSpec,
format_value,
get_spec,
)
from .base import Tool, ToolResult, register
if TYPE_CHECKING: # pragma: no cover - typing only
from ..ui.report import Block, Facts, Listing
@register
class PreferencesTool(Tool):
"""View and change MeshTerm's own preferences, saved to ``preferences.toml``."""
name = "preferences"
title = "Preferences"
icon = "⚙"
help = "How MeshTerm behaves — startup, sending, history, …"
category = "This app"
order = 5 # leads the section: the one row there that changes MeshTerm, not describes it
async def prompt_params(self, ctx: AppContext) -> dict[str, Any] | None:
"""Open the Preferences page and collect the changes it staged.
Args:
ctx: Shared application context.
Returns:
``{"ops": [("set", key, value), …]}`` to write, or ``None`` if the reader left
with nothing staged.
"""
from ..ui.preferences import edit_preferences
staged = await edit_preferences(ctx)
if not staged:
return None
return {"ops": [("set", key, value) for key, value in staged.items()]}
async def run(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
"""Apply a list of preference operations, saving the file once if anything changed.
Operations are ``("show",)``, ``("get", key)``, ``("set", key, value)``, and
``("reset",)``. They run in order, so a scripted ``set`` after a ``reset`` lands on
top of the defaults rather than under them.
Args:
ctx: Shared application context.
params: ``ops`` — the operations to perform.
Returns:
A :class:`ToolResult` counting the preferences changed.
Raises:
PreferenceError: If a key is unknown or a value fails its spec — reported
cleanly by the CLI rather than as a traceback.
"""
from ..services import consolefont
from ..ui.preferences import preferences_table
from ..ui.surface import TuiUi
prefs = ctx.preferences
scripted = not isinstance(ctx.ui, TuiUi)
ops: list[tuple] = list(params.get("ops") or [])
blocks: list[Block] = []
applied: list[dict[str, Any]] = []
changes = 0
for op in ops:
action = op[0]
if action == "show":
if scripted:
blocks.append(_listing(prefs))
else:
ctx.ui.show(preferences_table(prefs, ctx.console.width))
elif action == "get":
# The bare value and nothing else — the caller named the key, and
# whether it is overridden is what `show`'s DEFAULT lane answers. The
# document keeps both, since a parser has no lane to read them off.
spec = get_spec(op[1])
if scripted:
blocks.append(_one(prefs, spec))
else:
where = "changed" if prefs.is_overridden(spec.key) else "default"
shown = format_value(spec, prefs.get(spec.key))
ctx.ui.note(f"[accent]{spec.key}[/accent] = [brand]{shown}[/brand] ({where})")
elif action == "set":
before = prefs.get(op[1])
after = prefs.set(op[1], op[2])
if after != before:
changes += 1
applied.append({"key": op[1], "previous": before, "value": after})
elif action == "reset":
was = {s.key: prefs.get(s.key) for s in PREFERENCES if prefs.is_overridden(s.key)}
changes += prefs.reset()
applied.extend(
{"key": key, "previous": before, "value": prefs.get(key)}
for key, before in was.items()
)
if changes:
prefs.save()
if not scripted and any(c["key"] == consolefont.PREFERENCE_KEY for c in applied):
# The one preference whose effect is outside the app: the console's font is
# the console's, so it takes hold now rather than next launch. The kernel
# sends SIGWINCH on a VT font change, so prompt_toolkit repaints the whole
# frame at the new row count by itself. Menu-only — a scripted run was typed
# into a console it does not own (see meshterm.services.consolefont).
consolefont.apply(prefs.get(consolefont.PREFERENCE_KEY))
if scripted and any(op[0] in ("set", "reset") for op in ops):
blocks.append(_written(applied, prefs))
plural = "" if changes == 1 else "s"
message = (
f"[ok]✓[/ok] saved [brand]{changes}[/brand] preference{plural} to "
f"[accent]{prefs.path}[/accent]"
if changes
else None
)
return ToolResult(
summary={"changes": changes}, message=message, report=tuple(blocks) or None
)
# -- CLI --------------------------------------------------------------------
def register_cli(self, app: typer.Typer) -> None:
"""Register the ``preferences`` subcommand group.
Args:
app: The Typer application.
"""
from ..cli import run_tool_command
prefs_app = typer.Typer(help=self.help, no_args_is_help=False, rich_markup_mode=None)
@prefs_app.callback(invoke_without_command=True)
def _root(ctx: typer.Context) -> None:
"""Show every preference when no subcommand is given."""
if ctx.invoked_subcommand is None:
run_tool_command(self, {"ops": [("show",)]})
@prefs_app.command("show", help="Show every preference, its value, and its default")
def _show_cmd() -> None:
run_tool_command(self, {"ops": [("show",)]})
@prefs_app.command("get", help="Print one preference's value")
def _get_cmd(key: str = typer.Argument(..., help="Preference key")) -> None:
run_tool_command(self, {"ops": [("get", key)]})
@prefs_app.command("set", help="Set one preference to a value")
def _set_cmd(
key: str = typer.Argument(..., help="Preference key"),
value: str = typer.Argument(..., help="New value"),
) -> None:
run_tool_command(self, {"ops": [("set", key, value)]})
@prefs_app.command("reset", help="Return every preference to its built-in default")
def _reset_cmd(
yes: bool = typer.Option(False, "--yes", help="Skip the confirmation"),
) -> None:
if not yes:
raise typer.BadParameter(
"resetting drops every preference you have changed; pass --yes to confirm"
)
run_tool_command(self, {"ops": [("reset",)]})
app.add_typer(prefs_app, name=self.name)
__all__ = ["PreferencesTool", "PreferenceError"]
def _script_value(spec: PrefSpec, value: Any) -> str:
"""A preference's value in the form ``preferences set`` accepts back.
:func:`~meshterm.core.preferences.format_value` writes for the page: a number carries
its unit (``5 s``), so a bare figure in the VALUE lane still says what it counts. That
unit is exactly what :func:`~meshterm.core.preferences.parse_value` will not take
back, so the scripted dump drops it. Booleans keep ``on``/``off`` and an enum keeps
its own key, both of which do round-trip.
Args:
spec: The preference the value belongs to.
value: Its current value.
Returns:
The scripted rendering.
"""
if spec.value_type == "bool":
return "on" if value else "off"
if spec.value_type in ("int", "float"):
return f"{value:g}"
return str(value)
def _row(prefs: Preferences, spec: PrefSpec) -> dict[str, Any]:
"""One preference as a report row, in the shared setting shape.
``overridden`` is the fact the plain face makes a reader derive by comparing two
columns; a document that carried the same two numbers and left the comparison to the
consumer would be handing over homework it has the answer to.
"""
from ..ui.fields import Rendered
value = prefs.get(spec.key)
return {
"key": spec.key,
"value": Rendered(value, _script_value(spec, value)),
"type": spec.value_type,
# The reader's word for an enum's own key — never what `set` takes, which is the
# key itself and rides in `value`.
"label": (spec.choices or {}).get(value) if spec.value_type == "enum" else None,
"redacted": False,
"default": Rendered(spec.default, _script_value(spec, spec.default)),
"overridden": prefs.is_overridden(spec.key),
"help": spec.description,
}
def _columns() -> tuple:
"""The preference row's columns, shared by ``show`` and ``get``."""
from ..ui import fields
return (
fields.word("key", "PREFERENCE"),
fields.rendered("value", "VALUE"),
fields.hidden("type"),
fields.hidden("label"),
fields.hidden("redacted"),
fields.rendered("default", "DEFAULT"),
fields.hidden("overridden"),
fields.note("help", "DESCRIPTION"),
)
def _listing(prefs: Preferences) -> Listing:
"""Every preference, its value, its built-in default, and what it is for.
``DESCRIPTION`` comes straight from :attr:`PrefSpec.help` — the text is already
written, one short line each, and as the *last* column it can never disturb an
alignment or wrap. It used to be cut for parser hygiene, which is a bargain the plain
face no longer has to make: a listing is read by a person now, and a person choosing a
preference is exactly who that sentence was written for.
Args:
prefs: The preference set to report.
Returns:
The listing.
"""
from ..ui.report import Listing
return Listing(
key="preferences",
columns=_columns(),
rows=[_row(prefs, spec) for spec in PREFERENCES],
order=("PREFERENCE", "VALUE", "DEFAULT", "DESCRIPTION"),
)
def _one(prefs: Preferences, spec: PrefSpec) -> Facts:
"""One preference: the bare value plain, the whole setting object in the document."""
from ..ui.report import BARE, Facts
return Facts(
key="preference",
fields=_columns(),
values=_row(prefs, spec),
shape=BARE,
bare="value",
)
def _written(applied: list[dict[str, Any]], prefs: Preferences) -> Facts:
"""What ``set`` or ``reset`` changed — nothing plain, a record for whoever wants one.
The plain face says it in the exit status and in the acknowledgement on stderr, which
is the whole answer for a person. A caller managing a machine's configuration wants to
know *what moved*, and had no way to ask.
"""
from ..ui import fields
from ..ui.report import SILENT, Column, Facts
return Facts(
key="applied",
fields=(
fields.integer("changes", "changes"),
Column(key="applied"),
fields.word("path", "path"),
),
values={
"changes": len(applied),
"applied": applied,
"path": str(prefs.path) if prefs.path else None,
},
shape=SILENT,
)