The widest lane gets the widest column
The device config table let its two narrow lanes size themselves to their content first: SETTING stretched to the longest label it happened to hold and CURRENT to the longest value, and DESCRIPTION — the one lane whose content is systematically the widest — lived on what was left. That was 19 cells of 72, so nearly every explanation wrapped two or three lines deep and the reader scrolled a 67-line table to read twenty settings. The narrow lanes are capped instead, at 24 cells for the setting and 15 for the value, and their cells are pre-folded to fit so `no_wrap` still holds each lane to exactly that width. DESCRIPTION keeps the whole remainder: 27 cells, and the table falls to 52 lines. Four rows fold — three telemetry labels and the path-hash value — and every one of them sat on a wrapping description already, so the fold costs nothing the reallocation didn't already buy back. A folded label hangs two further in, so a continuation can never be read as a section heading at column 0, and a value folds before its parenthesised label rather than through it: `1` over `(2-byte hashes)`, not a torn `1 (2-byte`. A long node name — the field is 31 bytes — now folds too, where before it dragged the whole lane across the screen. The cap only exists to buy the prose room, so where that lane isn't drawn it lifts. Two places don't draw it: the PicoCalc, whose 53 columns are spent by the setting and its value, and the staged view, whose third narrow lane leaves nothing for prose. Rich was already dropping the column outright at both widths — now the table says so itself, and shreds nothing on the way. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
c4a718c28a
commit
1d951052bf
2 files changed
+187
-15
No files matched your search
@@ -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]``.
|
||||
|
||||
|
||||
@@ -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 ----------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user