diff --git a/meshterm/ui/device_picker.py b/meshterm/ui/device_picker.py index bb89c50..2684718 100644 --- a/meshterm/ui/device_picker.py +++ b/meshterm/ui/device_picker.py @@ -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 diff --git a/meshterm/ui/surface.py b/meshterm/ui/surface.py index 6df5687..000b08c 100644 --- a/meshterm/ui/surface.py +++ b/meshterm/ui/surface.py @@ -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}), ) diff --git a/meshterm/ui/tui/session.py b/meshterm/ui/tui/session.py index adc9d7a..72e0c93 100644 --- a/meshterm/ui/tui/session.py +++ b/meshterm/ui/tui/session.py @@ -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 diff --git a/tests/test_tui.py b/tests/test_tui.py index d93473a..e73be2a 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -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)