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:
Jean-Pierre MartineauandClaude Opus 5 committed 2026-09-05 14:52:45 -04:00
1 parent c4a718c28a
commit 1d951052bf
2 files changed
+187 -15

No files matched your search

+94 -15
View File
@@ -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]``.
+93
View File
@@ -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 ----------------------------------------------------------