The device splash pans as one table, its badge column first
The transport badge now leads each row with no heading over it. A list whose rows declare `pans` slides its column header and every table row by one shared shift, kept as the highlight moves and stopping where the widest line meets the edge, so the splash box keeps its width. Rows outside the table, like Add a network device and Quit, stay put. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
8fbf5b372f
commit
db7e42497f
3 files changed
+212
-81
No files matched your search
@@ -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
|
||||
|
||||
|
||||
+108
-19
@@ -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
|
||||
|
||||
+76
-31
@@ -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)
|
||||
|
||||
|
||||
Reference in new issue
Block a user