diff --git a/meshterm/ui/device_picker.py b/meshterm/ui/device_picker.py index bef6999..8257374 100644 --- a/meshterm/ui/device_picker.py +++ b/meshterm/ui/device_picker.py @@ -110,15 +110,15 @@ _BADGE_RIGHT = "▌" def _type_cell(device: DiscoveredDevice) -> Text: - """The TYPE-column badge for ``device`` as a styled fragment. + """The transport badge that leads ``device``'s row, as a styled fragment. Serial is a bare plug emoji (two cells, its own colour). Bluetooth is the rune on its blue badge, flanked by half-block slivers in the same blue so the fill reads as a slightly rounded chip a touch wider than the lone rune rather than a single hard-edged cell. - The plug carries a leading space so its two cells sit centred under the "TYPE" heading, - lining up with the Bluetooth rune (which the badge's left half-block already nudges in a - cell) rather than hugging the column's left edge. + The plug carries a leading space so its two cells sit centred on the Bluetooth rune + (which the badge's left half-block already nudges in a cell) rather than hugging the + column's left edge. """ if device.is_tcp: return Text(" " + _TCP_ICON) @@ -479,9 +479,6 @@ 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. @@ -848,10 +845,10 @@ def _build_items( ) -> list: """Build the aligned splash rows (a muted header + one :class:`Choice` per device). - The row for each device leads with its display name (the remembered node's name when - known, else the hardware name), followed by a TYPE glyph marking the transport, the - HARDWARE column (the remembered firmware model, else the USB vendor) with its tag, and the - connection target last; columns are padded to a shared width so they align. Confirmed + The row for each device leads with a badge marking the transport, then its display name + (the remembered node's name when known, else the hardware name), the HARDWARE column + (the remembered firmware model, else the USB vendor) with its tag, and the connection + target last; columns are padded to a shared width so they align. Confirmed companions sort to the top (most-recent first), wear their name in white and a bright tag; the remembered default is starred. Trailing rows let the user name a network device by hand and quit here. @@ -860,7 +857,9 @@ def _build_items( a model string, not a heading. Each lane used to be squeezed against a budget so the row fitted the box, and on a narrow terminal (or beside a 36-cell CoreBluetooth UUID) that cut the name the reader came to read down to its first five letters. A row wider than the box - now runs off its edge instead, and ←→ slide the highlighted row to read it to its end. The + now runs off its edge instead, and ←→ pan the whole table — header and every device row + together (:attr:`~meshterm.ui.tui.select.Choice.pans`), so each lane stays under its + label wherever the reader has panned to, and stays panned as the highlight moves. The address goes last because it is the lane the reader needs least: it tells two similar rows apart, which the name and the hardware usually already have. @@ -893,26 +892,24 @@ def _build_items( port_label = ( "PORT / ADDRESS" if has_serial and has_address else "ADDRESS" if has_address else "PORT" ) - # The TYPE column holds a small transport badge (at most 3 cells); its heading is wider, - # so the four-cell "TYPE" label sets the column width and every badge pads out to it. - type_w = len("TYPE") + # The badge column leads, unlabelled: a plug or a Bluetooth rune says what it is, and a + # heading over three cells of picture would only push the name a lane further right. + type_w = max(_type_cell(d).cell_len for d in devices) names = {d.stable_id: _display_name(d, registry) for d in devices} name_w = max(len("DEVICE"), *(cell_len(name) for name in names.values())) hardware_w = max(len("HARDWARE"), *(cell_len(_hardware_label(d, registry)) for d in devices)) tags = {d.stable_id: _tag(d, d.stable_id in known) for d in devices} tag_w = max(cell_len(text) for text, _ in tags.values()) - # A muted, aligned header, resolved against the render width because it is *pinned*: it - # leads the device rows as their landmark, so a long detection list keeps the lane names - # overhead as it scrolls — and a pinned row that wraps is drawn outside the body slice, - # where the second line costs the list a device. Every label is the one form, in full; a - # header wider than the box is cropped at its edge like the rows under it. The indent + # A muted, aligned header that leads the device rows as their landmark, so a long + # detection list keeps the lane names overhead as it scrolls. Every label is the one form, + # in full, and it pans with the rows, so a label is always over its lane. The indent # mirrors the row pointer (2) and the star column (2) so each label sits over its own lane. - lanes = [Lane("DEVICE", name_w + 2), Lane("TYPE", type_w + 2), Lane("HARDWARE", hardware_w + 2)] + lanes = [Lane("", type_w + 2), Lane("DEVICE", name_w + 2), Lane("HARDWARE", hardware_w + 2)] if tag_w: lanes.append(Lane("", tag_w + 2)) # the tag rides under HARDWARE's heading lanes.append(Lane(port_label)) - header = Separator(lambda width: column_header(lanes, width, indent=4), heading=True) + header = Separator(lambda width: column_header(lanes, width, indent=4), heading=True, pans=True) items: list = [header] for device in devices: @@ -921,24 +918,24 @@ def _build_items( row = Text() row.append("★" if is_remembered else " ", style="warn" if is_remembered else "") row.append(" ") - # A confirmed companion wears its name in white so it stands out from mere detections. - row.append(_pad(names[device.stable_id], name_w), style="device.known" if is_known else "") - row.append(" ") - # The TYPE badge marks the transport: a plug emoji for serial, or the Bluetooth rune - # on its blue badge for BLE. It's built with its own colours, then the column is - # padded with plain spaces — so the blue fill hugs just the badge, and the differing - # badge widths (emoji 2 cells, rune-plus-edges 3) still line up under "TYPE". + # The badge marks the transport: a plug emoji for serial, or the Bluetooth rune on its + # blue badge for BLE. It's built with its own colours, then the column is padded with + # plain spaces — so the blue fill hugs just the badge, and the differing badge widths + # still line up. cell = _type_cell(device) row.append_text(cell) row.append(" " * max(0, type_w - cell.cell_len)) row.append(" ") + # A confirmed companion wears its name in white so it stands out from mere detections. + row.append(_pad(names[device.stable_id], name_w), style="device.known" if is_known else "") + row.append(" ") row.append(_pad(_hardware_label(device, registry), hardware_w), style="muted") row.append(" ") if tag_w: tag, tag_style = tags[device.stable_id] row.append(_pad(tag, tag_w), style=tag_style) row.append(" ") - # Nothing is pinned here, so ←→ slide the whole row. The Trophy case pins its + # Nothing is pinned here, so ←→ pan 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: every lane is # drawn whole, so on a narrow terminal any of them may run past the edge, and pinning @@ -947,7 +944,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)) + items.append(Choice(title=row, value=device, deletable=device.is_tcp, pans=True)) items.extend(_action_rows()) return items diff --git a/meshterm/ui/tui/select.py b/meshterm/ui/tui/select.py index cbabb82..18f9fee 100644 --- a/meshterm/ui/tui/select.py +++ b/meshterm/ui/tui/select.py @@ -81,6 +81,12 @@ class Choice: of keeping its natural one for ←→ to slide over — nothing past the edge of such a row is worth sliding to, and a highlight that flipped the row back to a cut-off chart would undo the fit on exactly the row being read. + pans: The row is a line of a *table* that ←→ pan as one: every panning row and + panning column header (:attr:`Separator.pans`) slides by the same shift, so the + lanes stay under their labels however far the reader has panned, and the shift + survives ↑↓ rather than snapping back on each new row. Declaring it turns the + list's panning on, the way :attr:`hscroll_from` turns scrolling on; rows that + don't pan (an action row under the table) stay put. See :class:`SelectScreen`. """ title: str | Text | Callable[[], str | Text] | Callable[[int], str | Text] @@ -89,6 +95,7 @@ class Choice: detail: str | Text | Callable[[], str | Text] | None = None hscroll_from: int = 0 fitted: bool = False + pans: bool = False def __post_init__(self) -> None: """Read the title callable's arity once, so no paint has to ask again.""" @@ -238,12 +245,18 @@ class Separator: would shadow the heading it sits under). Set through :func:`~meshterm.ui.menus.section_heading`, or by hand on a column header that *is* its block's only landmark. + pans: The column header of a table that ←→ pan as one (see :attr:`Choice.pans`): + drawn in its fullest form and slid with the rows under it, so each label stays + over its lane. It is laid out like a row — its first two cells are the pointer + column, which stays put while the rest slides — which is how + :func:`~meshterm.ui.menus.column_header` already indents it. """ title: str | Text | Callable[[int], str | Text] style: str = "muted" pinned: bool = False heading: bool = False + pans: bool = False def text(self, width: int) -> str | Text: """The row's content at ``width`` cells, resolving a width-aware title.""" @@ -258,9 +271,20 @@ Item = "Choice | Separator" #: self-fitting column header or self-eliding row returns its fullest form. _UNBOUNDED = 10_000 +#: Cells a choice row's pointer (``❯ `` / `` ``) takes — the column a panning header keeps +#: still too, so its labels slide in step with the rows (see :attr:`Separator.pans`). +_POINTER = 2 + + +def _pans(items: list) -> bool: + """Whether any of ``items`` is a table row or header that ←→ pan (see :attr:`Choice.pans`).""" + return any(getattr(item, "pans", False) for item in items) + + #: The navigation actions that move the highlight to another row, so an ``hscroll`` list #: drops the current row's horizontal shift (each row scrolls on its own — see -#: :meth:`SelectScreen.handle`). The filter edits reset it in their own branches. +#: :meth:`SelectScreen.handle`). The filter edits reset it in their own branches. A panning +#: list keeps its shift: the table is what moved, not the row. _HSHIFT_RESET_ACTIONS = frozenset( { "up", @@ -371,7 +395,11 @@ class SelectScreen(Screen): on its own, independently of the rest of the screen. A row may hold a head block out of the scroll (:attr:`Choice.hscroll_from`), so only its overflowing run slides; whichever edges the line then continues past wear a - :func:`~meshterm.ui.pathline.cut_mark`. + :func:`~meshterm.ui.pathline.cut_mark`. A list whose rows *pan* + (:attr:`Choice.pans`) scrolls differently, and takes precedence: ←→ slide + the whole table — its column header and every panning row by one shared + shift, clamped to the widest of them — and the shift stays put as the + highlight moves. hscroll_hint: The footer atom surfaced (as the second ` · ` atom, right after the move atom) while ``hscroll`` is on and the highlighted row overflows — so ←→ advertises itself exactly when it would do something. Ignored when @@ -407,6 +435,9 @@ class SelectScreen(Screen): self._hscroll_auto and any(getattr(item, "hscroll_from", 0) > 0 for item in items) ) self._hscroll_hint = hscroll_hint + # A row built as a line of a table turns panning on, as a pinned head turns on + # scrolling (see Choice.pans); it is read off the rows each time they change. + self._pan = _pans(items) # Shortcuts are the non-filterable list's compensation for having no filter: the # letters are free, so a list may spend them (see the ``keys`` argument). self._keys: dict[str, Any] = {} if filterable else dict(keys or {}) @@ -460,6 +491,7 @@ class SelectScreen(Screen): self._items = items if self._hscroll_auto: self._hscroll = any(getattr(item, "hscroll_from", 0) > 0 for item in items) + self._pan = _pans(items) self.title = title self._prompt = prompt self._filter = "" @@ -500,6 +532,7 @@ class SelectScreen(Screen): self._hscroll = self._hscroll or any( getattr(item, "hscroll_from", 0) > 0 for item in items ) + self._pan = _pans(items) if title is not None: self.title = title if prompt is not None: @@ -508,7 +541,8 @@ class SelectScreen(Screen): index = next((i for i, c in enumerate(selectable) if c.value == was), None) if index is None: index = max(0, min(position, len(selectable) - 1)) - self._hshift = 0 # a different row is highlighted now + if not self._pan: + self._hshift = 0 # a different row is highlighted now; a pan is the table's self._index = index # --- filtering ----------------------------------------------------------- @@ -624,7 +658,7 @@ class SelectScreen(Screen): atoms = self._key_hint(current.value if current is not None else None) if atoms: base = splice_hint(base, atoms) - if self._hscroll and self._hscroll_hint and self._selected_overflows(): + if (self._hscroll or self._pan) and self._hscroll_hint and self._selected_overflows(): base = _insert_atom(base, self._hscroll_hint) if self._filter: base = _esc_verb(base, "clear") @@ -639,8 +673,12 @@ class SelectScreen(Screen): nothing reads as overflowing until a real width is known). Asked of :meth:`_max_hshift` rather than of the raw label width, so a row that pins a head block (:attr:`Choice.hscroll_from`) is judged on the run that would actually move — - the marks a scrolled row spends cells on included. + the marks a scrolled row spends cells on included. A panning list asks the same of + the whole table rather than of one row, since ←→ move the table wherever the + highlight is standing. """ + if self._pan and self._last_width > 0: + return self._pan_limit(self._last_width) > 0 if not self._hscroll or self._last_width <= 0: return False current = self._current_choice() @@ -718,7 +756,10 @@ class SelectScreen(Screen): # can't shift at all. Measured fresh each paint — a callable title may have changed # width — against the row's content area (width less the 2-cell pointer). self._last_width = width - if self._hscroll and self._hshift: + if self._pan: + # A panning list slides the table as one, as far as its widest line's tail. + self._hshift = max(0, min(self._hshift, self._pan_limit(width))) + elif self._hscroll and self._hshift: avail = max(1, width - 2) sel_len = cell_len(_plain(selected.scroll_text(avail))) if selected is not None else 0 anchor = selected.hscroll_from if selected is not None else 0 @@ -757,11 +798,22 @@ class SelectScreen(Screen): for item in rows: if isinstance(item, Separator): # A Text title carries its own spans (a two-colour column header); a plain - # string is drawn uniformly in the separator's style. Separators never - # h-scroll — the shift rides the highlighted choice row alone. - title = item.text(width) + # string is drawn uniformly in the separator's style. A separator h-scrolls + # only as a panning table's column header — the per-row shift rides the + # highlighted choice row alone. + title = item.text(_UNBOUNDED if item.pans and self._pan else width) content = title if isinstance(title, Text) else Text(title, style=item.style) - if item.pinned: + if item.pans and self._pan: + # Its fullest form, slid with the rows, its pointer column held still: + # one line however wide, since its labels must stay over their lanes. + slid = self._scroll_window(content, _POINTER, width) + drawn = [render_to_ansi(slid, width, no_wrap=True)] + if item.pinned: + self._pinned_header = (len(lines), drawn[0]) + elif item.heading: + block = list(drawn) + self._sticky_headers.append((len(lines), block)) + elif item.pinned: # A pinned header is drawn *outside* the body slice and is exactly one # row tall, so it crops like a row rather than wrapping — a column # header too wide for the terminal ellipsizes instead of stealing a @@ -811,8 +863,10 @@ class SelectScreen(Screen): # the 2-cell pointer). The exception is the highlighted row of an ``hscroll`` # list, which keeps its natural form: ←→ slide the full line, and a row that # pre-elided itself would have nothing left to slide over — unless the row - # declares its fitted form complete (``Choice.fitted``). - if self._hscroll and is_sel: + # declares its fitted form complete (``Choice.fitted``). A panning table's rows + # all keep their natural form, highlighted or not: the pan slides every one. + panned = self._pan and item.pans + if panned or (self._hscroll and not self._pan and is_sel): label = item.scroll_text(max(1, width - 2)) else: label = item.text(max(1, width - 2)) @@ -823,9 +877,10 @@ class SelectScreen(Screen): # keeps its colour. A plain string is styled uniformly as before. text = Text(pointer, style=style) label_text = label if isinstance(label, Text) else Text(label) - if self._hscroll and self._hshift and is_sel: - # Only the highlighted row slides, and only its label — the 2-cell pointer - # stays pinned. Every other row (and separator) renders unshifted. + if panned or (self._hscroll and not self._pan and self._hshift and is_sel): + # Only the label slides — the 2-cell pointer stays pinned. Per-row scrolling + # moves the highlighted row alone; a pan moves every row of the table, and + # draws its cut marks at the shift of 0 too, where a line runs off the edge. label_text = self._scroll_window(label_text, item.hscroll_from, max(1, width - 2)) text.append_text(label_text) text.style = style @@ -857,6 +912,40 @@ class SelectScreen(Screen): steps = -(-max(0, run - (lane - 1)) // self._HSCROLL_STEP) return steps * self._HSCROLL_STEP + def _pan_limit(self, width: int) -> int: + """How far ←→ may pan this list's table at ``width``: to its widest line's tail. + + Every panning line is measured on the same terms — a row in the content area after + its pointer, a column header over the whole width with its pointer column held — so + the two measure the same table and agree on where it ends. + + The stop is the exact shift that brings the widest line's tail to the edge, not the + whole :attr:`_HSCROLL_STEP` past it that :meth:`_max_hshift` takes for one row. Here + every line moves, so a whole-step stop would leave the entire table ending short of + the edge — and a box sized to its content (the startup splash) would shrink under the + reader on the last press, shedding hint atoms as it went. At the exact stop the + widest line meets the edge with no right-hand mark: its first hidden cell is past + its own end. + """ + avail = max(1, width - _POINTER) + + def tail(cells: int, anchor: int, room: int) -> int: + lane = max(1, room - anchor) + run = max(0, cells - anchor) + return 0 if run <= lane else run - (lane - 1) # the left mark costs a cell + + limit = 0 + for item in self._rows(): + if not getattr(item, "pans", False): + continue + if isinstance(item, Separator): + cells = cell_len(_plain(item.text(_UNBOUNDED))) + limit = max(limit, tail(cells, _POINTER, width)) + else: + cells = cell_len(_plain(item.scroll_text(avail))) + limit = max(limit, tail(cells, item.hscroll_from, avail)) + return limit + def _scroll_window(self, label: Text, anchor: int, avail: int) -> Text: """The highlighted row's label with its head pinned and its run slid ``_hshift`` in. @@ -916,7 +1005,7 @@ class SelectScreen(Screen): def handle(self, action: str, data: str = "") -> None: """Move the highlight, edit the filter, or commit/cancel the selection.""" choices = self._choices() - if self._hscroll and action in _HSHIFT_RESET_ACTIONS: + if self._hscroll and not self._pan and action in _HSHIFT_RESET_ACTIONS: self._hshift = 0 # moving off a row abandons its scroll — each row scrolls alone if action == "up": if choices: @@ -954,10 +1043,10 @@ class SelectScreen(Screen): # (e.g. a remembered network device in the picker); elsewhere it's inert. if choices and choices[self._index].deletable: self.resolve(DeleteRequest(choices[self._index].value)) - elif action == "left" and self._hscroll: + elif action == "left" and (self._hscroll or self._pan): self._hshift = max(0, self._hshift - self._HSCROLL_STEP) - elif action == "right" and self._hscroll: - self._hshift += self._HSCROLL_STEP # clamped to the highlighted row's tail at render + elif action == "right" and (self._hscroll or self._pan): + self._hshift += self._HSCROLL_STEP # clamped at render, to the row's or table's tail elif action == "escape": # A typed filter is the most recent thing the reader entered, so Esc peels that # before it leaves — the map and the mesh walk's rule, now every filtering diff --git a/tests/test_tui.py b/tests/test_tui.py index daf818b..6ad687a 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -1624,12 +1624,13 @@ def test_device_picker_shortens_nothing_and_puts_the_address_last() -> None: The lanes used to be squeezed against a budget so the row fitted the box, and beside a 36-cell CoreBluetooth UUID that cut a name down to ``Johnp…`` and the UUID to its tail. - Now a row wider than the box runs off its edge and ←→ read the rest, so the address, - the lane the reader needs least, goes last where running off the edge costs least. + Now a row wider than the box runs off its edge and ←→ pan to the rest, so the address, + the lane the reader needs least, goes last where running off the edge costs least. The + transport badge leads the row, with no heading over it. """ from meshterm.core.discovery import DiscoveredDevice from meshterm.platforms import PICOCALC_LYRA, REGULAR, set_platform - from meshterm.ui.device_picker import _build_items + from meshterm.ui.device_picker import _build_items, _display_name name = "MeshCore-Johnputer Wardriver" uuid = "12345678-1234-1234-1234-123456789ABC" @@ -1654,9 +1655,15 @@ def test_device_picker_shortens_nothing_and_puts_the_address_last() -> None: # The target is whole, and nothing follows it. assert row.endswith(device.target), platform.name assert name in rows[devices[0].stable_id] and adapter in rows[devices[2].stable_id] - # The header is unabridged at any width the row needs, ADDRESS last. + # The header is unabridged at any width the row needs, ADDRESS last, and the + # badge column it starts over has no label. header = items[0].text(1000) - assert header.split() == ["DEVICE", "TYPE", "HARDWARE", "PORT", "/", "ADDRESS"] + assert header.split() == ["DEVICE", "HARDWARE", "PORT", "/", "ADDRESS"] + for device in devices: + row = rows[device.stable_id] + name_at = row.index(_display_name(device, {})) + assert header.index("DEVICE") == cell_len(row[:name_at]) + 2 # + the pointer + assert row[:name_at].strip(), "the badge comes before the name" finally: set_platform(REGULAR) @@ -3630,22 +3637,60 @@ def test_a_hint_too_long_for_its_box_drops_atoms_rather_than_its_tail() -> None: ) -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. +def test_a_panning_list_slides_its_header_and_rows_as_one() -> None: + """←→ pan a table: its header and every row by one shift, kept as the highlight moves. - 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: - every lane is drawn whole, so on a narrow terminal any of them may run past the edge, - and pinning the head would leave part of a long name the one thing ←→ could not reach. - - 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. + The per-row scroll slides the highlighted row alone and drops its shift on ↑↓, which is + right for a list of independent long lines and wrong for a table, whose lanes mean + nothing once they have slid out from under their labels. A row that is not part of the + table (an action row under it) stays where it is. """ + from meshterm.ui.tui import Choice, SelectScreen, Separator + + # A header is laid out like a row: its first two cells are the pointer column. + header = Separator(" " + "NAME".ljust(10) + "-" * 30 + " WHERE", pans=True) + rows = [ + Choice("alpha".ljust(10) + "x" * 30 + " far-a", "a", pans=True), + Choice("beta".ljust(10) + "y" * 30 + " far-b", "b", pans=True), + ] + quit_row = Choice("Quit", "q") + screen = SelectScreen("t", [header, *rows, Separator(" "), quit_row], filterable=False) + width = 30 + + def lines() -> list[str]: + return _plain(screen.render_body(width)).split("\n") + + first = lines() + assert "←→ scroll" in screen.footer_hint # the table overflows, so ←→ say so + screen.handle("right") + screen.handle("right") + panned = lines() + assert panned[0] != first[0] and panned[1] != first[1] and panned[2] != first[2] + # Every line of the table moved by the same amount: the dash run under the header + # starts in the same column as the x and y runs under it. + starts = [line.index(ch) for line, ch in zip(panned[:3], "-xy", strict=True)] + assert len(set(starts)) == 1, panned + assert panned[-1] == first[-1], "the Quit row is not part of the table" + + screen.handle("down") + assert lines()[0] == panned[0], "moving the highlight keeps the pan" + + for _ in range(20): + screen.handle("right") + end = lines() + # Clamped at the widest line's tail, with each label over its lane. + assert end[0].rstrip().endswith("WHERE") and end[1].rstrip().endswith("far-a") + assert end[0].index("WHERE") == end[1].index("far-a") == end[2].index("far-b") + assert all(cell_len(line) <= width for line in end) + + +def test_the_splash_pans_its_table_on_every_platform() -> None: + """The splash's header and device rows pan together, and its action rows stay put.""" from meshterm.core.device_store import RememberedDevice from meshterm.core.discovery import serial_device - from meshterm.platforms import PICOCALC_LYRA, REGULAR, set_platform + from meshterm.platforms import CARDPUTER_ZERO, PICOCALC_LYRA, REGULAR, set_platform from meshterm.ui.device_picker import _build_items - from meshterm.ui.tui import Choice, SelectScreen + from meshterm.ui.tui import Choice, SelectScreen, Separator radio = serial_device("COM7", name="Wardriver") registry = { @@ -3659,25 +3704,25 @@ def test_the_splash_scrolls_the_whole_row_it_is_on() -> None: ) } try: - for platform, width in ((REGULAR, 66), (PICOCALC_LYRA, 47)): + for platform in (REGULAR, PICOCALC_LYRA, CARDPUTER_ZERO): set_platform(platform) items = _build_items([radio], registry[radio.stable_id], registry) - screen = SelectScreen( - "Select a companion device", items, filterable=False, hscroll=True - ) - row = next(it for it in items if isinstance(it, Choice)) - assert not row.hscroll_from, "no lane is pinned on the splash any more" + table = [it for it in items if getattr(it, "pans", False)] + assert isinstance(table[0], Separator) and table[0].heading + assert [it.value for it in table[1:]] == [radio] + assert not any( + it.pans for it in items if isinstance(it, Choice) and it.value is not radio + ), "Add a network device and Quit are not table rows" - before = _plain(screen.render_body(width)[1]) - for _ in range(3): + width = platform.readable_cols - platform.dialog_margin - 4 + screen = SelectScreen("Select a companion device", items, filterable=False) + before = _plain(screen.render_body(width)).split("\n") + for _ in range(20): screen.handle("right") - after = _plain(screen.render_body(width)[1]) - assert before != after, platform.name - # 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 + after = _plain(screen.render_body(width)).split("\n") + assert "Wardriver" in before[1] and "Wardriver" not in after[1], platform.name + # Panned to the end, the address sits under its label. + assert after[0].index("PORT") == after[1].index("COM7"), platform.name finally: set_platform(REGULAR)