The splash slides the whole row, because the whole row is what gets cut

The device rows pinned their fixed lanes and slid only the hardware model
underneath them. The reasoning was borrowed from the Trophy case, where the
rank, date and score always fit and only the walk overflows -- there the lanes
really are the reader's place in a long list, and sliding them off would cost
the row its identity to read something the columns already told you.

This list inverts that. On a narrow terminal it is the device name and the
address that get cut, so pinning them pinned the truncation in place: the one
thing the arrow keys could not reach was the text somebody most wanted to
finish reading. A name is not a landmark when it is the thing being elided.

So no lane is pinned, and the row slides whole. That needed the scroll to be
asked for outright, because what normally turns it on is a row pinning a head
block, and none of these do any more -- `hscroll` travels from the picker
through the splash surface to the list, the same path `live` took.

The test that covered the old behaviour is rewritten rather than removed. It
was a fair test of a decision that has changed, and the argument for the new
one belongs where the old one was.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 5 committed 2026-09-21 03:46:15 -04:00
1 parent 551a86f47a
commit 2dbae7af77
4 files changed
+41 -12

No files matched your search

+10 -6
View File
@@ -442,6 +442,9 @@ async def prompt_device(
keys=_SHORTCUTS,
key_hint=_shortcut_hint(len(store.hidden_ids())),
live=keep_looking,
# Said outright because the rows no longer imply it: scrolling is switched on by
# a row pinning a head block, and none of these do any more.
hscroll=True,
)
# Esc (``None``) and the Quit row both mean "leave the picker" — surface that to the
# caller as ``None`` so it can exit the program instead of continuing device-less.
@@ -949,11 +952,12 @@ def _build_items(
row.append_text(cell)
row.append(" " * max(0, type_w - cell.cell_len))
row.append(" ")
# Everything up to here is the row's fixed lanes — the reader's place in the list —
# so it is pinned and only what follows slides under ←→ (see Choice.hscroll_from).
# What follows is the hardware model and its tag, which is the part that runs long:
# a firmware model string is as long as its vendor felt like making it.
head = row.cell_len
# Nothing is pinned here, so ←→ slide the whole row. The Trophy case pins its
# rank/date/score lanes because those always fit and only the walk overflows, which
# makes them the reader's place in a long list. This list inverts that: on a narrow
# terminal it is the device name and the address that get cut, so pinning them would
# pin the truncation in place and leave the one thing ←→ could not reach being the
# text somebody most wants to finish reading.
row.append(_pad(_hardware_label(device, registry), hardware_w), style="muted")
# Only devices we've actually confirmed are billed as MeshCore companions; a USB
# vendor ID (or a BLE advert) is a sort hint, not a claim. A bare serial bridge earns
@@ -973,7 +977,7 @@ def _build_items(
# Only a network device opts into Delete-to-remove: it's listed solely from its
# remembered endpoint, so forgetting it is the only way it leaves the picker. A scanned
# serial/BLE device would just reappear, so Delete stays inert on those rows.
items.append(Choice(title=row, value=device, deletable=device.is_tcp, hscroll_from=head))
items.append(Choice(title=row, value=device, deletable=device.is_tcp))
items.extend(_action_rows())
return items
+4
View File
@@ -223,6 +223,7 @@ class Ui:
keys: Mapping[str, Any] | None = None,
key_hint: Callable[[Any], str] | None = None,
live: Callable[[Callable[[list], None]], Awaitable[None]] | None = None,
hscroll: bool | None = None,
) -> Any:
"""Choose one item on a chromeless startup splash; ``None`` if skipped.
@@ -522,6 +523,7 @@ class PlainUi(Ui):
keys: Mapping[str, Any] | None = None,
key_hint: Callable[[Any], str] | None = None,
live: Callable[[Callable[[list], None]], Awaitable[None]] | None = None,
hscroll: bool | None = None,
) -> Any:
"""Unsupported in scripted CLI mode."""
raise self._no_prompt()
@@ -786,6 +788,7 @@ class TuiUi(Ui):
keys: Mapping[str, Any] | None = None,
key_hint: Callable[[Any], str] | None = None,
live: Callable[[Callable[[list], None]], Awaitable[None]] | None = None,
hscroll: bool | None = None,
) -> Any:
"""Delegate to the session's chromeless startup select splash."""
return await self.session.select_startup(
@@ -797,6 +800,7 @@ class TuiUi(Ui):
keys=keys,
key_hint=key_hint,
live=live,
hscroll=hscroll,
**({} if footer_hint is None else {"footer_hint": footer_hint}),
)
+6
View File
@@ -918,6 +918,7 @@ class TuiSession:
keys: Mapping[str, Any] | None = None,
key_hint: Callable[[Any], str] | None = None,
live: Callable[[Callable[[list], None]], Awaitable[None]] | None = None,
hscroll: bool | None = None,
) -> Any:
"""Show a chromeless select splash (banner above a content-sized box).
@@ -941,6 +942,10 @@ class TuiSession:
nothing outlives the list it was redrawing and no repaint lands on a dialog that
has since opened over it.
``hscroll`` overrules what the rows imply: a splash whose rows pin no head block
still wants ←→ to read a long row to its end, and a row that pins nothing cannot
ask for that on its own (see :class:`~meshterm.ui.tui.select.SelectScreen`).
``keys`` hands the list bare-key shortcuts — which a splash can afford precisely
because it does not filter, so every letter is free (see :class:`SelectScreen`) —
and ``key_hint`` says what they are called on the row the highlight is standing on,
@@ -958,6 +963,7 @@ class TuiSession:
filterable=False,
keys=keys,
key_hint=key_hint,
hscroll=hscroll,
)
screen.chrome = False
screen.banner = banner
+21 -6
View File
@@ -3344,8 +3344,18 @@ def test_a_hint_too_long_for_its_box_drops_atoms_rather_than_its_tail() -> None:
)
def test_the_splash_scrolls_the_hardware_column_of_the_row_it_is_on() -> None:
"""←→ read a long firmware model to its end; the lanes in front of it stay put."""
def test_the_splash_scrolls_the_whole_row_it_is_on() -> None:
"""←→ move the entire row, lanes included, rather than sliding the tail under them.
The Trophy case pins its rank/date/score lanes because those always fit and only the
walk overflows, so they are the reader's place in a long list. This list inverts that:
on a narrow terminal it is the device name and the address that get cut, so pinning
them would pin the truncation in place and the one thing ←→ could not reach would be
the text somebody most wants to finish reading.
Scrolling has to be asked for outright here, because it is a row pinning a head block
that normally turns it on and no row does now.
"""
from meshterm.core.device_store import RememberedDevice
from meshterm.core.discovery import serial_device
from meshterm.platforms import PICOCALC, REGULAR, set_platform
@@ -3367,17 +3377,22 @@ def test_the_splash_scrolls_the_hardware_column_of_the_row_it_is_on() -> None:
for platform, width in ((REGULAR, 66), (PICOCALC, 47)):
set_platform(platform)
items = _build_items([radio], registry[radio.stable_id], registry)
screen = SelectScreen("Select a companion device", items, filterable=False)
screen = SelectScreen(
"Select a companion device", items, filterable=False, hscroll=True
)
row = next(it for it in items if isinstance(it, Choice))
assert row.hscroll_from > 0 # the fixed lanes are pinned...
assert row.hscroll_from < width # ...and leave something to scroll into
assert not row.hscroll_from, "no lane is pinned on the splash any more"
before = _plain(screen.render_body(width)[1])
for _ in range(3):
screen.handle("right")
after = _plain(screen.render_body(width)[1])
assert before != after, platform.name
assert after.startswith(before[: row.hscroll_from - 4]), "the lanes moved"
# The head moved with everything else: what was at the left edge is gone.
assert not after.startswith(before[:8]), f"the lanes stayed put on {platform.name}"
# And the name is what scrolled out of view, which is the point -- it is the
# part a narrow terminal cuts, so it has to be reachable.
assert "Wardriver" in before
finally:
set_platform(REGULAR)