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, keys=_SHORTCUTS,
key_hint=_shortcut_hint(len(store.hidden_ids())), key_hint=_shortcut_hint(len(store.hidden_ids())),
live=keep_looking, 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 # 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. # 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_text(cell)
row.append(" " * max(0, type_w - cell.cell_len)) row.append(" " * max(0, type_w - cell.cell_len))
row.append(" ") row.append(" ")
# Everything up to here is the row's fixed lanes — the reader's place in the list — # Nothing is pinned here, so ←→ slide the whole row. The Trophy case pins its
# so it is pinned and only what follows slides under ←→ (see Choice.hscroll_from). # rank/date/score lanes because those always fit and only the walk overflows, which
# What follows is the hardware model and its tag, which is the part that runs long: # makes them the reader's place in a long list. This list inverts that: on a narrow
# a firmware model string is as long as its vendor felt like making it. # terminal it is the device name and the address that get cut, so pinning them would
head = row.cell_len # 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") row.append(_pad(_hardware_label(device, registry), hardware_w), style="muted")
# Only devices we've actually confirmed are billed as MeshCore companions; a USB # 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 # 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 # 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 # 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. # 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()) items.extend(_action_rows())
return items return items
+4
View File
@@ -223,6 +223,7 @@ class Ui:
keys: Mapping[str, Any] | None = None, keys: Mapping[str, Any] | None = None,
key_hint: Callable[[Any], str] | None = None, key_hint: Callable[[Any], str] | None = None,
live: Callable[[Callable[[list], None]], Awaitable[None]] | None = None, live: Callable[[Callable[[list], None]], Awaitable[None]] | None = None,
hscroll: bool | None = None,
) -> Any: ) -> Any:
"""Choose one item on a chromeless startup splash; ``None`` if skipped. """Choose one item on a chromeless startup splash; ``None`` if skipped.
@@ -522,6 +523,7 @@ class PlainUi(Ui):
keys: Mapping[str, Any] | None = None, keys: Mapping[str, Any] | None = None,
key_hint: Callable[[Any], str] | None = None, key_hint: Callable[[Any], str] | None = None,
live: Callable[[Callable[[list], None]], Awaitable[None]] | None = None, live: Callable[[Callable[[list], None]], Awaitable[None]] | None = None,
hscroll: bool | None = None,
) -> Any: ) -> Any:
"""Unsupported in scripted CLI mode.""" """Unsupported in scripted CLI mode."""
raise self._no_prompt() raise self._no_prompt()
@@ -786,6 +788,7 @@ class TuiUi(Ui):
keys: Mapping[str, Any] | None = None, keys: Mapping[str, Any] | None = None,
key_hint: Callable[[Any], str] | None = None, key_hint: Callable[[Any], str] | None = None,
live: Callable[[Callable[[list], None]], Awaitable[None]] | None = None, live: Callable[[Callable[[list], None]], Awaitable[None]] | None = None,
hscroll: bool | None = None,
) -> Any: ) -> Any:
"""Delegate to the session's chromeless startup select splash.""" """Delegate to the session's chromeless startup select splash."""
return await self.session.select_startup( return await self.session.select_startup(
@@ -797,6 +800,7 @@ class TuiUi(Ui):
keys=keys, keys=keys,
key_hint=key_hint, key_hint=key_hint,
live=live, live=live,
hscroll=hscroll,
**({} if footer_hint is None else {"footer_hint": footer_hint}), **({} 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, keys: Mapping[str, Any] | None = None,
key_hint: Callable[[Any], str] | None = None, key_hint: Callable[[Any], str] | None = None,
live: Callable[[Callable[[list], None]], Awaitable[None]] | None = None, live: Callable[[Callable[[list], None]], Awaitable[None]] | None = None,
hscroll: bool | None = None,
) -> Any: ) -> Any:
"""Show a chromeless select splash (banner above a content-sized box). """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 nothing outlives the list it was redrawing and no repaint lands on a dialog that
has since opened over it. 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 ``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`) — 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, and ``key_hint`` says what they are called on the row the highlight is standing on,
@@ -958,6 +963,7 @@ class TuiSession:
filterable=False, filterable=False,
keys=keys, keys=keys,
key_hint=key_hint, key_hint=key_hint,
hscroll=hscroll,
) )
screen.chrome = False screen.chrome = False
screen.banner = banner 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: def test_the_splash_scrolls_the_whole_row_it_is_on() -> None:
"""←→ read a long firmware model to its end; the lanes in front of it stay put.""" """←→ 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.device_store import RememberedDevice
from meshterm.core.discovery import serial_device from meshterm.core.discovery import serial_device
from meshterm.platforms import PICOCALC, REGULAR, set_platform 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)): for platform, width in ((REGULAR, 66), (PICOCALC, 47)):
set_platform(platform) set_platform(platform)
items = _build_items([radio], registry[radio.stable_id], registry) 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)) row = next(it for it in items if isinstance(it, Choice))
assert row.hscroll_from > 0 # the fixed lanes are pinned... assert not row.hscroll_from, "no lane is pinned on the splash any more"
assert row.hscroll_from < width # ...and leave something to scroll into
before = _plain(screen.render_body(width)[1]) before = _plain(screen.render_body(width)[1])
for _ in range(3): for _ in range(3):
screen.handle("right") screen.handle("right")
after = _plain(screen.render_body(width)[1]) after = _plain(screen.render_body(width)[1])
assert before != after, platform.name 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: finally:
set_platform(REGULAR) set_platform(REGULAR)