Files
Jean-Pierre MartineauandClaude Opus 5.5 abfd32c491 Docstrings and comments of meshterm/ui are in ASD-STE100
The same rewrite as for the core package, for the screens, the dialogs,
the map, the charts, and the CLI renderers. The glossary words are used
for the screen: the user (not the reader), dialog (not popup), viewport,
list window, highlight, pointer, graph lane, marker, raster, result
screen, and key prefix (never hash for a truncated key).

The rewrite also corrects approximately 70 docstrings and comments that
no longer agreed with the code: rows that no longer exist (Back rows),
counts that changed, renamed constants, wrong cross-references, and
behaviour that changed. Each correction follows the code; no code
changed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 20:37:26 -04:00

670 lines
30 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""The output language of the plain CLI: aligned text without decoration, for a prompt.
MeshTerm has two front ends and a third face. A person who sits in front of the menu
reads the menu. All the other code in ``meshterm/ui/`` is made for that user: colour that
has a meaning, glyphs for concepts, and frames that make groups. A program reads
``--json``, often on a different machine and often later. This module is the third face:
**a person at a prompt who typed a command to find a fact, and who wants to read the
answer in one look.**
This is a change of premise. Before, the plain face was a serialization format that a
person could read only with effort. Several of its rules were there only to make it safe
to *split* the output into fields: quoted names, absolute timestamps, and a comma between
the hops of a path. Now a program that splits the output reads the machine face instead
(refer to :mod:`meshterm.ui.report` and :mod:`meshterm.ui.renderers`). Thus those rules
had a cost and no longer had a purpose. The rules that stay are the rules that make the
output the output of a Unix utility, and one new rule that replaces the quotes.
* **Alignment is the delimiter.** The eye already sees an aligned column as one field.
Thus a name has no quotes (:func:`name`). The *escapes* under the quotes stay, and they
must always stay (:func:`_escaped`). A node broadcasts its own name, and a stranger
writes the body of a message. Neither value can end the record that contains it.
* **No colour.** ``stdout`` is more often a pipe than a terminal. A colour that goes into
a file is noise in that file. The console that this module builds has
``color_system=None``, thus Rich writes no escape sequence at all: no dim and no bold.
This is stricter than ``no_color``, which keeps the attributes and removes only the
colours.
* **No wrapping.** A record is a line. If the console wraps a record, the record becomes
two lines, and the fields of the second line are under the wrong headings. Thus the
console is :data:`WIDTH` cells wide, much wider than any real line, and each column is
``no_wrap``. The only intentional exception is a *written page* (``about``,
``support``). It is drawn on a console that is :data:`PAGE_WIDTH` cells wide instead.
A paragraph is not a record, and it has no headings to align under. If it does not
wrap, it is one line of 600 cells, and a person cannot read that line in any terminal.
* **No frames.** No panel borders, no table boxes, no rules, and no titles. The command
that the person typed is the title. A box around the answer is a second title.
* **Aligned columns, one header line.** The shape of ``ps`` and ``df``: a header row in
upper case, then the records. :data:`GUTTER` spaces separate the padded fields. Refer to
:func:`columns`.
* **A time is an age.** ``5m``, ``3h``, ``never``: the same scale that the columns of the
menu use (:func:`age`). The reason is that "how long ago" is the question that a person
asks. An absolute instant stays where the instant is the fact: the device clock, a
scheduled appointment, and the clock of a live capture (:func:`stamp`). ``--absolute``
changes all the output back to absolute times for a person who wants them. The machine
face always uses absolute UTC times.
* **A route is drawn, a path is typed.** The lexicon already makes these two concepts
different. A *path* is an ordered list of hops that you write or force. A *route* is
the sequence of nodes that a walk went through. Thus a path keeps its commas
(:func:`spec`), because you can type it back into ``--path`` (a round trip). A route
gets arrows (:func:`route`), because it is a picture and clearly not a spec. Before,
both had commas, and thus both seemed to make a round trip. Only one of them does.
* **One token for "nothing".** :data:`NONE` (a ``-`` alone) in each column. Thus a person
never has to tell an absent value from an empty value, an ``—``, an ``n/a``, or a blank.
``never`` is a *value*, not an absence: a node that was never heard is a fact.
* **No trailing whitespace.** If the last column is padded to its width, each line ends
with invisible spaces. The console removes them when it writes the line
(:class:`_Trimmed`).
The menu cannot get to this module, and this module cannot get to the menu.
:class:`~meshterm.ui.surface.PlainUi` is the seam. :func:`~meshterm.cli.main_callback`
chooses it when the command line names a subcommand.
"""
from __future__ import annotations
from collections.abc import Iterable, Sequence
from dataclasses import dataclass
from datetime import datetime
from typing import Any
from rich.console import Console, Group, RenderableType
from rich.panel import Panel
from rich.table import Table
from rich.text import Text
#: The width of the scripted console. It is wide enough that Rich never wraps or cuts any
#: text that MeshTerm can make (a key of 64 hex digits, a full route, the description of a
#: preference). If a line is longer than the terminal of the person, that terminal deals
#: with it. The value is not ``sys.maxsize``, because Rich sizes some structures from the
#: width. A very large number costs nothing, but an astronomically large number has a cost.
WIDTH = 1 << 14
#: The number of spaces between columns. It is two, so that a padded column never touches
#: the next column. Also, a person never reads a single space in a field without quotes as
#: a separator.
GUTTER = 2
#: The only token for a value that is absent, unknown, or not applicable. A person (and a
#: parser) learns it one time. It is never ``—``, ``n/a``, ``never``, or an empty cell.
NONE = "-"
#: The separator between the hops of a route, with a space on each side. It is the same
#: ``→`` that the path lines of the menu use (:data:`meshterm.ui.pathline._ARROW`). The
#: PicoCalc console font has this glyph (we checked it on the handheld). The arrow shows
#: the direction in which the packet went. Also, it makes the quotes unnecessary: a person
#: can no longer read a comma in the name of a node as a boundary between hops.
ARROW = " → "
#: The width at which a *written page* wraps. It is not the width of the terminal, because
#: the same paragraph must look the same in a pipe, in a redirected file, or in the
#: ``text`` field of the machine face. Also, 72 is already the width limit of each screen
#: in this app.
PAGE_WIDTH = 72
#: The characters that have a readable escape of their own. All other characters that
#: cannot go in a record get the ``\xNN``/``\uNNNN`` escape below.
_BREAKS = {"\\": "\\\\", "\n": "\\n", "\r": "\\r", "\t": "\\t"}
#: The code points that must never go to stdout without an escape. This is a *category*,
#: not a list: each C0 and C1 control character, and the two Unicode separators at which a
#: terminal also breaks a line. Before, a table of four entries caught the newline and the
#: tab, but let ESC through. An ESC in a node name starts a live colour run in the file of
#: the caller. The rule "no colour, not a single escape sequence" is there to prevent
#: exactly this. A BEL was worse: it disappeared without a warning, so the printed name was
#: not the name in the advert.
_UNPRINTABLE = frozenset([*range(0x00, 0x20), 0x7F, *range(0x80, 0xA0), 0x2028, 0x2029])
def _escaped(text: str) -> str:
r"""``text``, with an escape for each character that cannot go in one record.
A record is a line, and a field is a sequence of printable cells. This function makes
both statements true for a value that came over the radio. It uses a named escape
where one is readable (``\n``), and a numeric escape for all other characters. It
doubles the backslash, so that the result has only one meaning when a person reads it
back.
This function is the part of the quoting that stayed when we removed the quotes. The
quotes made a name *one field* for a program that splits the line, and now the
alignment does that. But nothing else can stop a newline in a name: without the
escape, the newline ends the record of the name too early.
"""
out: list[str] = []
for ch in text:
if ch in _BREAKS:
out.append(_BREAKS[ch])
elif ord(ch) in _UNPRINTABLE:
out.append(f"\\x{ord(ch):02x}" if ord(ch) < 0x100 else f"\\u{ord(ch):04x}")
else:
out.append(ch)
return "".join(out)
def name(value: str | None) -> str:
r"""The name of a node as a field: without quotes and with escapes, or :data:`NONE`.
The quotes are gone. They were there so that a name with a space or a comma stayed one
field for the program that split the line. Now that program reads JSON, and in plain
text the alignment of the columns already shows where a field ends. The escapes stay.
A node broadcasts its own name, so each name here is remote data, and a newline in a
name must still not end the record that contains it.
An empty name is still :data:`NONE`, not an empty cell. ``""`` means *called
nothing*, and the token means *never said*. These are different facts. The machine
face also needs this difference (an absent name is ``null``, an empty name is
``""``), and a field that shows both in the same way cannot give the difference back.
Args:
value: The name, or ``None`` or empty when the node did not give a name.
Returns:
The escaped name, or :data:`NONE`.
"""
return _escaped(value) if value else NONE
def text(value: str | None) -> str:
r"""Free text as a field that can end a record without a break in the record.
Use it for the field that a listing puts last, because that field is the rest of the
line: the body of a message, or the description of a preference. "The rest of the
line" and "any text at all" are not compatible. Before, a body that contained a
newline ended its record too early. The rest of the text was indented under the other
columns, and it looked like a second record with an empty ``TIME``. Also, a stranger
writes the body, so such a message is a real possibility.
Args:
value: The free text. ``None`` gives :data:`NONE`.
Returns:
The text, with escapes for its line breaks and tabs.
"""
return NONE if value is None else _escaped(value)
def stamp(when: datetime | None) -> str:
"""Format a timestamp as local ISO-8601 to the second, or as :data:`NONE`.
Use it where the instant is the fact, and not a way to say how long ago something
occurred: the clock of the device, an appointment set with ``--at``, the time of each
row in a live capture (without it, each row shows ``now``), and each column under
``--absolute``.
Stored times are UTC and have a timezone. A naive time has an offset that we do not
know. Thus it is not a fact, and the function shows it as absent.
:func:`~meshterm.ui.widgets.age_seconds` uses the same rule for ages.
Args:
when: The instant to format.
Returns:
For example ``2026-09-07T18:22:41-04:00``, or :data:`NONE`.
"""
if when is None or when.tzinfo is None:
return NONE
return when.astimezone().isoformat(timespec="seconds")
def age(when: datetime | None, *, absent: str = NONE) -> str:
"""How long ago ``when`` was: ``now``, ``5m``, ``3h``, ``2d``, ``4w``.
This is the default form of a time, because of the change of premise. A person at a
prompt who reads ``2026-09-07T19:58:53-04:00`` must do arithmetic to answer
"recently?", and that is the question for which the person typed the command. The
function calls :func:`~meshterm.ui.widgets.format_age`, so that the two faces always
agree.
Args:
when: The instant to show as an age. A naive datetime has no offset that we know,
thus it shows as absent, the same as in :func:`stamp`.
absent: The text for an unknown time. A heard age gives ``"never"``, because a
node that was never heard is a fact, not an absent field. All other callers
use the default token, which says that this row has no such time at all.
:func:`name` makes the same difference between ``-`` and an empty name, for a
different value.
Returns:
The age, or ``absent``.
"""
from .widgets import age_seconds, format_age
seconds = age_seconds(when)
return absent if seconds is None else format_age(seconds)
def number(value: Any, spec: str = "") -> str:
"""Format a number, or :data:`NONE` when it is absent.
Args:
value: The number to format. ``None`` shows as absent.
spec: An optional :func:`format` spec, for example ``"+.1f"``.
Returns:
The formatted number, or :data:`NONE`.
"""
if value is None:
return NONE
return format(value, spec) if spec else str(value)
#: The units that :func:`duration` uses, the largest first.
_UNITS: tuple[tuple[int, str], ...] = ((86400, "d"), (3600, "h"), (60, "m"), (1, "s"))
def duration(seconds: float | None) -> str:
"""A span of seconds in a readable form: ``93784`` → ``1d 2h``, ``360`` → ``6m``.
It has a maximum of two units, and the two units are always next to each other. Thus
a person sees the magnitude immediately, and the precision is never more than a person
uses to make a decision. Use it only as a *gloss* next to the raw number
(``uptime_s 93784 (1d 2h)``), never instead of the number. The key tells what the
number counts, and a caller that reads the key must still find a number under it.
Args:
seconds: The span. ``None`` shows as absent. The function removes the sign,
because a caller that adds a gloss to a signed value gives the direction in its
own words.
Returns:
The span in one or two units, or :data:`NONE`.
"""
if seconds is None:
return NONE
whole = int(abs(seconds))
amounts: list[tuple[int, str]] = []
for size, unit in _UNITS:
amounts.append((whole // size, unit))
whole %= size
lead = next((i for i, (amount, _) in enumerate(amounts) if amount), len(amounts) - 1)
parts = [f"{amounts[lead][0]}{amounts[lead][1]}"]
if lead + 1 < len(amounts) and amounts[lead + 1][0]:
parts.append(f"{amounts[lead + 1][0]}{amounts[lead + 1][1]}")
return " ".join(parts)
def location(lat: float | None, lon: float | None) -> str:
"""A shared position as one field: ``45.50190,-73.56740``, or :data:`NONE`.
One column, not two. For a person, a pair of coordinates is one fact, and it goes
unchanged into the search box of a map. Also, two columns of ``-`` for each contact
that never shared a position are worse than one column.
Args:
lat: Latitude in decimal degrees.
lon: Longitude in decimal degrees.
Returns:
The pair, to five decimal places, or :data:`NONE` when one of the two values is
absent.
"""
if lat is None or lon is None:
return NONE
return f"{lat:.5f},{lon:.5f}"
def route(hops: Iterable[tuple[str | None, str | None]]) -> str:
"""The route of a walk, drawn: ``Yagi-Repeater (a1) → Alice (d4) → Yagi (a1)``.
A *route* is what a walk did. Thus it is a picture and not a spec, and for this reason
it uses :data:`ARROW` and not the comma that ``--path`` accepts. When a route had
commas, it looked as if a person could paste it, but a person cannot. The route
contains the names, and nothing can read it back (no round trip).
Each hop always shows its hash. The hash is the join key to the table of hops under a
trace, and it is the token that ``--path`` accepts. It also makes two contacts with
the same name different. If the hash is removed "where the name is unique", the
grammar of one line depends on the content of a different line.
Our node is a hop like all other hops, with a name and a hash. The menu draws it as
``★``, because the user never has to be told which node is theirs. But here, the
person who reads the line often reads it from a file, and was not at the prompt when
the command ran.
Args:
hops: ``(label, hash)`` pairs, in the order of propagation. A hop with both values
shows as ``Name (hash)``. A hop with one value shows only that value, never an
empty ``()``. The menu's :class:`~meshterm.ui.pathline.PathLine` also labels an
unresolved hop in this way.
Returns:
The joined route, or :data:`NONE` when there are no hops.
"""
parts: list[str] = []
for label, value in hops:
if label and value:
parts.append(f"{_escaped(label)} ({value})")
else:
parts.append(_escaped(label) if label else (value or NONE))
return ARROW.join(parts) if parts else NONE
def spec(hops: Iterable[str]) -> str:
"""A forced path in the form that the device got it: ``a1,d4,a1``.
This is the other half of the split that :func:`route` names. It is the *spec*: hashes
with commas between them, exactly what ``--path`` accepts back. Thus it is the only
line on the two faces that makes a round trip, and it must never get a name, an
arrow, or a space.
Args:
hops: The hashes of the hops, in the order of propagation.
Returns:
The spec, joined with commas, or :data:`NONE` when there are no hops.
"""
parts = [hop for hop in hops if hop]
return ",".join(parts) if parts else NONE
def columns(*headers: str, right: Sequence[str] = ()) -> Table:
"""Build the scripted table: a header line in upper case, then padded records.
The shape that ``ps`` and ``df`` print: no box, no title, and no padding at the edges.
Each column has the width of its widest value, and :data:`GUTTER` spaces separate the
columns. Nothing is wrapped, and nothing is cut with an ellipsis. Numeric lanes align
to the right. Thus the eye can compare the magnitudes in a column, and the result of a
split of the column does not change. An *age* lane aligns in the same way, so that the
scale from ``now`` to ``4w`` reads down the column.
Args:
*headers: The column headings, already in upper case.
right: The headings (from ``headers``) whose values align to the right.
Returns:
A Rich :class:`Table` that is ready for ``ctx.ui.show``.
"""
table = Table(
box=None,
show_edge=False,
pad_edge=False,
expand=False,
# (top, right, bottom, left): all of the gutter is on the right of each cell. Thus
# no line starts with an indent, and the last column ends where its value ends.
padding=(0, GUTTER, 0, 0),
)
wanted = set(right)
for header in headers:
table.add_column(
header,
justify="right" if header in wanted else "left",
no_wrap=True,
# "crop", not "ignore". Neither one cuts text with an ellipsis: neither one
# ever writes the "…" that makes a key or a route unusable to the caller. But
# Rich's ``Text.wrap`` returns early on "ignore" (text.py: ``if overflow ==
# "ignore": lines.append(line); continue``), and that early return is *above*
# the justify step. Thus "ignore" discards ``justify="right"`` without a
# warning. You can think that the two settings are different only past the
# 16384 cells of the console. But there, "crop" cuts and "ignore" also cuts.
# They are different on each line before that point, where only "crop"
# aligns.
overflow="crop",
)
return table
def pairs(rows: Iterable[tuple[str, str]]) -> Table:
"""Build the scripted key/value listing: the key, then the value, aligned, no header.
A set of facts about one thing prints in this form (``meshterm info``, ``config
show``, ``trace``), the shape of ``sysctl -a``. The keys are the keys that the related
``get``/``set`` subcommand accepts. Thus a person can type a line from ``show`` back
in.
Args:
rows: ``(key, value)`` pairs, in the order of display.
Returns:
A Rich :class:`Table` with no header line.
"""
table = Table(
box=None,
show_edge=False,
show_header=False,
pad_edge=False,
expand=False,
padding=(0, GUTTER, 0, 0),
)
table.add_column(no_wrap=True, overflow="crop")
table.add_column(no_wrap=True, overflow="crop")
for key, value in rows:
table.add_row(key, value)
return table
@dataclass(frozen=True, slots=True)
class Lanes:
"""A column layout with fixed widths, for output whose widths come with the data.
:func:`columns` sizes each lane to its widest value. It can do this only after it has
all the records. A live capture never has all the records: ``monitor`` prints a row
when a packet arrives, and the next row can be two times as wide. Before, those two
streams joined their fields with the gutter and had no alignment at all. For this
reason, a name in one of them still had its quotes: nothing else showed where the
field ended.
Lanes that are fixed at the start correct this problem fully. A value that is wider
than its lane **goes past its lane and pushes the rest of that row to the right**. It
is not cut with an ellipsis. One row of many is wider, and nothing gives false
information about what the row contains. Put the one field that has no width limit
(the name of a node, the body of a message) last. Then it pushes nothing at all.
Attributes:
widths: ``(header, width)`` for each lane, in order.
right: The headers whose cells align to the right.
"""
widths: tuple[tuple[str, int], ...]
right: frozenset[str] = frozenset()
@property
def header(self) -> str:
"""The header line, printed before the first record."""
return self.record(*(header for header, _ in self.widths))
def record(self, *cells: str) -> str:
"""One record, padded into the lanes and joined with the gutter."""
out: list[str] = []
for (header, width), cell in zip(self.widths, cells, strict=True):
out.append(cell.rjust(width) if header in self.right else cell.ljust(width))
return (" " * GUTTER).join(out).rstrip()
def stream(*widths: tuple[str, int], right: Sequence[str] = ()) -> Lanes:
"""Build a layout with fixed lanes for a live stream (refer to :class:`Lanes`).
Args:
*widths: ``(header, width)`` for each lane, in order.
right: The headings whose values align to the right.
Returns:
The layout, which makes its own header line and formats each record.
"""
return Lanes(widths=widths, right=frozenset(right))
# -- the console ---------------------------------------------------------------------
class _Trimmed:
"""A text stream that removes the trailing whitespace from each line that goes through.
Rich pads the cells of a table to the width of their column, also the cells of the
last column. Without this stream, each record ends with invisible spaces. The stream
is the only place that catches all of them (table padding, a justified :class:`Text`,
a blank line that Rich padded), and no renderable has to know about it.
The stream holds back only the *trailing run of whitespace*, never a full line. Rich
writes a rendered row one segment at a time, and on Windows it flushes after each
segment. Thus a wrapper that waits for the newline before it decides must decide
dozens of times in each row. And a wrapper that trims at each flush removes the
padding that separates two columns.
All the text up to the last character that is not a space goes through immediately.
The spaces after it wait until the stream knows if a newline or another column
follows. At the end of the stream, nothing follows, and the held spaces are truly
trailing whitespace. Thus the stream removes them.
"""
def __init__(self, stream: Any) -> None:
"""Wrap ``stream``, and hold back the trailing whitespace that arrives."""
self._stream = stream
self._held = ""
def write(self, text: str) -> int:
"""Write ``text``, but write whitespace only after something follows it on the line."""
pending = self._held + text
if "\n" in pending:
*lines, pending = pending.split("\n")
self._stream.write("".join(line.rstrip() + "\n" for line in lines))
body = pending.rstrip()
if body:
self._stream.write(body)
self._held = pending[len(body) :]
return len(text)
def flush(self) -> None:
"""Flush the wrapped stream, but keep the held whitespace.
This method intentionally writes nothing. A flush comes between two columns as
often as at the end of a line, and it does not tell which of the two it is.
"""
self._stream.flush()
def __getattr__(self, name: str) -> Any:
"""Delegate all other attributes (``isatty``, ``encoding``, ...) to the real stream."""
return getattr(self._stream, name)
def console(stream: Any = None) -> Console:
"""Build the scripted console: no colour, no wrapping, no trailing whitespace.
Args:
stream: The file to write to. The default is ``sys.stdout``.
Returns:
A :class:`~rich.console.Console` that writes plain text and nothing else.
"""
import sys
target = sys.stdout if stream is None else stream
reconfigure = getattr(target, "reconfigure", None)
if reconfigure is not None:
try: # a name or mark that is not in cp1252 must not raise on an old Windows console
reconfigure(encoding="utf-8")
except (ValueError, OSError): # pragma: no cover - the stream cannot be reconfigured
pass
return Console(
file=_Trimmed(target),
# Not `no_color`, which keeps bold, dim, reverse, and the other attributes, and
# removes only the colours. `color_system=None` is the only setting with which
# Rich writes no escape sequence at all.
color_system=None,
width=WIDTH,
# Without this, Rich finds a real terminal and soft-wraps to its width.
soft_wrap=True,
highlight=False,
emoji=False,
# A node broadcasts its own name, so each name on this console is remote data.
# Rich reads `[...]` as a style tag. Before, `[bold]Loud` printed as `Loud`: the
# name changed without a warning, and it was no longer the string that identifies
# the node. Also, `[/]Bob` raised MarkupError, and the full command stopped. The
# scripted CLI never prints markup, so here the parser can only cause errors.
markup=False,
)
def stderr_console() -> Console:
"""The console for all the output that is *about* the run, and not part of its answer.
stdout contains only the answer to the command. Thus a progress bar, a log line, an
acknowledgement, and an error message all go to this console instead. They are
visible in a terminal, but they are not in ``meshterm contacts > contacts.txt``. This
console keeps its colour, because usually only a terminal reads it.
**When it does not write to a terminal, it does not wrap.** That is the purpose of the
width below. When Rich cannot measure the destination, it uses 80 cells. Thus
``2> errors.log`` wrapped each message at 80 cells, and then
``grep 'not already connected'`` found nothing, because the logger broke the sentence
across three lines. In a real terminal, the console does not change the width: a
progress bar that is 16384 cells wide is useless as a progress bar.
Returns:
A :class:`~rich.console.Console` with the theme, that writes to ``sys.stderr``.
"""
import sys
from .theme import active_theme
reconfigure = getattr(sys.stderr, "reconfigure", None)
if reconfigure is not None:
try: # the ✓ of an acknowledgement must not depend on the code page of the machine
reconfigure(encoding="utf-8")
except (ValueError, OSError): # pragma: no cover - the stream cannot be reconfigured
pass
try:
wraps = bool(sys.stderr.isatty())
except (AttributeError, ValueError): # pragma: no cover - a closed or unusual stream
wraps = False
return Console(
file=sys.stderr,
theme=active_theme(),
markup=False,
emoji=False,
width=None if wraps else WIDTH,
)
# -- the fallback --------------------------------------------------------------------
def flatten(renderable: RenderableType) -> list[RenderableType]:
"""Remove the frames from a renderable that comes to the scripted console with them.
Each CLI surface is *written* plain: a tool gives its answer as a
:mod:`~meshterm.ui.report`, and the plain renderer builds it through :func:`columns`
and :func:`pairs`. This function is the fallback under that rule. It is for a
renderable that the menu also uses and that still comes in a box. A :class:`Panel`
loses its border and title, and the function returns its body. A :class:`Table`
loses its box, its title, and its expansion. The function flattens a :class:`Group`
one member at a time.
It is intentionally not a *design*. The table of a screen has the columns of the
menu, not the columns of the CLI. Thus this function makes such a table printable,
not correct.
Args:
renderable: The renderable that a tool gave to
:meth:`~meshterm.ui.surface.PlainUi.show`.
Returns:
The renderables without frames, to print in order.
"""
if isinstance(renderable, Panel):
return flatten(renderable.renderable)
if isinstance(renderable, Table):
renderable.title = None
renderable.caption = None
renderable.box = None
renderable.show_edge = False
renderable.pad_edge = False
renderable.expand = False
renderable.padding = (0, GUTTER, 0, 0)
for column in renderable.columns:
column.no_wrap = True
column.overflow = "crop"
column.ratio = None
return [renderable]
if isinstance(renderable, Group):
out: list[RenderableType] = []
for child in renderable.renderables:
out.extend(flatten(child))
return out
return [renderable]
def blank() -> Text:
"""One empty line, to separate a listing from the block after it."""
return Text("")