Files
Jean-Pierre MartineauandClaude Haiku 5.5 9e579424b4 The Anonymize preference masks the home folder and addresses in the log and diagnostics
Off by default. Each masked value keeps a tidbit (j*********u, DA:xx:xx:xx:7C:7C,
192.xxx.x.180, m******e.local), so two radios stay two radios. The log masks each
line as it is written; diagnostics take --anonymize/--no-anonymize, and the page
toggles with `a` (Shift+F3 on the PicoCalc). Refs #31.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-17 21:30:26 -04:00

274 lines
11 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""The Diagnostics page: the bug-report block, drawn as one of the app's written pages.
The page has the shape of prose, not of a screen: a title, a standfirst, sections, and a
list under each section. Thus it is markdown, and it goes through the renderer that the
About pages use (:mod:`meshterm.ui.markdown`). This is not a choice of presentation that
we make two times: the same document is the one that MeshTerm **saves**, and an issue
tracker renders markdown as it is written. Thus one source gives one appearance, on the
screen and in the issue.
Two things are most necessary for a long block, and a hand-built screen had neither of
them. A written page gets both:
- Its ``##`` headings are landmarks. Each heading pins to the top row while its section
scrolls under it, and ``^PgUp``/``^PgDn`` step from one section to the next.
- The F-key lane of the PicoCalc gets ``Sect ↑``/``Sect ↓``, for the same reason that a
grouped list gets them.
:class:`~meshterm.ui.about.AboutPage` already does all of this. Thus this page is that
page with one verb added.
The verb is **save** (:data:`SAVE_KEY`). The page shows it where the platform shows verbs:
in the footer hint on a desktop, and on the free F3 slot of the lane on the console. The
page never shows it in its own body, because the body belongs to the report. The save
answers in a dialog, because the answer is a path that the user may have to read
carefully, and because a write can fail. The page under the dialog does not change, and
it is still there when the dialog closes.
A second verb, **anonymize** (:data:`ANONYMIZE_KEY`), changes the page between the
report as it is and the report with its personal details masked. It is the menu half of
``meshterm diagnostics --anonymize``. The ``anonymize`` preference chooses the form in
which the page opens, and the key changes only this visit. The save writes the form that
is visible, so the user saves what the user reads. The title says ``anonymized`` while the
masks are visible.
"""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING
from rich.text import Text
from .about import AboutPage
from .markdown import render_markdown
if TYPE_CHECKING: # pragma: no cover - typing only
from ..context import AppContext
from .tui.session import TuiSession
#: The keyboard key that writes the page to a file. It is a bare letter. A read-only page
#: can use a bare letter, because it has no filter and no field in which the letter can
#: have a different meaning.
SAVE_KEY = "s"
#: The action that the keyboard key and the chip on the lane both dispatch. Thus the chip
#: is not a second implementation of the keyboard key.
SAVE_ACTION = "save"
#: The keyboard key that changes the page between the report and the anonymized report. A
#: bare letter, for the same reason as :data:`SAVE_KEY`.
ANONYMIZE_KEY = "a"
#: The action that :data:`ANONYMIZE_KEY` and its chip on the lane both dispatch.
ANONYMIZE_ACTION = "anonymize"
_TITLE = "Diagnostics"
class DiagnosticsPage(AboutPage):
"""The written diagnostics page, plus the verbs that anonymize it and write it to a file.
All of the behaviour for reading comes from :class:`~meshterm.ui.about.AboutPage`: the
pager, the sticky ``##`` headings, the section jumps, and the lane that dims when there
is nothing to page. This class adds the save, on the one slot that the About pages
leave free, and the anonymize key, on the Shift half of that slot.
"""
def __init__(
self,
source: str,
*,
session: TuiSession,
save_path: Path,
anonymized_source: str | None = None,
anonymized: bool = False,
) -> None:
"""Show ``source`` as the page, and save it to ``save_path`` when the user asks.
Args:
source: The markdown document, from
:func:`~meshterm.tools.diagnostics.markdown_source`.
session: The session, for the dialog in which the save answers.
save_path: The file to which :data:`SAVE_KEY` writes.
anonymized_source: The same document with its personal details masked, from
:func:`~meshterm.tools.diagnostics.anonymized`. ``None`` gives a page with
no anonymize key.
anonymized: Whether the page opens on ``anonymized_source``.
"""
self._sources = {False: source, True: anonymized_source}
self._anonymized = anonymized and anonymized_source is not None
super().__init__(self._title(), render_markdown(self._source))
self._session = session
self._save_path = save_path
self._saving = False
@property
def _source(self) -> str:
"""The document that the page shows now, and that the save writes."""
return self._sources[self._anonymized] or self._sources[False] or ""
@property
def _can_anonymize(self) -> bool:
"""Whether the page has the second form of the document to change to."""
return self._sources[True] is not None
def _title(self) -> str:
"""The title, with ``· anonymized`` while the masks are visible."""
return f"{_TITLE} · anonymized" if self._anonymized else _TITLE
@property
def footer_hint(self) -> str: # type: ignore[override]
"""The keys of the read-only page, with the verbs between the pager and Esc.
The hint has the standard sentence shape: navigation first, then the actions, then
Esc. The pager atom is present only while :attr:`~Screen.content_overflows` is
true, as on each read-only page. Thus the line never names a keyboard key that does
nothing. The anonymize atom names what the key does next: ``anonymize`` on the
report as it is, and ``reveal`` on the masked report.
"""
scroll = "↑↓ PgUp/PgDn scroll · " if self.content_overflows else ""
toggle = ""
if self._can_anonymize:
verb = "reveal" if self._anonymized else "anonymize"
toggle = f"{ANONYMIZE_KEY} {verb} · "
return f"{scroll}{toggle}{SAVE_KEY} save · Esc back"
@property
def picocalc_lyra_lane(self):
"""The lane of the About page, with ``Save`` on the slot that it leaves free.
F1/F2 are the section pair and F4/F5 are the pager. Thus F3 is the one free primary
slot. On this platform, the lane is the only place that can show a bare-letter
keyboard key. Thus the chip is necessary, and not only a decoration. The anonymize
key goes on the Shift half of the same slot, because the two verbs are both about
what leaves the machine. Its label names what the chip does next.
"""
from .tui.fkeys import FPair
lane = list(super().picocalc_lyra_lane)
if self._can_anonymize:
label = "Reveal" if self._anonymized else "Anon"
lane[2] = FPair("Save", SAVE_ACTION, label, ANONYMIZE_ACTION)
else:
lane[2] = FPair("Save", SAVE_ACTION)
return lane
def handle(self, action: str, data: str = "") -> None:
"""Save or anonymize on a keyboard key or a chip. Scroll, jump, or leave on all others."""
typed = data.lower() if action == "text" else ""
if action == SAVE_ACTION or typed == SAVE_KEY:
self._begin_save()
return
if self._can_anonymize and (action == ANONYMIZE_ACTION or typed == ANONYMIZE_KEY):
self._toggle_anonymized()
return
super().handle(action, data)
def _toggle_anonymized(self) -> None:
"""Show the other form of the document, at the same scroll position.
Both forms have the same lines in the same order, because a mask keeps the length
of each value. Thus the user stays at the same place in the page.
"""
self._anonymized = not self._anonymized
self._doc = render_markdown(self._source)
self.title = self._title()
def _begin_save(self) -> None:
"""Write the file and report the result in a dialog, outside the key handler.
This method uses ``run_detached``, because the ``handle`` of a screen is
synchronous and the dialog is a screen. Thus this method can only start the flow,
and the task that it starts is outside the navigation chain (refer to
:meth:`~meshterm.ui.tui.session.TuiSession.run_detached`). Only one save runs at a
time, so a held keyboard key cannot put dialogs on top of each other.
"""
if self._saving:
return
self._saving = True
async def run() -> None:
try:
await self._session.message_dialog(self._write(), title="Diagnostics")
finally:
self._saving = False
self._session.run_detached(run())
def _write(self) -> Text:
"""Write the document, and return the result as the message of the dialog.
The message is a :class:`~rich.text.Text` with real spans, not a markup string.
The dialog draws the message as it is, and reads its spans to find the border tone
(refer to ``session._message_border``). If markup stays as text, the dialog shows
its tags, and the border of a failure has the colour of a success.
Only the **mark** has a style: ``✓`` or ``✗``, the ok/err pair of the app. This
mark is sufficient for the border to read the tone. The path itself stays in the
body colour, where it is easy to read. That is the purpose of the mark.
"""
from ..tools.diagnostics import write_markdown
message = Text()
try:
written = write_markdown(self._source, self._save_path)
except OSError as exc:
# For example: a read-only home, a full disk, or a path that does not exist.
# The page itself does not change, and the user can still read it. That is
# the important fallback.
message.append("✗ ", style="err")
message.append(f"could not save to\n{self._save_path}\n\n")
message.append(exc.strerror or str(exc), style="muted")
return message
message.append("✓ ", style="ok")
message.append(f"saved to\n{written}")
return message
async def open_diagnostics_page(
ctx: AppContext,
source: str,
*,
save_path: Path,
anonymized_source: str | None = None,
anonymized: bool = False,
) -> None:
"""Open the Diagnostics page full-screen, and keep it until the user leaves it.
Args:
ctx: The shared application context. The interactive TUI must be in use.
source: The markdown document to show and to save.
save_path: The file to which the save key of the page writes.
anonymized_source: The same document with its personal details masked.
anonymized: Whether the page opens on ``anonymized_source``.
Raises:
RuntimeError: If the call is outside the interactive menu (there is no
full-screen session).
"""
from .surface import TuiUi
if not isinstance(ctx.ui, TuiUi): # pragma: no cover - the menu-only caller guards this
raise RuntimeError("the Diagnostics page is only available in the menu")
session = ctx.ui.session
page = DiagnosticsPage(
source,
session=session,
save_path=save_path,
anonymized_source=anonymized_source,
anonymized=anonymized,
)
await session.run_screen(page)
__all__ = [
"ANONYMIZE_ACTION",
"ANONYMIZE_KEY",
"SAVE_ACTION",
"SAVE_KEY",
"DiagnosticsPage",
"open_diagnostics_page",
]