diff --git a/meshterm/ui/config_editor.py b/meshterm/ui/config_editor.py index 226fdf0..9ff1154 100644 --- a/meshterm/ui/config_editor.py +++ b/meshterm/ui/config_editor.py @@ -28,6 +28,7 @@ first-class manager — see the ``channels`` tool.) from __future__ import annotations import asyncio +import textwrap from pathlib import Path from typing import TYPE_CHECKING, Any, Optional from urllib.parse import quote @@ -54,6 +55,7 @@ from ..core.device_config import ( parse_value, settings_by_category, ) +from ..platforms import Platform, on_platform from .menus import ( confirm_discard, exit_rows, @@ -217,6 +219,11 @@ def config_table( """ pending = pending or {} show_staged = bool(pending) + # The DESCRIPTION lane needs a screen wide enough to hold prose beside the setting and + # its value(s) — 72 columns, and no third narrow lane. Where it doesn't fit, the table + # is the settings alone (Rich was already dropping the column outright at those + # widths) and the explanations stay in the editor, one row at a time. + describe = _describe and not show_staged # Match the contacts list: a frameless SIMPLE_HEAD table with a left-justified accent title # and muted headers, so the two screens read as one family (see widgets.contacts_table). table = Table( @@ -229,40 +236,112 @@ def config_table( expand=False, padding=(0, 2, 0, 0), ) + # The two narrow lanes are ``no_wrap`` and pre-folded to a cap (see _fold_label / + # _fold_value), so each takes exactly the room its cap allows and DESCRIPTION — the + # lane whose content is systematically the widest — keeps the whole remainder. Letting + # the labels and values size themselves instead gave the descriptions 19 cells of a + # 72-cell screen and wrapped almost every one of them three lines deep. table.add_column("SETTING", style="muted", no_wrap=True) table.add_column("CURRENT", no_wrap=True) if show_staged: table.add_column("STAGED", style="warn", no_wrap=True) - table.add_column("DESCRIPTION", style="muted") - # Each setting's name is indented two spaces so the rows read as sitting *under* their - # accent section heading, which stays flush-left. - indent = " " + if describe: + table.add_column("DESCRIPTION", style="muted") + + def row_of(cells: list[str], description: str) -> list[str]: + """One table row: the narrow lanes, plus the description where it is drawn.""" + return [*cells, description] if describe else cells + for category, specs in settings_by_category(): table.add_section() header = [f"[accent]── {category} ──[/accent]", ""] if show_staged: header.append("") - table.add_row(*header, "") + table.add_row(*row_of(header, "")) for spec in specs: - current = format_value(spec, spec.getter(snapshot)) - row = [f"{indent}{spec.label}", current] + row = [ + _fold_label(spec.label, describe), + _fold_value(format_value(spec, spec.getter(snapshot)), describe), + ] if show_staged: - row.append(format_value(spec, pending[spec.key]) if spec.key in pending else "") - table.add_row(*row, spec.help) + row.append( + _fold_value(format_value(spec, pending[spec.key]), describe) + if spec.key in pending + else "" + ) + table.add_row(*row_of(row, spec.help)) if custom: table.add_section() - cols = 4 if show_staged else 3 - table.add_row( - "[accent]── Custom ──[/accent]", *([""] * (cols - 1)) - ) + blanks = 2 if show_staged else 1 + table.add_row(*row_of(["[accent]── Custom ──[/accent]", *([""] * blanks)], "")) for key, value in custom.items(): - row = [f"{indent}{key}", value] + row = [_fold_label(key, describe), _fold_value(value, describe)] if show_staged: row.append("") - table.add_row(*row, "") + table.add_row(*row_of(row, "")) return table +#: Cell caps for the table's two narrow lanes, indent included. Sized to the widest label +#: and value the settings actually carry, less the few longest — the four that overflow +#: fold onto a second line, which costs nothing on rows whose description was wrapping +#: anyway, and buys every other row ten more cells of prose. +_SETTING_CAP = 24 +_VALUE_CAP = 15 + +#: Each setting's name is indented two spaces so the rows read as sitting *under* their +#: accent section heading, which stays flush-left; a folded continuation hangs two further +#: in, so a wrapped label can never be mistaken for a heading at column 0. +_INDENT = " " +_HANG = " " + + +def _fold_label(label: str, describe: bool) -> str: + """One SETTING cell: indented, and hard-wrapped at :data:`_SETTING_CAP` if it must be. + + The cap only buys room for the DESCRIPTION lane, so where that lane isn't drawn the + label keeps its natural width — nothing is competing for the cells. + """ + if not describe: + return f"{_INDENT}{label}" + return ( + "\n".join( + textwrap.wrap( + label, _SETTING_CAP, initial_indent=_INDENT, subsequent_indent=_HANG + ) + ) + or _INDENT + ) + + +def _fold_value(value: str, describe: bool) -> str: + """One CURRENT/STAGED cell, hard-wrapped at :data:`_VALUE_CAP`. + + An enum value reads ``N (label)``, so the fold prefers the break *before* the + parenthesised label — ``1`` over ``(2-byte hashes)`` rather than a torn ``1 (2-byte`` + — and a long node name (the field is 31 bytes wide) folds instead of stretching the + lane across the whole screen. + """ + if not describe or cell_len(value) <= _VALUE_CAP: + return value + head, sep, tail = value.partition(" (") + if sep and cell_len(head) <= _VALUE_CAP and cell_len(tail) + 1 <= _VALUE_CAP: + return f"{head}\n({tail}" + return "\n".join(textwrap.wrap(value, _VALUE_CAP)) or value + + +#: Whether this platform's screen is wide enough for a DESCRIPTION lane at all. The +#: PicoCalc's 53 columns are spent by the setting and its value. +_describe: bool = True + + +@on_platform +def _bind_describe(platform: Platform) -> None: + """Bind the DESCRIPTION lane to the platform (runs now and on every switch).""" + global _describe + _describe = platform.readable_cols >= 72 + + def _setting_value(spec: SettingSpec, snapshot: dict, pending: dict) -> Text: """One setting's VALUE lane: ``current [→ staged]``. diff --git a/tests/test_config_editor.py b/tests/test_config_editor.py index 28c44a2..48e72c9 100644 --- a/tests/test_config_editor.py +++ b/tests/test_config_editor.py @@ -8,6 +8,7 @@ each prompt from a queue. from __future__ import annotations +import io import re from contextlib import asynccontextmanager from pathlib import Path @@ -21,14 +22,17 @@ from meshterm.core.admin_store import AdminStore from meshterm.core.config import Settings from meshterm.core.device_store import DeviceStore from meshterm.persistence.repository import Repository +from meshterm.platforms import PICOCALC, REGULAR, set_platform from meshterm.ui.config_editor import ( _parse_coords, _valid_coords, + config_table, contact_share_url, device_actions, edit_config, send_advert, ) +from meshterm.ui.theme import active_theme from meshterm.ui.tui.prompt import TypedConfirmDialog from meshterm.ui.tui.screen import CANCEL @@ -53,6 +57,95 @@ class _Fut: self._done = True +# -- the config table's column allocation --------------------------------------- + +#: A snapshot carrying one of every field the table draws, including the values that push +#: each lane widest: the longest setting label and the longest formatted value. +_SNAPSHOT = { + "name": "Homestead-Hub", + "adv_lat": 45.5017, + "adv_lon": -73.5673, + "pin_code": 123456, + "radio_freq": 869525, + "radio_bw": 250, + "radio_sf": 11, + "radio_cr": 5, + "tx_power": 22, + "max_tx_power": 30, + "airtime_factor": 1.0, + "rx_delay": 0.0, + "manual_add_contacts": 0, + "autoadd_config": 0, + "flood_scope": "", + "adv_loc_policy": 1, + "multi_acks": 0, + "telemetry_mode_base": 1, + "telemetry_mode_loc": 0, + "telemetry_mode_env": 0, + "path_hash_mode": 1, +} + + +def _table_lines(width: int, **kwargs: Any) -> list[str]: + """The config table's rendered lines at ``width`` columns.""" + console = Console( + width=width, file=io.StringIO(), theme=active_theme(), legacy_windows=False + ) + with console.capture() as capture: + console.print(config_table(_SNAPSHOT, {}, **kwargs)) + return _plain(capture.get()).rstrip("\n").split("\n") + + +def test_config_table_gives_the_description_lane_the_widest_share() -> None: + lines = _table_lines(72) + header = next(line for line in lines if line.lstrip().startswith("SETTING")) + setting = header.index("CURRENT") + current = header.index("DESCRIPTION") - setting + description = 72 - header.index("DESCRIPTION") + # DESCRIPTION carries the systematically widest content, so it takes the widest lane + # — not what two lanes that sized themselves to their content left over. Its share + # used to be 19 of the 72 cells, and every explanation wrapped three deep. + assert description >= setting > current + assert description >= 26 + + +def test_config_table_keeps_every_row_within_three_lines() -> None: + # The whole point of the reallocation: no setting's explanation sprawls further than + # three lines at the readability standard. + lines = _table_lines(72) + body = [line for line in lines if line.strip() and not line.startswith("──")] + runs, run = [], 0 + for line in body: + if line.startswith(" ") and not line.startswith(" "): + runs.append(run) + run = 1 + else: + run += 1 + runs.append(run) + assert max(runs) <= 3 + + +def test_config_table_drops_the_description_lane_where_it_cannot_fit() -> None: + # PicoCalc's 53 columns hold the setting and its value and nothing more; the labels + # keep their natural width there, nothing competing for the cells. + set_platform(PICOCALC) + try: + lines = _table_lines(PICOCALC.readable_cols) + finally: + set_platform(REGULAR) + header = next(line for line in lines if line.lstrip().startswith("SETTING")) + assert "DESCRIPTION" not in header + assert any("Telemetry mode (environment)" in line for line in lines) + assert max(len(line.rstrip()) for line in lines) <= PICOCALC.readable_cols + + +def test_config_table_drops_the_description_lane_beside_a_staged_column() -> None: + # Three narrow lanes leave no room for prose, so the staged view is values alone. + lines = _table_lines(72, pending={"tx_power": 14}) + header = next(line for line in lines if line.lstrip().startswith("SETTING")) + assert "STAGED" in header and "DESCRIPTION" not in header + + # -- contact share URL ----------------------------------------------------------