Files
Jean-Pierre MartineauandClaude Opus 5.5 0b931e7cc4 The Courier outbox is a TARGET and TRIGGER table, and Enter opens the message
Each section of the outbox now has column headers: TARGET and TRIGGER for
waiting entries, TARGET and OUTCOME for finished ones. The rows no longer
show the message. Enter on a waiting entry opens it to edit (the courier
holds the entry while the prompt is open, so the old text cannot go out
meanwhile), and Enter on a finished one shows it. Del deletes an entry
after a red confirm, and ^R sends a waiting entry now; on the PicoCalc
they ride F3 as Send, with Delete behind it. On a narrow screen a long
name gives cells to the trigger and breaks off on its crack.

The message field has the colours of the chat's compose line: a white
prompt mark and white text.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 00:22:05 -04:00

4172 lines
181 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SPDX-License-Identifier: Apache-2.0
"""Unit tests for the reusable text-UI library (``meshterm.ui.tui``).
These tests examine the pure logic. They do not start a real prompt_toolkit application,
so they run fast and without a display. The logic includes:
- The rendering and slicing of ANSI text.
- The filtering of a selection, and the navigation in it.
- The scroll calculation.
- The guarantee that a composed frame fits the terminal.
- The editing and validation of a prompt.
- The parity of the progress handle with the Rich ``Progress``.
"""
from __future__ import annotations
import asyncio
from prompt_toolkit.input.defaults import create_pipe_input
from prompt_toolkit.output import DummyOutput
from rich.cells import cell_len
from rich.console import Group
from rich.panel import Panel
from rich.table import Table
from rich.text import Text
from meshterm import copyright_notice
from meshterm.ui.menus import section_heading
from meshterm.ui.pathline import CRACK_TAIL, PathHop, PathLine
from meshterm.ui.tui import frame
from meshterm.ui.tui.progress import ProgressScreen
from meshterm.ui.tui.prompt import (
AutocompleteScreen,
ButtonDialog,
ConfirmScreen,
TextScreen,
)
from meshterm.ui.tui.render import render_lines, render_to_ansi
from meshterm.ui.tui.screen import CANCEL, Screen, ScrollScreen
from meshterm.ui.tui.select import Choice, ReorderScreen, SelectScreen, Separator
from meshterm.ui.tui.session import TuiSession
from meshterm.ui.tui.spinner import spinner_interval
from tests.conftest import plain as _plain
_UNSET = object()
class _Fut:
"""A minimal stand-in for an asyncio.Future that captures the result of a screen."""
def __init__(self) -> None:
self.result = _UNSET
self._done = False
def done(self) -> bool:
return self._done
def set_result(self, value: object) -> None:
self.result = value
self._done = True
def _run(screen, action: str, data: str = ""):
"""Attach a fake future, send one action, and return the result that it captured."""
screen.future = _Fut()
screen.handle(action, data)
return screen.future.result
# --- render ------------------------------------------------------------------
def test_render_lines_counts_visible_rows() -> None:
"""A table of three rows renders to three ANSI lines, with no extra blank line."""
table = Table(show_header=False, box=None)
table.add_column("a")
for value in ("one", "two", "three"):
table.add_row(value)
lines = render_lines(table, 40)
assert len(lines) == 3
assert not lines[-1].strip().endswith("\n")
def test_render_to_ansi_wraps_to_width() -> None:
"""The render uses the width that the caller asks for, and wraps long text onto more lines."""
long = Text("word " * 40) # The text has 200 characters, so it must wrap at width 20.
lines = render_to_ansi(long, 20).split("\n")
assert len(lines) > 1
# --- select ------------------------------------------------------------------
def _menu() -> SelectScreen:
items = [
Separator("── group ──"),
Choice("alpha", 1),
Choice("beta", 2),
Choice("gamma", 3),
]
return SelectScreen("pick", items, default=2)
def test_select_default_and_arrows_walk_the_choices() -> None:
"""The default choice is selected at the start, and the arrows step over the choices."""
screen = _menu()
assert _run(screen, "enter") == 2 # The default is beta.
screen = _menu()
screen.handle("down") # From beta to gamma.
screen.handle("up") # From gamma to beta.
screen.handle("up") # From beta to alpha.
assert _run(screen, "enter") == 1
def test_select_filter_narrows_but_keeps_separators() -> None:
"""Typing filters the list to the choices that match, and the section headings stay."""
screen = _menu()
screen.handle("text", "a") # This matches alpha, beta, and gamma. Each one has an 'a'.
rows = screen._rows()
assert [r.title for r in rows if isinstance(r, Separator)] == ["── group ──"]
assert {r.title for r in rows if isinstance(r, Choice)} == {"alpha", "beta", "gamma"}
screen.handle("text", "l") # The filter is now 'al', so only alpha matches. The heading stays.
rows = screen._rows()
assert [r.title for r in rows if isinstance(r, Choice)] == ["alpha"]
assert [r.title for r in rows if isinstance(r, Separator)] == ["── group ──"]
assert _run(screen, "enter") == 1
def test_select_filter_ignores_leading_and_trailing_spaces() -> None:
"""A leading space never starts the filter. The match ignores a trailing space."""
screen = _menu()
screen.handle("text", " ") # The screen ignores it. The filter never starts with a space.
assert screen._filter == ""
for ch in "alpha ": # The word "alpha", then a trailing space.
screen.handle("text", ch)
assert screen._filter == "alpha " # The space stays in the buffer...
# ...but the match removes it. Thus the trailing space does not stop "alpha" from matching.
assert [r.title for r in screen._rows() if isinstance(r, Choice)] == ["alpha"]
def test_select_non_filterable_ignores_typing() -> None:
"""If filtering is off, typed keys do not narrow the list and do not add a filter line."""
screen = SelectScreen("pick", [Choice("alpha", 1), Choice("beta", 2)], filterable=False)
screen.handle("text", "a")
screen.handle("backspace")
assert screen._filter == ""
assert len(screen._rows()) == 2 # The filter removed no row.
def test_select_delete_hint_follows_the_highlight() -> None:
"""The 'Del remove' atom shows only while the highlight is on a row that the user can delete."""
screen = SelectScreen(
"pick",
[Choice("keep", 1), Choice("drop", 2, deletable=True)],
footer_hint="↑↓ move · Enter select · Esc quit",
delete_hint="Del remove",
filterable=False,
)
# On the row that the user cannot delete, the footer is the plain base hint.
assert "Del remove" not in screen.footer_hint
screen.handle("down") # Move to the row that the user can delete.
# The atom appears. It goes in before the Esc atom at the end, so Esc stays last.
assert screen.footer_hint == "↑↓ move · Enter select · Del remove · Esc quit"
screen.handle("up") # Move back to the plain row.
assert "Del remove" not in screen.footer_hint
# The box has the size of the fullest footer, so it does not become wider on the move.
assert "Del remove" in screen.sizing_footer_hint
def test_select_no_delete_hint_leaves_footer_fixed() -> None:
"""If there is no delete_hint, a row that the user can delete does not change the footer."""
screen = SelectScreen(
"pick", [Choice("drop", 1, deletable=True)], footer_hint="↑↓ move · Esc quit"
)
assert screen.footer_hint == "↑↓ move · Esc quit"
assert screen.sizing_footer_hint == "↑↓ move · Esc quit"
def test_select_escape_cancels() -> None:
"""Esc resolves the sentinel and not a value."""
assert _run(_menu(), "escape") is CANCEL
def test_select_cursor_line_tracks_selection() -> None:
"""The line that the screen reports for the highlight counts the separators and filter line."""
screen = _menu() # The default is beta, which is row index 2 (separator, alpha, beta).
screen.render_body(40)
assert screen.cursor_line() == 2
def test_select_cursor_line_counts_a_prompt_once() -> None:
"""Under a prompt, the line that the screen reports for the highlight is the line of the row.
The screen lays out the rows after the lines of the prompt. Thus the index at which it
records them already counts the prompt. The screen once added the height of the prompt
again, and it reported a line that was too low. The frame kept that line visible. Then
↑ moved the real highlight above the top edge of the list window. This happened in the
archive preview, where the prompt is over a long list.
"""
items = [Choice(f"c{i}", i) for i in range(12)]
screen = SelectScreen("pick", items, prompt="Read the list, then pick one.", default=3)
lines = screen.render_body(40)
assert "❯ c3" in Text.from_ansi(lines[screen.cursor_line()]).plain
def test_select_callable_title_re_renders_live() -> None:
"""MeshTerm resolves a callable title on each paint, so a live badge follows the state."""
unread = {"n": 0}
screen = SelectScreen("pick", [Choice(lambda: f"chan ● {unread['n']}", 1)])
assert "chan ● 0" in "\n".join(screen.render_body(40))
unread["n"] = 3 # A message arrived while the list was open.
assert "chan ● 3" in "\n".join(screen.render_body(40))
def test_select_width_aware_title_fits_itself_to_the_row() -> None:
"""A title that knows the width fits itself to the row on which MeshTerm draws it.
A route elides its middle. The natural form of the title is still the text that the
filter uses, and the text that the dialog measures to find its own width.
"""
seen: list[int] = []
def fitted(width: int) -> str:
seen.append(width)
return "you → hub → far" if width >= 20 else "you ⋯ far"
screen = SelectScreen("pick", [Choice(fitted, 1)])
assert "you ⋯ far" in "\n".join(screen.render_body(12))
assert seen[-1] == 10 # The content area of the row: the width minus the pointer of 2 cells.
assert "you → hub → far" in "\n".join(screen.render_body(40))
# The screen measures the fullest form.
assert screen.dialog_width > cell_len("you → hub → far")
screen.handle("text", "hub") # The filter reads the natural form, which has no width limit.
assert screen._rows() == screen._items
def test_select_row_cracks_a_chip_path_and_ellipsizes_everything_else() -> None:
"""A row that is too wide for the list is cut at the crack, or ends with an ellipsis.
A row that has a path line (a trophy walk, a probe candidate) breaks its chip off at
the crack. An ordinary row of prose keeps the ellipsis. The row decides which one,
because the list does not know that it holds a route.
"""
route = PathLine(
[PathHop(f"NODE{i:02d}", key=f"{i:02x}aa") for i in range(8)], mode="powerline"
).text()
screen = SelectScreen("pick", [Choice(route, 1), Choice("a plainly worded row", 2)])
rows = [ln for ln in _plain(screen.render_body(16)).split("\n") if ln.strip()]
route_row = next(ln for ln in rows if "NODE" in ln)
prose_row = next(ln for ln in rows if "plainly" in ln)
assert route_row.rstrip().endswith(CRACK_TAIL) and "…" not in route_row
assert prose_row.rstrip().endswith("…") # The prose was shortened, and the ellipsis shows it.
def test_select_hscroll_highlight_keeps_the_natural_row() -> None:
"""In an hscroll list, the highlighted row keeps its natural text, which is not fitted.
←→ slide the full line in that row. Each other row still elides itself to the width.
"""
def fitted(width: int) -> str:
return "start middle end" if width >= 20 else "start ⋯ end"
items = [Choice(fitted, 1), Choice(fitted, 2)]
screen = SelectScreen("pick", items, hscroll=True)
body = "\n".join(screen.render_body(14))
# The highlighted row has its natural form, and the screen crops it.
assert "start middl" in body
assert "start ⋯ end" in body # The row that is not highlighted fitted itself.
def test_select_hscroll_highlight_draws_a_fitted_row_fitted() -> None:
"""A row whose fitted form is complete (``Choice.fitted``) stays fitted under the highlight.
The rows of the Channels list shorten their last chart to the width. When the screen
drew the natural form under the highlight, the chart came back cut off in the row that
the user was reading. There is nothing past the edge of such a row to slide to. Thus
the row does not claim to overflow: it has no ←→ atom, and → leaves the row where it is.
"""
def chart_row(width: int) -> str:
return "lanes " + "⣀" * max(0, min(12, width - 6))
screen = SelectScreen("pick", [Choice(chart_row, 1, fitted=True)], hscroll=True)
lines = [Text.from_ansi(line).plain for line in screen.render_body(14)]
assert lines[0] == "❯ lanes " + "⣀" * 6 # The row is fitted to the content area of 12 cells.
assert not screen._selected_overflows()
screen.handle("right")
assert [Text.from_ansi(line).plain for line in screen.render_body(14)] == lines
def test_select_filter_matches_callable_title() -> None:
"""The filter that the user types matches the current text of a callable title."""
screen = SelectScreen("pick", [Choice(lambda: "alpha", 1), Choice("beta", 2)])
screen.handle("text", "alp")
assert [r.label for r in screen._rows()] == ["alpha"]
assert _run(screen, "enter") == 1
# --- edge scroll ---------------------------------------------------------------
def _edge_list() -> SelectScreen:
"""Six lines of lead-in over four choices and a closing note, taller than 4 rows."""
items: list = [Separator(f"lead {i}") for i in range(6)]
items += [Choice(name, name) for name in ("alpha", "beta", "gamma", "delta")]
items.append(Separator("closing note"))
return SelectScreen("pick", items)
def _press(screen, action: str, data: str = "") -> None:
"""Send one key as the session does: the edge scroll first, then the screen."""
if not screen.edge_scroll(action):
screen.handle(action, data)
def _view(screen, viewport: int = 4) -> list[str]:
"""Paint one time through the slicer of the frame, and return the text of the visible rows."""
visible, _above, _below = frame._visible_slice(screen, screen.render_body(40), viewport)
return [_plain([line]).strip() for line in visible]
def test_edge_scroll_brings_back_what_sits_above_the_first_row() -> None:
"""↑ on the first row scrolls the page one line, up to its very top.
When the highlight followed the user down, it scrolled the lead-in off. Nothing in
the lead-in can take the highlight. Thus, before the edge scroll, the user had no way
back to it. The highlight can scroll off the screen in the meantime. The snap-back
brings it back, and it does not move it.
"""
screen = _edge_list()
assert _view(screen) == ["lead 3", "lead 4", "lead 5", "❯ alpha"]
_press(screen, "up") # Alpha is the first row, so the page scrolls one line.
assert _view(screen)[0] == "lead 2"
for _ in range(5):
_press(screen, "up")
view = _view(screen)
# This is the top. Alpha is off the screen.
assert view == ["lead 0", "lead 1", "lead 2", "lead 3"]
_press(screen, "up")
assert _view(screen) == view # Nothing is further up, so the view stays.
# The snap-back: ↓ only brings alpha back, and it stays highlighted. The next ↓ moves it.
_press(screen, "down")
assert "❯ alpha" in _view(screen)
_press(screen, "down")
assert "❯ beta" in _view(screen)
def test_edge_scroll_brings_back_what_sits_below_the_last_row() -> None:
"""↓ on the last row scrolls the closing note into view. ↑ moves the highlight as before."""
screen = _edge_list()
_view(screen)
for _ in range(3):
_press(screen, "down")
assert _view(screen) == ["alpha", "beta", "gamma", "❯ delta"] # The note is below.
_press(screen, "down") # Delta is the last row.
assert _view(screen) == ["beta", "gamma", "❯ delta", "closing note"]
_press(screen, "up") # The highlight is visible, so ↑ moves it.
assert "❯ gamma" in _view(screen)
def test_enter_on_a_highlight_scrolled_off_brings_it_back_instead() -> None:
"""Nothing runs that the user cannot see. The first Enter only snaps the highlight back."""
screen = _edge_list()
screen.future = _Fut()
_view(screen)
for _ in range(3):
_press(screen, "up")
assert "❯ alpha" not in _view(screen)
_press(screen, "enter")
assert screen.future.result is _UNSET # The user selected nothing...
assert "❯ alpha" in _view(screen) # ...and the highlight came back.
_press(screen, "enter")
assert screen.future.result == "alpha"
def test_home_and_end_take_the_page_to_its_very_ends() -> None:
"""End shows the closing note under the last row. Home shows the lead-in over the first row."""
screen = _edge_list()
_view(screen)
_press(screen, "end")
assert _view(screen) == ["beta", "gamma", "❯ delta", "closing note"]
_press(screen, "home")
assert _view(screen) == ["lead 0", "lead 1", "lead 2", "lead 3"]
_press(screen, "down") # The snap-back: alpha, where Home left the highlight, comes into view.
assert "❯ alpha" in _view(screen)
def test_any_other_key_ends_the_edge_scroll() -> None:
"""A filter that the user types moves the highlight, so the frame follows it again at once."""
screen = _edge_list()
_view(screen)
for _ in range(3):
_press(screen, "up")
_view(screen)
assert screen.edge_scrolled
_press(screen, "text", "g")
assert not screen.edge_scrolled
assert "❯ gamma" in _view(screen)
def test_a_screen_without_a_highlight_keeps_its_own_arrows() -> None:
"""The edge scroll does not act on a plain scroll screen, because the screen keeps its ↑↓."""
screen = ScrollScreen(Text("\n".join(f"row {i}" for i in range(20))), title="t")
for action in ("up", "down", "home", "end", "enter"):
assert not screen.edge_scroll(action)
class _Rows(Screen):
"""A screen that has no edge-scroll code: a lead-in, a highlight over rows, and a note.
Its ↑↓ clamp, as the arrows of each highlight do. Its Home and End move nothing. It
names the line of its highlight in ``cursor_line``, and a screen supplies only this.
``window`` is the number of rows that the screen draws at one time. The rows scroll
with the highlight, as a ListWindow does.
"""
def __init__(self, count: int = 3, *, window: int | None = None) -> None:
super().__init__()
self._count = count
self._window = window or count
self._index = 0
self._top = 0
self._cursor: int | None = None
def render_body(self, width: int) -> list[str]:
self._top = max(min(self._top, self._index), self._index - self._window + 1)
lines = [f"lead {i}" for i in range(4)]
for i in range(self._top, self._top + self._window):
if i == self._index:
self._cursor = len(lines)
lines.append(("❯ " if i == self._index else " ") + f"row {i}")
lines.append("note")
return lines
def cursor_line(self) -> int | None:
return self._cursor
def handle(self, action: str, data: str = "") -> None:
if action == "up":
self._index = max(0, self._index - 1)
elif action == "down":
self._index = min(self._count - 1, self._index + 1)
def test_every_screen_with_a_highlight_edge_scrolls() -> None:
"""A screen inherits the edge scroll: a screen that only names the line of its highlight has it.
_Rows does not say where its edges are. The paint reads them from the page.
"""
screen = _Rows()
assert _view(screen) == ["lead 1", "lead 2", "lead 3", "❯ row 0"]
_press(screen, "up") # Row 0 is the first row, so the page takes the line.
assert _view(screen) == ["lead 0", "lead 1", "lead 2", "lead 3"]
_press(screen, "down") # The snap-back.
assert "❯ row 0" in _view(screen)
for _ in range(2):
_press(screen, "down")
_view(screen)
_press(screen, "down") # Row 2 is the last row, so the note comes into view.
assert _view(screen) == ["row 0", "row 1", "❯ row 2", "note"]
def test_several_arrows_before_a_paint_scroll_as_many_lines() -> None:
"""Arrows that come faster than the paint are judged together.
Three arrows at the edge scroll three lines.
"""
screen = _Rows()
_press(screen, "up") # This is before any paint, so there is nothing on the page to judge.
_view(screen)
for _ in range(2):
_press(screen, "down")
_view(screen)
for _ in range(3):
_press(screen, "down")
view = _view(screen)
assert view == ["row 0", "row 1", "❯ row 2", "note"] # The view clamps at the end of the page.
def test_a_list_scrolling_in_its_own_window_is_not_at_its_edge() -> None:
"""A highlight that keeps its line while rows pass under it has moved: no edge scroll.
When the user walks ↓ in a list that has a window, the highlight stays on the last line
of the window. Thus the index of the line alone does not show if the highlight moved.
The line as drawn does show it.
"""
screen = _Rows(6, window=2)
_view(screen)
_press(screen, "down")
_view(screen)
before = screen.scroll
_press(screen, "down") # Row 2 is on the same body line as row 1, but the text is different.
assert "❯ row 2" in _view(screen)
assert not screen.edge_scrolled and screen.scroll == before
for _ in range(3):
_press(screen, "down")
_view(screen)
_press(screen, "down") # Row 5 is the last row.
assert _view(screen)[-1] == "note" and screen.edge_scrolled
def test_a_screen_can_keep_its_arrows_from_edge_scroll() -> None:
"""If ``edge_scrolls`` is off, the screen never edge scrolls.
The remote CLI uses ↑↓ to recall history.
"""
screen = _Rows()
screen.edge_scrolls = False
view = _view(screen)
_press(screen, "up")
assert _view(screen) == view and not screen.edge_scrolled
def test_home_that_moves_no_highlight_leaves_the_arrows_to_it() -> None:
"""Home takes the page to its top. Only the page says where the highlight went.
The Home of _Rows moves nothing, so ↑ after Home is still a step up the rows. The
arrow that points away from a highlight that is off the screen goes to the screen.
Then the highlight comes back into the viewport.
"""
screen = _Rows()
_view(screen)
for _ in range(2):
_press(screen, "down")
_view(screen)
_press(screen, "home")
assert _view(screen)[0] == "lead 0"
_press(screen, "up")
assert "❯ row 1" in _view(screen)
def test_a_list_with_no_rows_still_edge_scrolls() -> None:
"""If there is nothing to highlight, the empty state takes the place of the highlight."""
screen = _edge_list()
_view(screen)
_press(screen, "text", "z")
_press(screen, "text", "z")
assert _view(screen)[-1] == "no matches"
_press(screen, "up")
view = _view(screen)
assert "no matches" not in view and view[-1] == "closing note"
def test_reorder_home_and_end_reach_the_ends() -> None:
"""Home and End also move the highlight of the reorder list to its ends, as on each list."""
screen = ReorderScreen("order", ["a", "b", "c"])
screen.handle("end")
assert any(line.startswith("❯ c") for line in _view(screen))
screen.handle("home")
assert any(line.startswith("❯ a") for line in _view(screen))
def test_autocomplete_keeps_its_highlighted_suggestion_in_view() -> None:
"""A box that is too short for each suggestion follows the highlight, and edge scrolls."""
screen = AutocompleteScreen("t", [f"opt{i}" for i in range(8)], prompt="pick one")
_view(screen, 5)
for _ in range(7):
_press(screen, "down")
_view(screen, 5)
assert _view(screen, 5)[-1] == "❯ opt7"
for _ in range(7):
_press(screen, "up")
_view(screen, 5)
assert _view(screen, 5)[0] == "❯ opt0"
_press(screen, "up") # This is the first suggestion, so the field comes back above it.
assert _view(screen, 5)[1] == "❯ opt0"
def test_select_clamps_at_the_ends() -> None:
"""Up on the first row and Down on the last row stay where they are.
No highlight in the app rolls over.
"""
items = [Choice("alpha", 1), Choice("beta", 2), Choice("gamma", 3)]
screen = SelectScreen("pick", items)
screen.handle("up") # The highlight is on the first choice. It must not jump to the last.
assert _run(screen, "enter") == 1
screen = SelectScreen("pick", items, default=3) # The last choice.
screen.handle("down") # The highlight is on the last choice. It must not wrap to the first.
assert _run(screen, "enter") == 3
def test_select_pageup_pagedown_jump_by_a_screenful() -> None:
"""PageDown and PageUp move the highlight one screenful at a time. They clamp to the choices."""
items = [Choice(f"c{i}", i) for i in range(30)]
screen = SelectScreen("pick", items) # The highlight starts on the first choice.
screen.note_metrics(total=30, viewport=11) # A screenful is viewport - 1 = 10 rows.
screen.handle("pagedown")
assert _run(screen, "enter") == 10 # The highlight moved one page (viewport - 1) down.
screen = SelectScreen("pick", items, default=25)
screen.note_metrics(total=30, viewport=11)
screen.handle("pageup")
assert _run(screen, "enter") == 25 - 10 # Then it moved one page back up.
def _grouped_menu(default: object = None) -> SelectScreen:
"""A menu of two sections. Each section is long enough to scroll past a small viewport."""
items: list = [section_heading("Channels")]
items += [Choice(f"chan{i}", ("c", i)) for i in range(6)]
items += [section_heading("Direct")]
items += [Choice(f"peer{i}", ("d", i)) for i in range(8)]
return SelectScreen("pick", items, default=default)
def _top_plain(screen: SelectScreen, viewport: int) -> str:
"""Slice the screen at the scroll that its selection sets. Return the top row as plain text."""
lines = screen.render_body(40)
visible, _above, _below = frame._visible_slice(screen, lines, viewport)
return Text.from_ansi(visible[0]).plain.strip()
def test_select_pins_section_heading_when_it_scrolls_off() -> None:
"""If the user selects a row deep in a section, the heading of the section stays on top."""
# A highlight on a channel that is far enough down pushes the "Channels" heading off the
# top. The screen pins the heading again, and it does not vanish.
assert _top_plain(_grouped_menu(default=("c", 5)), viewport=6) == "── Channels ──"
# Deep in the Direct group, the pinned heading changes to the heading of that section.
assert _top_plain(_grouped_menu(default=("d", 6)), viewport=6) == "── Direct ──"
def test_select_does_not_pin_a_heading_that_is_still_visible() -> None:
"""If the list is at the top, the real heading shows, and nothing is pinned over it."""
screen = _grouped_menu() # The default is the first choice, so the scroll stays at 0.
lines = screen.render_body(40)
visible, above, _below = frame._visible_slice(screen, lines, 6)
assert Text.from_ansi(visible[0]).plain.strip() == "── Channels ──"
# This is the top of the list. There is no pinned copy and no "more above".
assert above is False
def _trophy_shaped() -> SelectScreen:
"""The shape of the Trophy case: a heading, its description, then the rows of that board."""
items: list = [section_heading("Longest haul")]
items += [Separator(f" description line {i}", style="muted") for i in range(2)]
items += [Choice(f"rec{i}", ("l", i)) for i in range(6)]
items += [section_heading("Widest arc"), Separator(" no records yet", style="muted")]
items += [Choice(f"arc{i}", ("a", i)) for i in range(6)]
return SelectScreen("Trophy case", items, default=("l", 5))
def _blocks(screen: SelectScreen) -> list[list[str]]:
"""The rows of each sticky block that the screen recorded, as plain text."""
return [
[Text.from_ansi(line).plain.strip() for line in rows]
for _idx, rows in screen._sticky_headers
]
def test_select_blocks_a_heading_with_the_prose_written_under_it() -> None:
"""The landmark of a heading continues through the separators that follow it at once.
This is the shape of the Trophy case. The ``── heading ──`` of each discipline has a
wrapped description after it. The description explains the rows below it, so it belongs
overhead with the heading. Prose that follows a row (a stray note, or the blank line
of the exit group) labels nothing, and it is not a landmark.
"""
screen = _trophy_shaped()
screen.render_body(40)
assert _blocks(screen) == [
["── Longest haul ──", "description line 0", "description line 1"],
["── Widest arc ──", "no records yet"],
]
def test_select_pins_a_block_row_only_once_it_has_scrolled_off() -> None:
"""A block gives its rows one at a time, so the pins continue into the body.
While the description is the top content row, only the heading pins over it. When
both have scrolled off, the two pin together. The prose never pins alone, because the
row that says which section this is always leads the rows overhead.
"""
screen = _trophy_shaped()
screen.render_body(40)
screen.note_metrics(total=20, viewport=12)
plain = lambda scroll: [ # noqa: E731 - a one-line reader for the assertions below
Text.from_ansi(line).plain.strip() for line in screen.sticky_rows(scroll)
]
assert plain(0) == [] # The heading is the top row, so there is nothing to copy.
assert plain(1) == ["── Longest haul ──"] # Its description is still on the screen.
assert plain(2) == ["── Longest haul ──", "description line 0"]
assert plain(4) == [
"── Longest haul ──",
"description line 0",
"description line 1",
]
# A block never takes more than half the viewport. The rows go from the end, so a short
# terminal gives up the heading last.
screen.note_metrics(total=20, viewport=4)
assert plain(4) == ["── Longest haul ──", "description line 0"]
screen.note_metrics(total=20, viewport=2)
assert plain(4) == ["── Longest haul ──"]
def test_select_pins_the_heading_not_the_prose_beneath_it() -> None:
"""The pinned rows always start with the heading, never with the last muted line under it."""
screen = _trophy_shaped()
assert _top_plain(screen, viewport=6) == "── Longest haul ──"
# Also, the note for an empty state cannot take the place of its heading.
assert _top_plain(_reselect(screen, ("a", 4)), viewport=6) == "── Widest arc ──"
def _reselect(screen: SelectScreen, value: object) -> SelectScreen:
"""Move the highlight of a select screen to ``value``. The helper walks Down to it."""
while screen._choices()[screen._index].value != value:
screen.handle("down")
return screen
def test_select_pinned_heading_keeps_the_last_row_reachable() -> None:
"""Also with a pinned heading, the last choice stays fully visible and is not clipped."""
screen = _grouped_menu()
screen.handle("end") # Highlight the last choice.
lines = screen.render_body(40)
visible, _above, below = frame._visible_slice(screen, lines, 6)
assert Text.from_ansi(visible[0]).plain.strip() == "── Direct ──" # The heading is pinned.
assert any("peer7" in Text.from_ansi(row).plain for row in visible) # The last row shows.
assert below is False # Also, the user can see that this is the bottom.
def _columned_menu(default: object = None) -> SelectScreen:
"""A grouped menu that starts with a pinned column header, as the config editor does."""
items: list = [Separator(" SETTING VALUE", pinned=True)]
items += [section_heading("Channels")]
items += [Choice(f"chan{i}", ("c", i)) for i in range(6)]
items += [section_heading("Direct")]
items += [Choice(f"peer{i}", ("d", i)) for i in range(8)]
return SelectScreen("pick", items, default=default)
def test_select_pins_a_column_header_above_the_section_heading() -> None:
"""A pinned column header stays for the whole list, and the heading that governs is under it."""
screen = _columned_menu(default=("d", 6)) # This is deep in the second section.
lines = screen.render_body(40)
visible, above, _below = frame._visible_slice(screen, lines, 7)
assert [Text.from_ansi(row).plain.strip() for row in visible[:2]] == [
"SETTING VALUE", # The lanes, which are pinned for each section.
"── Direct ──", # This is over the section that has the highlight.
]
assert above is True
assert any("peer6" in Text.from_ansi(row).plain for row in visible) # The highlight shows.
# The pinned header is not a section landmark. Only the two headings are landmarks.
assert _blocks(screen) == [["── Channels ──"], ["── Direct ──"]]
def test_select_column_header_shows_itself_at_the_top_and_pins_alone() -> None:
"""At the top, the header only draws. Past the top, it pins before any heading scrolls off."""
screen = _columned_menu() # The highlight is on the first choice, so the list is at the top.
lines = screen.render_body(40)
visible, above, _below = frame._visible_slice(screen, lines, 8)
assert Text.from_ansi(visible[0]).plain.strip() == "SETTING VALUE"
assert above is False # Nothing is pinned over the real row, and nothing is above it.
# After a scroll of one row, the header pins while the heading of its own section is
# still the top content row. Thus the header is the only pin.
assert screen.sticky_rows(1) == [screen._pinned_header[1]]
def test_select_pinned_column_header_keeps_the_last_row_reachable() -> None:
"""Two pinned rows still leave the last choice fully visible, with no clip."""
screen = _columned_menu()
screen.handle("end") # Highlight the last choice.
lines = screen.render_body(40)
visible, _above, below = frame._visible_slice(screen, lines, 7)
assert Text.from_ansi(visible[0]).plain.strip() == "SETTING VALUE"
assert any("peer7" in Text.from_ansi(row).plain for row in visible)
assert below is False
def test_select_walking_up_from_the_bottom_keeps_the_highlight_in_view() -> None:
"""↑ from the last row to the first never leaves the highlight outside the window.
At the bottom, the window slides down under its pinned rows. Thus the pinned rows use
old lines at the top, and not the last lines. When the user walks back up, the
highlight reaches the top line of the scroll window before the scroll must move. A
slide past that line hid the row that the user had just moved to. It hid the row for
one step with a pinned column header, and for two steps with a section heading under it.
"""
for viewport in (5, 6, 7, 8):
screen = _columned_menu()
screen.handle("end")
for _ in range(len(screen._choices())):
visible, _above, _below = frame._visible_slice(screen, screen.render_body(40), viewport)
rows = [Text.from_ansi(row).plain for row in visible]
current = screen._current_choice().label
assert any(f"❯ {current}" in row for row in rows), (viewport, current, rows)
screen.handle("up")
def test_select_resolves_a_width_aware_separator_at_the_render_width() -> None:
"""A callable separator title gets the render width, so a header can fit itself."""
screen = SelectScreen(
"pick", [Separator(lambda w: f"HEADER@{w}", pinned=True), Choice("row", 1)]
)
assert Text.from_ansi(screen.render_body(30)[0]).plain.strip() == "HEADER@30"
assert Text.from_ansi(screen.render_body(48)[0]).plain.strip() == "HEADER@48"
# The measure of the natural width asks for the fullest form, not a form that has the
# size of the terminal. Thus the box has the size of the whole header, and only the
# terminal can force the header to abbreviate.
natural = SelectScreen(
"pick",
[Separator(lambda w: "H" * min(w, 120), pinned=True), Choice("row", 1)],
footer_hint="Esc back",
)
assert natural.dialog_width >= 120
def test_select_pinned_header_crops_where_a_plain_separator_wraps() -> None:
"""A pinned row must stay one row. If it is too wide, it ends with an ellipsis, not a wrap."""
wide = "SETTING" + " " * 40 + "DESCRIPTION"
screen = SelectScreen("pick", [Separator(wide, pinned=True), Choice("row", 1)])
lines = screen.render_body(24)
assert screen._pinned_header == (0, lines[0])
assert Text.from_ansi(lines[0]).plain.rstrip().endswith("…")
assert len(lines) == 2 # The header and the one choice. Nothing wrapped onto a new row.
# An ordinary separator still wraps. Each row of it is a body line.
plain = SelectScreen("pick", [Separator(wide), Choice("row", 1)])
assert len(plain.render_body(24)) == 3
def test_screen_sticky_rows_stack_the_pinned_header_over_the_section_heading() -> None:
"""The shared rule that composes both pins: the header of the whole list, then the section."""
screen = Screen()
screen.note_metrics(total=40, viewport=12)
screen._pinned_header = (0, "COLUMNS")
screen._sticky_headers = [(1, ["A"]), (5, ["B"])]
assert screen.sticky_rows(0) == [] # Nothing has scrolled off.
assert screen.sticky_rows(1) == ["COLUMNS"] # Heading A is the top row.
assert screen.sticky_rows(3) == ["COLUMNS", "A"] # This is inside section A.
assert screen.sticky_rows(9) == ["COLUMNS", "B"] # Below each heading, the last one pins.
assert Screen().sticky_rows(9) == [] # The screen recorded nothing, so nothing is pinned.
def test_screen_sticky_block_picks_the_governing_recorded_block() -> None:
"""The logic of the base Screen.sticky_block works for any list of recorded landmarks.
The select list and the chat transcript both use it. They fill ``_sticky_headers``.
This test examines the shared rule directly. The rule takes the last block that starts
at or above the offset. It pins exactly the rows of that block that the offset passed.
"""
screen = Screen()
screen.note_metrics(total=40, viewport=12)
screen._sticky_headers = [(0, ["A"]), (5, ["B", "b"]), (12, ["C"])]
assert screen.sticky_block(0) == [] # Block A is the top row.
assert screen.sticky_block(3) == ["A"] # The scroll is past A and before B, so A governs.
assert screen.sticky_block(5) == [] # The heading of block B is now the top row.
assert screen.sticky_block(6) == ["B"] # Its second row is still on the screen.
assert screen.sticky_block(7) == ["B", "b"] # Both rows are gone, so both pin.
assert screen.sticky_block(20) == ["C"] # Below each block, the last block pins.
assert Screen().sticky_block(9) == [] # There are no recorded landmarks, so nothing pins.
def test_select_ctrl_page_jumps_between_sections() -> None:
"""Ctrl+PageDown goes to the first choice of the next section. Ctrl+PageUp goes back up."""
screen = _grouped_menu() # Channels (6) then Direct (8). The highlight is on the first choice.
screen.handle("ctrl_pagedown")
assert _run(screen, "enter") == ("d", 0) # The highlight jumped to the first Direct choice.
screen.handle("ctrl_pageup")
# The highlight was at the top of Direct, so it went back to the first choice of Channels.
assert _run(screen, "enter") == ("c", 0)
screen.handle("ctrl_pagedown")
assert _run(screen, "enter") == ("d", 0) # Then it went forward to Direct again.
# --- scroll ------------------------------------------------------------------
def test_screen_scroll_helpers_page_and_clamp_to_metrics() -> None:
"""The shared scroll helpers move one screenful for a page, and clamp to the body."""
screen = Screen()
screen.note_metrics(total=100, viewport=10)
screen.scroll_pages(1)
assert screen.scroll == 9 # A page is viewport - 1.
screen.scroll_to_bottom()
assert screen.scroll == 90 # This is total - viewport.
screen.scroll_lines(50)
assert screen.scroll == 90 # The scroll clamps and never goes past the bottom.
screen.scroll_to_top()
assert screen.scroll == 0
def test_screen_section_scroll_walks_recorded_headers() -> None:
"""Ctrl+PageUp and Ctrl+PageDown move the scroll offset between the recorded section limits."""
screen = Screen()
screen.note_metrics(total=100, viewport=10)
screen._sticky_headers = [(0, ["A"]), (20, ["B"]), (60, ["C"])]
screen.scroll_to_next_section()
assert screen.scroll == 20 # From the top to the start of section B.
screen.scroll_to_next_section()
assert screen.scroll == 60 # To the start of C.
screen.scroll_to_next_section()
assert screen.scroll == 90 # There is no section after C, so the scroll clamps to the bottom.
screen.scroll = 40 # This is in the middle of section B.
screen.scroll_to_section_start()
assert screen.scroll == 20 # Up to the start of B.
screen.scroll_to_section_start()
assert screen.scroll == 0 # It was at the top of B, so it goes to the section before (A).
def test_scroll_screen_ctrl_edges_and_sectionless_fallback() -> None:
"""Ctrl+Home and Ctrl+End reach the edges. With no sections, Ctrl+PageUp/PageDown do too."""
body = Text("\n".join(f"line {i}" for i in range(100)))
screen = ScrollScreen(body, title="log")
screen.render_body(40) # This sets total = 100.
screen.note_viewport(10)
screen.handle("ctrl_end")
assert screen.scroll == 90
screen.handle("ctrl_home")
assert screen.scroll == 0
screen.handle("ctrl_pagedown") # The screen has no sections, so it goes to the bottom.
assert screen.scroll == 90
screen.handle("ctrl_pageup")
assert screen.scroll == 0
# --- scroll (existing) -------------------------------------------------------
def test_scroll_screen_paging_and_clamp() -> None:
"""PageDown moves one page and End jumps to the bottom. Both clamp to the content."""
body = Text("\n".join(f"line {i}" for i in range(100)))
screen = ScrollScreen(body, title="log")
screen.render_body(40) # This sets total = 100.
screen.note_viewport(10)
screen.handle("pagedown")
assert screen.scroll == 9 # A page is viewport - 1.
screen.handle("end")
assert screen.scroll == 90 # This is total - viewport.
screen.handle("home")
assert screen.scroll == 0
screen.handle("up")
assert screen.scroll == 0 # The scroll clamps and is never negative.
def test_scroll_screen_escape_resolves_none() -> None:
"""A result dialog closes with ``None`` when the user presses Esc or Enter."""
assert _run(ScrollScreen(Text("x")), "escape") is None
assert _run(ScrollScreen(Text("x")), "enter") is None
# --- reorder -----------------------------------------------------------------
def test_reorder_apply_row_commits_new_order() -> None:
"""Enter grabs and drops a list row. Enter on Apply commits the new order."""
screen = ReorderScreen("order", ["a", "b", "c"])
screen.handle("enter") # Grab "a".
assert screen._grabbed and "Enter drop" in screen.footer_hint
screen.handle("down") # Carry it past "b".
screen.handle("enter") # Drop it.
assert not screen._grabbed and "Enter grab" in screen.footer_hint
screen.handle("down") # The highlight moves from position 1 past "c"...
screen.handle("down") # ...onto the Apply row.
assert _run(screen, "enter") == [1, 0, 2]
def test_reorder_actions_follow_the_dirty_state() -> None:
"""An order that the user did not change has no action row. A change adds Apply and Back.
Esc leaves in each case. Thus a list that the user did not change shows no row for it.
The pair appears only when there is something to apply, and Apply has no key of its own.
"""
screen = ReorderScreen("order", ["a", "b", "c"])
assert screen._actions() == []
screen.handle("enter")
screen.handle("down") # The order is now changed.
assert [key for key, _ in screen._actions()] == ["apply", "back"]
screen.handle("up") # The row is back at its place, so the order is clean again.
assert screen._actions() == []
def test_reorder_back_row_and_escape_cancel_discarding_moves() -> None:
"""Enter on Back resolves the sentinel, as Esc does, so the caller keeps the old order."""
screen = ReorderScreen("order", ["a", "b", "c"])
screen.handle("enter")
screen.handle("down")
assert _run(screen, "escape") is CANCEL
screen = ReorderScreen("order", ["a", "b", "c"])
screen.handle("enter")
screen.handle("down")
screen.handle("enter") # Drop it at position 1. The order is now changed.
for _ in range(3): # The highlight goes from 1 to 2, then to Apply, then to Back.
screen.handle("down")
assert _run(screen, "enter") is CANCEL
def test_reorder_cursor_runs_into_the_action_rows_and_clamps() -> None:
"""↑ on the first row stays. ↓ walks the list and then the action rows, and then stops.
If the order is clean, the list is the whole space of the highlight. If the order is
changed, the two action rows join the space. The highlight goes through them to the
bottom, and it never goes round to the top.
"""
screen = ReorderScreen("order", ["a", "b"])
screen.handle("up") # The highlight is at the top, and there is no last row to go to.
assert screen._index == 0
screen.handle("down") # It goes to the last list row, because there are no action rows.
assert screen._index == 1
screen.handle("down") # It stops there.
assert screen._index == 1
screen.handle("enter") # Grab row 1...
# ...and carry it up. The order is changed, so Apply and Back join the space.
screen.handle("up")
screen.handle("enter") # Drop it. The highlight moved with it to the first list row.
screen.handle("down") # To the second list row.
screen.handle("down") # Off the list, onto Apply.
screen.handle("down") # ...then to the Back row that discards the changes, below it.
assert screen._index == 3
screen.handle("down") # This is the bottom of the space, so the highlight stays.
assert screen._index == 3
def test_reorder_ignores_typed_characters_including_space() -> None:
"""Typed characters, also the space bar, do not grab a row and do not resolve the screen."""
screen = ReorderScreen("order", ["a", "b"])
screen.future = _Fut()
screen.handle("text", "x")
screen.handle("text", " ") # Space does not grab now. Enter is the key that grabs.
screen.handle("space")
assert not screen._grabbed
assert not screen.future.done()
def test_reorder_dialog_width_is_stable_across_states() -> None:
"""The reorder dialog has one width in each state that it can have.
The natural width fits the widest of the rows, the hints, and the actions for a changed
order. It does not change when the user grabs a row or changes the order. Thus the
dialog does not change its size.
"""
screen = ReorderScreen("order", ["🔒 alpha", "# b"])
w = screen.dialog_width
screen.handle("enter") # Grab.
assert screen.dialog_width == w
screen.handle("down") # The order is changed, so the Apply and discard rows appear.
assert screen.dialog_width == w
# --- frame -------------------------------------------------------------------
def test_compose_base_fills_exactly_terminal_height() -> None:
"""The composed base frame has exactly ``rows`` lines, for any size of the content."""
tall = Text("\n".join(f"row {i}" for i in range(200)))
screen = ScrollScreen(tall, title="big")
for rows in (10, 24, 50):
out = frame.compose_base(Text("header"), screen, "Esc back", 80, rows)
assert out.count("\n") + 1 == rows
def test_a_flush_screen_draws_up_to_the_side_borders() -> None:
"""A ``flush`` base gets the two padding columns back. An ordinary base keeps its padding."""
class Fill(Screen):
floating = False
def render_body(self, width: int) -> list[str]:
return ["#" * width] * 3
for flush, row in ((True, "│" + "#" * 78 + "│"), (False, "│ " + "#" * 76 + " │")):
screen = Fill()
screen.flush = flush
lines = [_plain(ln) for ln in frame.compose_base(Text("h"), screen, "", 80, 12).split("\n")]
assert row in lines, (flush, lines)
def test_compose_dialog_is_bounded() -> None:
"""A dialog for a very large renderable never goes over the terminal height."""
tall = Text("\n".join(f"row {i}" for i in range(200)))
out = frame.compose_dialog(ScrollScreen(tall, title="d"), 80, 20)
assert out.count("\n") + 1 <= 20
def _box_height(screen: Screen) -> int:
"""The height in rows of the dialog box that ``compose_dialog`` draws for ``screen``."""
return frame.compose_dialog(screen, 80, 40).count("\n") + 1
def test_ordinary_dialog_box_resizes_to_each_body() -> None:
"""An ordinary dialog (not grow-only) has the size of the body that it shows now."""
short = _box_height(ScrollScreen(Text("one line"), title="d"))
tall = _box_height(ScrollScreen(Text("\n".join(f"row {i}" for i in range(15))), title="d"))
assert tall > short
class _GrowScreen(Screen):
"""A grow-only dialog whose body height is set for each paint, for the ratchet test."""
grow_only = True
def __init__(self) -> None:
super().__init__()
self.title = "d"
self.footer_hint = ""
self.n = 1
def render_body(self, width: int) -> list[str]:
return [f"row {i}" for i in range(self.n)]
def test_grow_only_dialog_box_holds_its_tallest_size() -> None:
"""A grow-only dialog makes its box larger for a taller body, and never makes it smaller."""
screen = _GrowScreen()
screen.n = 2
small = _box_height(screen)
screen.n = 16
grown = _box_height(screen)
assert grown > small # A taller body makes the box larger.
screen.n = 2
assert _box_height(screen) == grown # A shorter body after that keeps the larger box.
def _box_width(screen: Screen) -> int:
"""The width in cells of the dialog box that ``compose_dialog`` draws for ``screen``."""
out = Text.from_ansi(frame.compose_dialog(screen, 80, 40)).plain
return max(cell_len(line.rstrip()) for line in out.split("\n"))
def test_grow_only_dialog_box_holds_its_widest_size() -> None:
"""The natural width of a grow-only dialog also ratchets. It becomes wider, never narrower."""
screen = _GrowScreen()
screen.dialog_width = 30
narrow = _box_width(screen)
screen.dialog_width = 60
wide = _box_width(screen)
assert wide > narrow # A wider body makes the box larger.
screen.dialog_width = 30
assert _box_width(screen) == wide # A narrower body after that keeps the wider box.
# An ordinary dialog keeps the size of each width that it gets.
plain = ScrollScreen(Text("x"), title="d")
plain.dialog_width = 60
wide = _box_width(plain)
plain.dialog_width = 30
assert _box_width(plain) < wide
def test_compose_startup_is_chromeless_and_shows_banner() -> None:
"""The startup splash fills the height, draws the banner, and has no header or footer bar."""
screen = SelectScreen("pick", [Choice("alpha", 1), Choice("beta", 2)])
screen.chrome = False
screen.banner = ["LOGO-ROW-A", "LOGO-ROW-B"]
out = frame.compose_startup(screen, 80, 24)
assert out.count("\n") + 1 == 24 # The splash fills the terminal height exactly.
plain = Text.from_ansi(out).plain
assert "LOGO-ROW-A" in plain and "LOGO-ROW-B" in plain # The banner is drawn.
assert "pick" in plain # The box keeps its title.
# The box has the size of its content and not the full width. No line that MeshTerm
# renders spans the whole terminal.
assert all(len(line.rstrip()) < 80 for line in plain.split("\n"))
def test_compose_bare_is_the_body_alone_on_blank_rows() -> None:
"""A bare frame has no header, footer, title, or box. It has the body, a little above centre."""
body = Group(Text("CODE", justify="center"), Text("https://x", justify="center"))
screen = ScrollScreen(body, title="Share x", floating=False)
screen.bare = True
out = frame.compose_bare(screen, 53, 26)
rows = out.split("\n")
assert len(rows) == 26 # The frame fills the terminal height exactly.
plain = [Text.from_ansi(row).plain for row in rows]
assert not any("Share x" in row or "Esc" in row for row in plain) # No title and no hint.
assert not any("─" in row or "│" in row or "╭" in row for row in plain) # No box.
filled = [i for i, row in enumerate(plain) if row.strip()]
assert filled == [9, 10] # Two body rows, at 2/5 of the blank space.
assert plain[9].rstrip() == " " * 24 + "CODE" # The text is centred across the whole width.
assert screen._scroll_viewport == 26 # The frame states the height before it asks for the body.
def test_compose_bare_windows_a_body_taller_than_the_frame_from_the_top() -> None:
"""A body that is too tall for the frame scrolls. The top (the code) is whole first."""
body = Group(*(Text(f"row {i}") for i in range(40)))
screen = ScrollScreen(body, floating=False)
screen.bare = True
rows = Text.from_ansi(frame.compose_bare(screen, 53, 26)).plain.split("\n")
assert len(rows) == 26
assert rows[0].strip() == "row 0" and rows[25].strip() == "row 25"
screen.scroll_to_bottom()
rows = Text.from_ansi(frame.compose_bare(screen, 53, 26)).plain.split("\n")
assert rows[25].strip() == "row 39"
def test_startup_splash_gives_rows_back_in_order_when_short() -> None:
"""A short terminal gives up the blank line first, then the top of the mark, and never the box.
The lettering says what the app is. The box is what the user came to use. The globe
over the lettering is decoration, so it is what the splash gives up. The crop is from
the top. Thus the lettering keeps its place while the mark becomes thinner above it.
"""
marks = [f"MARK{i:02d}" for i in range(16)] # A stand-in that has the height of the real mark.
def splash(rows: int) -> list[str]:
screen = SelectScreen("pick", [Choice("alpha", 1), Choice("beta", 2)])
screen.chrome = False
screen.banner = list(marks)
return Text.from_ansi(frame.compose_startup(screen, 80, rows)).plain.split("\n")
tall = splash(40)
# There is room: each row of the mark shows, with a blank line between it and the box.
assert all(m in "\n".join(tall) for m in marks)
mark_end = max(i for i, line in enumerate(tall) if marks[-1] in line)
assert tall[mark_end + 1].strip() == ""
# Make the terminal shorter by one row at a time, and note when the splash gives up
# each part. The exact heights depend on the height of the box below, so the test
# checks only the order.
gap_lost = cropped = None
for rows in range(40, 8, -1):
lines = splash(rows)
end = max((i for i, ln in enumerate(lines) if marks[-1] in ln), default=None)
has_gap = end is not None and end + 1 < len(lines) and lines[end + 1].strip() == ""
if gap_lost is None and not has_gap:
gap_lost = rows
if cropped is None and marks[0] not in "\n".join(lines):
cropped = rows
if end is not None: # While the splash draws any part of the mark, it draws the last row.
assert marks[-1] in "\n".join(lines), f"{rows} rows: lost the mark's last row"
assert gap_lost is not None, "the blank line was never given back"
assert cropped is not None, "the mark was never cropped"
assert gap_lost > cropped, "the blank line must go before the mark is cropped"
# The crop has a limit. The first row after _BANNER_CROP_ROWS is never sheared, for any
# height of the terminal. (Below a certain height, the block is taller than the screen,
# and the clip at the end takes the bottom. This is a different mechanism, not the crop.)
keeper = marks[frame._BANNER_CROP_ROWS]
for rows in range(20, 5, -1):
assert keeper in "\n".join(splash(rows)), f"{rows} rows: cropped past the bound"
def test_startup_splash_fits_every_platform_width() -> None:
"""The real wordmark, drawn at the width of each platform, never goes over that width.
The full-size mark is 71 cells, and a PicoCalc console is 53 cells. The splash is the
one screen that the gallery does not cover. It shipped torn on the PicoCalc until the
narrow mark was added. Thus this test is the gate.
"""
from meshterm.platforms import PICOCALC_LYRA, REGULAR, set_platform
from meshterm.ui.logo import load_logo
try:
for platform in (REGULAR, PICOCALC_LYRA):
set_platform(platform)
cols = platform.readable_cols
screen = SelectScreen("Choose a device", [Choice("alpha", 1)])
screen.chrome = False
screen.banner = load_logo() # A real caller sets this: the full-size mark.
screen.footnote = copyright_notice()
out = frame.compose_startup(screen, cols, platform.readable_rows)
for i, line in enumerate(Text.from_ansi(out).plain.split("\n")):
assert cell_len(line) <= cols, (
f"{platform.name} splash line {i} is {cell_len(line)} cells, over {cols}"
)
finally:
set_platform(REGULAR)
def test_logo_takes_the_widest_mark_that_fits_the_columns() -> None:
"""The screen picks the size, not the platform. A narrow desktop gets the small mark."""
from meshterm.ui.logo import load_logo, logo_width
wide = logo_width(load_logo())
narrow = logo_width(load_logo(53))
assert 0 < narrow <= 53 < wide
# If there is room for the big mark, the screen picks it.
assert logo_width(load_logo(wide)) == wide
# If the room is one column short, the screen picks the small mark.
assert logo_width(load_logo(wide - 1)) == narrow
assert load_logo(narrow - 1) == [] # nothing fits: no banner beats a torn one
def test_compose_startup_shows_footnote_under_logo() -> None:
"""The startup footnote is directly under the logo.
MeshTerm draws a footnote (for example a copyright) muted, and aligned to the right
edge of the logo. Thus the logo and the footnote read as one signed block.
"""
screen = SelectScreen("pick", [Choice("a", 1)])
screen.chrome = False
screen.banner = ["A" * 40, "B" * 40] # A wide wordmark, to align the note with.
screen.footnote = "note-xyz"
lines = Text.from_ansi(frame.compose_startup(screen, 80, 20)).plain.split("\n")
logo_rows = [i for i, ln in enumerate(lines) if set(ln.strip()) in ({"A"}, {"B"})]
note_row = next(i for i, ln in enumerate(lines) if "note-xyz" in ln)
assert lines[note_row].strip() == "note-xyz" # The note has its own line.
assert note_row == logo_rows[-1] + 1 # The note is directly under the logo, with no gap.
# The right edges align: the note ends at the same column as the logo.
assert len(lines[note_row].rstrip()) == len(lines[logo_rows[-1]].rstrip())
def test_compose_startup_box_is_horizontally_centered() -> None:
"""The box has the size of its content and is centred, so its rows have a left margin."""
screen = SelectScreen("pick", [Choice("a", 1)])
screen.chrome = False
lines = Text.from_ansi(frame.compose_startup(screen, 80, 20)).plain.split("\n")
box_lines = [ln for ln in lines if ln.strip()]
assert box_lines and all(ln.startswith(" ") for ln in box_lines) # The box is centred.
# --- frames ------------------------------------------------------------------
def _cell_color(line: str, idx: int) -> tuple[int, int, int]:
"""Find the foreground RGB of the character at ``idx`` in an ANSI line."""
from meshterm.ui.tui.render import _console
style = Text.from_ansi(line).get_style_at_offset(_console(80), idx)
assert style.color is not None
triplet = style.color.get_truecolor()
return (triplet.red, triplet.green, triplet.blue)
_BORDER = (143, 143, 143) # The border grey of the theme, #8f8f8f.
def _find(plain: str, glyphs: frozenset[str]) -> int:
"""The index of the first box glyph from ``glyphs``.
On legacy Windows, the render console can use square corners in place of rounded
corners. Thus a test matches the whole family and not one character.
"""
return next(i for i, ch in enumerate(plain) if ch in glyphs)
_CORNERS = frozenset("╭┌╮┐╰└╯┘")
_EDGES = frozenset("─│")
def _border_colours(lines: list[str]) -> set[tuple[int, int, int]]:
"""Each different colour in which MeshTerm paints the box-drawing glyphs in ``lines``."""
return {
_cell_color(line, i)
for line in lines
for i, ch in enumerate(Text.from_ansi(line).plain)
if ch in _CORNERS or ch in _EDGES
}
def test_a_dialog_leaves_its_hint_to_the_footer_line() -> None:
"""On the desktop, the box says nothing about keys, because the footer row does.
The frame draws the hint of the top screen on its footer line. A dialog that also had
the hint in its own border put the same sentence on one frame two times (JP,
2026-08-31). Now the border has no hint, and the footer is the only place for hints.
"""
hint = "←→ choose · Enter select · Esc cancel"
screen = ScrollScreen(Text("Delete this contact?"), title="Delete contact")
screen._footer_hint = hint
box = _plain(frame.compose_dialog(screen, 72, 20))
assert "Esc cancel" not in box and "Enter select" not in box
base = ScrollScreen(Text("row"), title="Contacts", floating=False)
footer = _plain(frame.compose_base(Text("h"), base, hint, 72, 20).split("\n")[-1])
assert footer.strip() == hint
def test_a_silent_dialog_border_still_says_there_is_more() -> None:
"""If the border has no hint, the clip arrows keep the ``more`` words of the base frame."""
tall = ScrollScreen(Text("\n".join(f"line {i}" for i in range(40))), title="Packet")
tall._footer_hint = "↑↓ newer/older · Esc close"
assert "↓ more" in _plain(frame.compose_dialog(tall, 72, 16))
def test_a_frame_draws_in_one_colour() -> None:
"""A border has one colour all the way round, with no lit corner and no fading edge.
For a time, the frames had light from the top left. The corner blended toward white,
and the light decayed along the top edge and the left edge. It looked like a gradient
over the chrome, and not like a box. Thus we removed the pass, and the border draws
flat. The test checks the nested panels with the outer frame, because the pass also
lit those.
"""
inner = Panel(Text("body"), border_style="border", width=20)
screen = ScrollScreen(Group(Text("above"), inner), title="outer")
lines = frame.compose_base(Text("h"), screen, "hint", 60, 20).split("\n")
assert _border_colours(lines) == {_BORDER}, "the base frame draws in more than one colour"
dialog = frame.compose_dialog(ScrollScreen(Text("body"), title="d"), 80, 20)
assert _border_colours(dialog.split("\n")) == {_BORDER}, "so does a dialog's"
# --- prompts -----------------------------------------------------------------
def test_text_screen_edits_and_validates() -> None:
"""Typing edits the buffer. A validator that fails blocks the submit and shows the error."""
screen = TextScreen("name?", validate=lambda v: True if v == "ok" else "nope")
for ch in "xy":
screen.handle("text", ch)
screen.future = _Fut()
screen.handle("enter") # The text 'xy' fails the validation.
assert not screen.future.done()
assert screen._error == "nope"
screen.handle("backspace")
screen.handle("backspace")
for ch in "ok":
screen.handle("text", ch)
assert _run(screen, "enter") == "ok"
def test_text_screen_byte_limit_gauges_and_blocks_an_oversize_entry() -> None:
"""A field with a byte limit shows the used/limit gauge, and blocks Enter over the cap."""
import re
screen = TextScreen("msg?", byte_limit=10)
for ch in "hello":
screen.handle("text", ch)
body = re.sub(r"\x1b\[[0-9;]*m", "", "\n".join(screen.render_body(40)))
assert "5/10" in body # The gauge shows used/limit.
for ch in " world": # The total is 11 bytes, which is over the cap of 10 bytes.
screen.handle("text", ch)
screen.future = _Fut()
screen.handle("enter")
assert not screen.future.done() # The screen blocks the entry that is over the limit.
assert "Too long by 1 byte" in screen._error
# If the user trims the text to the limit, the screen lets it submit.
for _ in range(2):
screen.handle("backspace")
assert _run(screen, "enter") == "hello wor"
def test_text_screen_compose_field_has_the_colours_of_the_chat_compose_line() -> None:
"""A message field draws its ``›`` in the cursor white and its text in the compose white.
These are the colours of the compose line of a chat. A plain field keeps the accent
``›`` and the default colour of the text.
"""
from meshterm.ui.tui.prompt import LineEditor
editor = LineEditor("hi")
line = editor.render(compose=True)
assert not str(line.style) # a base style also colours the typed text
prompt = {str(span.style) for span in line.spans if span.start == 0}
typed = {str(span.style) for span in line.spans if (span.start, span.end) == (2, 3)}
assert (prompt, typed) == ({"cursor"}, {"compose"})
plain = editor.render()
assert str(plain.style) == "accent"
assert "compose" not in {str(span.style) for span in plain.spans}
screen = TextScreen("Message for Hub", byte_limit=10, compose=True)
assert screen._compose is True
def test_line_editor_word_motion() -> None:
"""Ctrl+Left and Ctrl+Right move by word.
They go to the start of the current word. If the cursor is already there, they go to
the previous word or the next word.
"""
from meshterm.ui.tui.prompt import LineEditor
editor = LineEditor("the quick brown fox") # The cursor is at the end (len 20).
editor.edit("ctrl_left")
assert editor.cursor == 17 # The start of "fox".
editor.edit("ctrl_left")
assert editor.cursor == 11 # It skips the double space, to the start of "brown".
editor.edit("ctrl_left")
assert editor.cursor == 4 # The start of "quick".
editor.edit("ctrl_right")
assert editor.cursor == 11 # Forward over "quick" and the spaces, to the start of "brown".
editor.cursor = 13 # The middle of "brown".
editor.edit("ctrl_left")
assert editor.cursor == 11 # To the start of the current word, not the previous word.
def test_text_screen_password_masks() -> None:
"""A password field renders bullets and not the characters that the user typed."""
screen = TextScreen("pw?", password=True)
for ch in "secret":
screen.handle("text", ch)
rendered = "\n".join(screen.render_body(40))
assert "secret" not in rendered
assert "•" in rendered
def test_line_editor_caps_length_and_truncates_paste() -> None:
"""An editor with a max_length ignores keys past the cap, and cuts a paste that is too long."""
from meshterm.ui.tui.prompt import LineEditor
editor = LineEditor("", max_length=6)
for ch in "123456":
assert editor.edit("text", ch) is True
# The editor is full. It ignores the key and keeps the buffer.
assert editor.edit("text", "9") is False
assert editor.text == "123456"
pasted = LineEditor("", max_length=6)
pasted.edit("text", "12345678") # One insert that is too long.
assert pasted.text == "123456" # The editor filled only the six slots that were free.
def test_line_editor_paste_folds_controls_and_respects_max_length() -> None:
"""A ``paste`` action changes newlines and controls to spaces, and inserts the text."""
from meshterm.ui.tui.prompt import LineEditor
editor = LineEditor("ab")
editor.cursor = 1
assert editor.edit("paste", "X\nY") is True
# The newline became a space, and it went in the middle of the buffer.
assert editor.text == "aX Yb"
capped = LineEditor("", max_length=3)
capped.edit("paste", "hello")
# The editor cuts a paste to the room that is left, as for a big insert.
assert capped.text == "hel"
# A paste of nothing does not change the buffer.
assert LineEditor("z").edit("paste", "") is False
def test_pin_dialog_shows_six_slots_with_dots_for_blanks() -> None:
"""The PIN field has six fixed slots: bullets for typed digits and centre dots for blanks."""
from meshterm.ui.tui.prompt import PinDialog
dialog = PinDialog("MeshCore-Testbench")
# Empty: six blank centre dots and no bullets.
field = dialog._editor.render(slots=PinDialog.PIN_LENGTH).plain
assert field.count("·") == 6
assert "•" not in field
# After three digits: three bullets and three centre dots.
for ch in "123":
dialog.handle("text", ch)
field = dialog._editor.render(slots=PinDialog.PIN_LENGTH).plain
assert field.count("•") == 3
assert field.count("·") == 3
# A PIN of six digits fills each slot. The editor caps more typing at six.
for ch in "456999":
dialog.handle("text", ch)
assert dialog._editor.text == "123456"
field = dialog._editor.render(slots=PinDialog.PIN_LENGTH).plain
assert field.count("•") == 6
assert "·" not in field
def test_confirm_toggle_and_default() -> None:
"""The confirm toggles with the arrows or letters, and returns the bool that the user chose."""
screen = ConfirmScreen("sure?", default=True)
assert _run(screen, "enter") is True
screen = ConfirmScreen("sure?", default=True)
screen.handle("left") # Toggle to No.
assert _run(screen, "enter") is False
screen = ConfirmScreen("sure?", default=True)
screen.handle("text", "n")
assert _run(screen, "enter") is False
def test_button_dialog_enter_commits_highlighted() -> None:
"""Enter returns the value of the highlighted button. The default sets the highlight."""
screen = ButtonDialog("quit?", [("Yes", True), ("No", False)], default=0)
assert _run(screen, "enter") is True
screen = ButtonDialog("quit?", [("Yes", True), ("No", False)], default=1)
assert _run(screen, "enter") is False
def test_button_dialog_arrows_move_highlight() -> None:
"""←/→ move the highlight between the buttons and clamp at the ends. Tab still cycles.
Tab is the exception. It has no reverse key of its own, so on the last chip it goes
round to the first chip and does not stop.
"""
screen = ButtonDialog("quit?", [("Yes", True), ("No", False)], default=0)
screen.handle("right") # To No.
assert _run(screen, "enter") is False
screen = ButtonDialog("quit?", [("Yes", True), ("No", False)], default=0)
screen.handle("left") # The highlight is already at the left end, so it stays on Yes.
assert _run(screen, "enter") is True
screen = ButtonDialog("quit?", [("Yes", True), ("No", False)], default=1)
screen.handle("right") # The highlight is already at the right end, so it stays on No.
assert _run(screen, "enter") is False
screen = ButtonDialog("quit?", [("Yes", True), ("No", False)], default=1)
screen.handle("tab") # From No, round to Yes.
assert _run(screen, "enter") is True
def test_button_dialog_shortcut_keys_commit_instantly() -> None:
"""A shortcut key that is in the map commits its value at once. It ignores the highlight."""
screen = ButtonDialog(
"quit?", [("Yes", True), ("No", False)], default=1, keys={"y": True, "n": False}
)
assert _run(screen, "text", "Y") is True # The match ignores case, and ignores the default No.
screen = ButtonDialog(
"quit?", [("Yes", True), ("No", False)], default=0, keys={"y": True, "n": False}
)
assert _run(screen, "text", "n") is False
def test_button_dialog_escape_cancels() -> None:
"""Esc resolves with CANCEL, so the caller can treat it as 'stay'."""
from meshterm.ui.tui.screen import CANCEL
screen = ButtonDialog("quit?", [("Yes", True), ("No", False)])
assert _run(screen, "escape") is CANCEL
def test_button_dialog_renders_a_styled_text_prompt_line_per_line() -> None:
"""A multi-line Text prompt that has styles renders each line, and keeps its content."""
message = Text.from_markup("[ok]✓[/ok] clock set\n[ok]●[/ok] wrote backup.toml")
screen = ButtonDialog(message, [("OK", "ok")])
body = "\n".join(screen.render_body(60))
plain = Text.from_ansi(body).plain
assert "✓ clock set" in plain
assert "● wrote backup.toml" in plain
assert "OK" in plain
def test_button_dialog_sizes_to_the_widest_prompt_line() -> None:
"""dialog_width follows the longest line of a multi-line Text prompt."""
wide = "a really quite long outcome line for sizing"
message = Text(f"short\n{wide}")
screen = ButtonDialog(message, [("OK", "ok")])
assert screen.dialog_width == len(wide) + 12 # This is the margin that the dialog adds.
def test_autocomplete_suggests_and_tab_completes() -> None:
"""Suggestions ignore case. Tab fills the highlighted suggestion, and Enter commits."""
screen = AutocompleteScreen("target?", ["Alice", "Bob", "alfred"])
for ch in "al":
screen.handle("text", ch)
assert screen._suggestions() == ["Alice", "alfred"]
screen.handle("down") # Highlight 'alfred'.
screen.handle("tab") # Fill it.
assert screen._editor.text == "alfred"
assert _run(screen, "enter") == "alfred"
def test_autocomplete_accepts_free_text() -> None:
"""Text that has no matching suggestion still commits as typed (for example a hex prefix)."""
screen = AutocompleteScreen("target?", ["Alice"])
for ch in "3d":
screen.handle("text", ch)
assert _run(screen, "enter") == "3d"
# --- device picker -----------------------------------------------------------
def test_device_picker_builds_aligned_columns(tmp_path) -> None:
"""The picker puts devices in columns that line up across rows of different widths."""
from meshterm.core.device_store import DeviceStore
from meshterm.core.discovery import DiscoveredDevice
from meshterm.ui.device_picker import prompt_device
devices = [
DiscoveredDevice(port="COM5", product="Wio SX1262", vid=0x2886),
DiscoveredDevice(port="/dev/ttyUSB0", product="FT232R USB UART", vid=0x0403),
]
captured: dict = {}
class _Ui:
async def select_startup( # noqa: ANN001, ANN201, ANN003
self, title, items, *, default=None, banner=None, footnote=None, **_kw
):
captured["items"] = items
captured["banner"] = banner
captured["footnote"] = footnote
return None # The user skips, so the flow never reaches the smoke test.
async def _never(_device): # The check is not used when the user skips.
raise AssertionError("verify should not run when selection is skipped")
store = DeviceStore(tmp_path / "devices.json")
asyncio.run(prompt_device(_Ui(), devices, store, _never))
# The picker passes the banner (the wordmark) through, so the splash can draw it.
assert captured["banner"] and any("█" in row for row in captured["banner"])
# There is no footnote. The wordmark has its own copyright, so the splash adds none.
assert captured["footnote"] is None
# The port of each device row is at the same column. This proves that the name column
# has padding.
rows = [
it.label.plain if hasattr(it.label, "plain") else it.label
for it in captured["items"]
if isinstance(it, Choice)
]
# Two devices, then the action rows at the end (add a network device, then Quit).
assert len(rows) == 4
assert rows[-2].strip().endswith("Add a network device…") # No caveat tag follows it.
assert rows[-1].strip().endswith("Quit")
device_rows = rows[:2]
assert all(port in row for port, row in zip(("COM5", "/dev/ttyUSB0"), device_rows, strict=True))
assert device_rows[0].index("COM5") == device_rows[1].index("/dev/ttyUSB0")
def test_device_picker_shortens_nothing_and_puts_the_address_last() -> None:
"""MeshTerm draws each field whole, and the connection target is the last column of the row.
The lanes once had a budget, so that the row fitted the box. Beside a CoreBluetooth
UUID of 36 cells, this cut a name to ``Johnp…`` and cut the UUID to its tail. Now a row
that is wider than the box goes past its edge, and ←→ pan to the rest. The address is
the lane that the user needs least, so it is last. There, the loss at the edge is
smallest. The transport badge starts the row, and it has 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, _display_name
name = "MeshCore-Johnputer Wardriver"
uuid = "12345678-1234-1234-1234-123456789ABC"
adapter = "CP2102 USB to UART Bridge Controller"
devices = [
DiscoveredDevice(transport="ble", address=uuid, name=name, product=name),
DiscoveredDevice("/dev/cu.Bluetooth-Incoming-Port"),
DiscoveredDevice("/dev/ttyUSB0", product=adapter, vid=0x10C4),
]
try:
for platform in (REGULAR, PICOCALC_LYRA):
set_platform(platform)
items = _build_items(devices, None, {})
rows = {
it.value.stable_id: it.title.plain
for it in items
if isinstance(it, Choice) and it.value in devices
}
for device in devices:
row = rows[device.stable_id].rstrip()
assert "…" not in row, platform.name
# The target is whole, and nothing comes after 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 complete at each width that the row needs. ADDRESS is last, and
# the badge column over which the header starts has no label.
header = items[0].text(1000)
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 # Plus the pointer.
assert row[:name_at].strip(), "the badge comes before the name"
finally:
set_platform(REGULAR)
def test_device_picker_names_and_sorts_known_devices(tmp_path) -> None:
"""A confirmed device shows its node name (in white), goes to the top, and marks its type."""
from meshterm.core.device_store import DeviceStore
from meshterm.core.discovery import DiscoveredDevice
from meshterm.ui.device_picker import _BLE_ICON, _SERIAL_ICON, prompt_device
# A serial node that the user confirmed before, an unknown serial port, and a BLE companion.
known = DiscoveredDevice(
port="COM11", serial_number="SN1", description="USB Serial Device (COM11)"
)
unknown = DiscoveredDevice(port="COM3", product="Some Adapter", vid=0x1234)
ble = DiscoveredDevice(
transport="ble", address="AA:BB:CC:DD:EE:FF", name="MeshCore-Roam", product="MeshCore-Roam"
)
devices = [unknown, known, ble] # The order of discovery: the known device is not first.
store = DeviceStore(tmp_path / "devices.json")
store.remember(known, node_name="BaseStation")
captured: dict = {}
class _Ui:
async def select_startup( # noqa: ANN001, ANN201, ANN003
self, title, items, *, default=None, banner=None, footnote=None, **_kw
):
captured["items"] = items
return None # The user skips, so the flow never reaches the smoke test.
async def _never(_device):
raise AssertionError("verify should not run when selection is skipped")
asyncio.run(prompt_device(_Ui(), devices, store, _never))
rows = [it.title for it in captured["items"] if isinstance(it, Choice)]
device_rows = rows[:-1] # Remove the Quit row at the end.
# The confirmed device is at the very top. The row shows its mesh node name, not the
# generic description of the operating system, "USB Serial Device".
top = device_rows[0]
assert "BaseStation" in top.plain
assert "USB Serial Device" not in top.plain
# The name is also white ("device.known"), so that it is easy to see.
assert any(span.style == "device.known" for span in top.spans)
# The TYPE column marks the transport: a serial glyph for the wired node, and the
# Bluetooth rune for the companion that advertises over BLE.
assert _SERIAL_ICON in top.plain
assert any(_BLE_ICON in row.plain for row in device_rows)
def test_device_picker_ranks_likely_companions_above_a_bare_port() -> None:
"""The UART of a board (with no USB identity) goes below companions on any transport.
On a Cardputer Zero, ``/dev/ttyS0`` is the GPS of the Cap. MeshTerm scans serial first,
so it listed this port first. Then the splash opened with the highlight on a port that
cannot be a node.
"""
from meshterm.core.config import SpiWiring
from meshterm.core.discovery import DiscoveredDevice, spi_device
from meshterm.ui.device_picker import _order
uart = DiscoveredDevice(port="/dev/ttyS0", description="n/a")
ble = DiscoveredDevice(transport="ble", address="AA:BB", name="MeshCore-Roam")
cap = spi_device(SpiWiring(bus_id=0, cs_id=1), name="Cap LoRa-1262")
assert _order([uart, ble, cap], {}) == [ble, cap, uart]
def test_device_picker_reinjects_remembered_tcp_device(tmp_path) -> None:
"""A remembered TCP companion appears in the picker again, also if no scan can find it."""
from meshterm.core.device_store import DeviceStore
from meshterm.core.discovery import tcp_device
from meshterm.ui.device_picker import _TCP_ICON, prompt_device
store = DeviceStore(tmp_path / "devices.json")
store.remember(tcp_device("192.168.1.50", 5000), node_name="WifiNode")
captured: dict = {}
class _Ui:
async def select_startup( # noqa: ANN001, ANN201, ANN003
self, title, items, *, default=None, banner=None, footnote=None, **_kw
):
captured["items"] = items
return None # The user skips, so the flow never reaches the smoke test.
async def _never(_device):
raise AssertionError("verify should not run when selection is skipped")
# The scan found nothing in this session, but MeshTerm builds the remembered network
# device into the list again.
asyncio.run(prompt_device(_Ui(), [], store, _never))
rows = [it.title for it in captured["items"] if isinstance(it, Choice)]
device_rows = [r for r in rows if hasattr(r, "plain") and "WifiNode" in r.plain]
assert device_rows, "the remembered TCP device should be listed"
top = device_rows[0]
assert "192.168.1.50:5000" in top.plain # Its host and port are in the address column.
assert _TCP_ICON in top.plain # It has the network glyph in the TYPE column.
def test_device_picker_lists_configured_tcp_profile(tmp_path) -> None:
"""A ``[profiles.*]`` TCP entry is in the picker under its alias, and the user can select it."""
from meshterm.core.config import DeviceProfile
from meshterm.core.device_store import DeviceStore
from meshterm.ui.device_picker import _TCP_ICON, prompt_device
store = DeviceStore(tmp_path / "devices.json")
profiles = {
"bridge": DeviceProfile(name="bridge", transport="tcp", host="127.0.0.1", tcp_port=5000)
}
captured: dict = {}
class _Ui:
async def select_startup( # noqa: ANN001, ANN201, ANN003
self, title, items, *, default=None, banner=None, footnote=None, **_kw
):
captured["items"] = items
return None # The user skips, so the flow never reaches the smoke test.
async def _never(_device):
raise AssertionError("verify should not run when selection is skipped")
# The scan found nothing and nothing is remembered. The profile alone puts the endpoint
# in the list.
asyncio.run(prompt_device(_Ui(), [], store, _never, profiles))
rows = [it.title for it in captured["items"] if isinstance(it, Choice)]
device_rows = [r for r in rows if hasattr(r, "plain") and "bridge" in r.plain]
assert device_rows, "the configured TCP profile should be listed"
top = device_rows[0]
assert "127.0.0.1:5000" in top.plain # Its host and port are in the address column.
assert _TCP_ICON in top.plain # It has the network glyph in the TYPE column.
def test_device_picker_lists_configured_serial_profile(tmp_path) -> None:
"""A serial profile in the config is in the picker under its alias.
The user can select a soldered ``/dev/ttyS1``, although the scan of pyserial never
produces that platform port.
"""
from meshterm.core.config import DeviceProfile
from meshterm.core.device_store import DeviceStore
from meshterm.ui.device_picker import prompt_device
store = DeviceStore(tmp_path / "devices.json")
profiles = {"picocalc": DeviceProfile(name="picocalc", port="/dev/ttyS1", baudrate=115200)}
captured: dict = {}
class _Ui:
async def select_startup( # noqa: ANN001, ANN201, ANN003
self, title, items, *, default=None, banner=None, footnote=None, **_kw
):
captured["items"] = items
return None # The user skips, so the flow never reaches the smoke test.
async def _never(_device):
raise AssertionError("verify should not run when selection is skipped")
# The scan found nothing and nothing is remembered. The serial profile alone puts the
# port in the list.
asyncio.run(prompt_device(_Ui(), [], store, _never, profiles))
rows = [it.title for it in captured["items"] if isinstance(it, Choice)]
device_rows = [r for r in rows if hasattr(r, "plain") and "picocalc" in r.plain]
assert device_rows, "the configured serial profile should be listed"
assert "/dev/ttyS1" in device_rows[0].plain # Its port is in the address column.
def test_device_picker_serial_profile_yields_to_scanned_port(tmp_path) -> None:
"""A serial profile yields to the scanned port when both name the same port.
The row that pyserial found wins, because it has real USB metadata and the bare
profile does not.
"""
from meshterm.core.config import DeviceProfile
from meshterm.core.device_store import DeviceStore
from meshterm.core.discovery import DiscoveredDevice
from meshterm.ui.device_picker import prompt_device
store = DeviceStore(tmp_path / "devices.json")
scanned = [DiscoveredDevice(port="/dev/ttyUSB0", product="XIAO", vid=0x2886, pid=0x8044)]
profiles = {"radio": DeviceProfile(name="radio", port="/dev/ttyUSB0")}
captured: dict = {}
class _Ui:
async def select_startup( # noqa: ANN001, ANN201, ANN003
self, title, items, *, default=None, banner=None, footnote=None, **_kw
):
captured["items"] = items
return None
async def _never(_device):
raise AssertionError("verify should not run when selection is skipped")
asyncio.run(prompt_device(_Ui(), scanned, store, _never, profiles))
rows = [it.title for it in captured["items"] if isinstance(it, Choice)]
usb_rows = [r for r in rows if hasattr(r, "plain") and "/dev/ttyUSB0" in r.plain]
assert len(usb_rows) == 1, "the scanned port must not be duplicated by the profile"
def test_device_picker_profile_yields_to_remembered_endpoint(tmp_path) -> None:
"""A profile at an endpoint that is already remembered is not listed twice.
The row that has more information wins.
"""
from meshterm.core.config import DeviceProfile
from meshterm.core.device_store import DeviceStore
from meshterm.core.discovery import tcp_device
from meshterm.ui.device_picker import prompt_device
store = DeviceStore(tmp_path / "devices.json")
# The user confirmed it before, so it has the real node name that MeshTerm learned at
# connect time.
store.remember(tcp_device("127.0.0.1", 5000), node_name="uConsole")
profiles = {
"bridge": DeviceProfile(name="bridge", transport="tcp", host="127.0.0.1", tcp_port=5000)
}
captured: dict = {}
class _Ui:
async def select_startup( # noqa: ANN001, ANN201, ANN003
self, title, items, *, default=None, banner=None, footnote=None, **_kw
):
captured["items"] = items
return None
async def _never(_device):
raise AssertionError("verify should not run when selection is skipped")
asyncio.run(prompt_device(_Ui(), [], store, _never, profiles))
rows = [it.title for it in captured["items"] if isinstance(it, Choice)]
endpoint_rows = [r for r in rows if hasattr(r, "plain") and "127.0.0.1:5000" in r.plain]
assert len(endpoint_rows) == 1, "the endpoint should appear exactly once"
# The remembered node name wins over the plain alias of the profile.
assert "uConsole" in endpoint_rows[0].plain
assert "bridge" not in endpoint_rows[0].plain
def test_device_picker_adds_network_device(tmp_path) -> None:
"""The 'add a network device' row asks for the host and port, and confirms the TCP companion."""
from meshterm.core.device_store import DeviceStore
from meshterm.ui.device_picker import _ADD_TCP, prompt_device
store = DeviceStore(tmp_path / "devices.json")
probed: dict = {}
class _Ui:
async def select_startup( # noqa: ANN001, ANN201, ANN003
self, title, items, *, default=None, banner=None, footnote=None, **_kw
):
# Select the action row "add a network device".
return next(it.value for it in items if isinstance(it, Choice) and it.value is _ADD_TCP)
async def prompt_text_startup(
self,
title,
*,
prompt="",
default="",
validate=None,
help_text="",
banner=None,
footnote=None,
):
# The validator accepts a correct endpoint.
assert validate("192.168.1.50:5000") is True
return "192.168.1.50:5000"
async def busy_startup(self, message, coro, *, title="", banner=None, footnote=None):
return await coro
async def verify(device, pin=None):
probed["target"] = device.target
return {"adv_name": "WifiNode"} # A real companion answers.
chosen = asyncio.run(prompt_device(_Ui(), [], store, verify))
assert chosen is not None and chosen.is_tcp and chosen.target == "192.168.1.50:5000"
assert probed["target"] == "192.168.1.50:5000"
# MeshTerm remembers the confirmed network device for ever, with the node name that it
# reported.
remembered = store.load()
assert remembered is not None and remembered.is_tcp and remembered.node_name == "WifiNode"
def test_device_picker_removes_network_device_on_delete(tmp_path) -> None:
"""Delete on a network row asks to confirm, then forgets the device and removes the row."""
from meshterm.core.device_store import DeviceStore
from meshterm.core.discovery import tcp_device
from meshterm.ui.device_picker import _QUIT, prompt_device
from meshterm.ui.tui import DeleteRequest
store = DeviceStore(tmp_path / "devices.json")
store.remember(tcp_device("192.168.1.50", 5000), node_name="WifiNode")
seen_rows: list[list] = []
confirmed_prompts: list = []
class _Ui:
def __init__(self) -> None:
self._passes = 0
async def select_startup( # noqa: ANN001, ANN201, ANN003
self, title, items, *, default=None, banner=None, footnote=None, **_kw
):
names = [
it.label.plain
for it in items
if isinstance(it, Choice) and hasattr(it.label, "plain")
]
seen_rows.append(names)
self._passes += 1
if self._passes == 1:
# First pass: the network row is present, and the user can delete it. Press
# Delete.
row = next(
it
for it in items
if isinstance(it, Choice) and getattr(it.value, "is_tcp", False)
)
assert row.deletable
return DeleteRequest(row.value)
# Second pass (after the removal): quit the picker.
return _QUIT
async def confirm_startup(
self,
prompt,
*,
title="",
confirm_label="Remove",
banner=None,
footnote=None,
backdrop_items=None,
backdrop_default=None,
):
confirmed_prompts.append(prompt)
# The confirm floats over the picker. It gets the rows to draw behind it, with
# the row to remove already highlighted.
assert backdrop_items is not None
assert getattr(backdrop_default, "is_tcp", False)
return True # The user confirms the removal.
async def _never(_device, _pin=None):
raise AssertionError("verify should not run when a row is deleted, not chosen")
result = asyncio.run(prompt_device(_Ui(), [], store, _never))
assert result is None # The user quit on the second pass.
# The confirm named the device and its endpoint.
assert confirmed_prompts and "WifiNode" in confirmed_prompts[0]
assert "192.168.1.50:5000" in confirmed_prompts[0]
# The row was in the first paint and gone in the second, and the store forgot the device.
assert any("WifiNode" in name for name in seen_rows[0])
assert not any("WifiNode" in name for name in seen_rows[1])
assert store.load() is None
def test_device_picker_keeps_network_device_when_removal_cancelled(tmp_path) -> None:
"""If the user cancels the Delete confirm, the remembered network device stays."""
from meshterm.core.device_store import DeviceStore
from meshterm.core.discovery import tcp_device
from meshterm.ui.device_picker import _QUIT, prompt_device
from meshterm.ui.tui import DeleteRequest
store = DeviceStore(tmp_path / "devices.json")
store.remember(tcp_device("192.168.1.50", 5000), node_name="WifiNode")
class _Ui:
def __init__(self) -> None:
self._passes = 0
async def select_startup( # noqa: ANN001, ANN201, ANN003
self, title, items, *, default=None, banner=None, footnote=None, **_kw
):
self._passes += 1
if self._passes == 1:
row = next(
it
for it in items
if isinstance(it, Choice) and getattr(it.value, "is_tcp", False)
)
return DeleteRequest(row.value)
return _QUIT
async def confirm_startup(
self,
prompt,
*,
title="",
confirm_label="Remove",
banner=None,
footnote=None,
backdrop_items=None,
backdrop_default=None,
):
return False # The user backs out (Cancel or Esc).
async def _never(_device, _pin=None):
raise AssertionError("verify should not run")
asyncio.run(prompt_device(_Ui(), [], store, _never))
# MeshTerm forgot nothing, so it still remembers the device.
remembered = store.load()
assert remembered is not None and remembered.node_name == "WifiNode"
def test_confirm_startup_floats_red_over_the_picker_backdrop() -> None:
"""The removal confirm floats as a red dialog over a picker that MeshTerm draws again.
It is not a full splash.
"""
from meshterm.core.discovery import tcp_device
session = TuiSession()
device = tcp_device("192.168.1.50", 5000, name="WifiNode")
items = [Choice("WifiNode (192.168.1.50:5000)", device, deletable=True)]
async def main() -> None:
task = asyncio.ensure_future(
session.confirm_startup(
"Remove WifiNode?",
title="Remove network device",
banner=["MESHTERM"],
backdrop_items=items,
backdrop_default=device,
)
)
# Let confirm_startup push the backdrop list and float the confirm over it.
for _ in range(3):
await asyncio.sleep(0)
base = session._base_screen()
floats = session._float_layers()
# MeshTerm draws the picker again as the base screen that has no chrome. The confirm
# floats over it. It is not a full-screen splash that replaces the list.
assert isinstance(base, SelectScreen) and base.chrome is False
assert len(floats) == 1
dialog = floats[0]
assert isinstance(dialog, ButtonDialog)
assert dialog.border_style == "err" # The red that is reserved for data loss.
dialog.resolve(True) # Commit the removal.
result = await task
assert result is True
assert session._stack == [] # MeshTerm removes the backdrop with the dialog.
asyncio.run(main())
class _PickerUi:
"""A fake splash UI that always selects the first device, and then closes messages."""
def __init__(self) -> None:
self.notes: list = []
async def select_startup( # noqa: ANN001, ANN201, ANN003
self, title, items, *, default=None, banner=None, footnote=None, **_kw
):
return next(it.value for it in items if isinstance(it, Choice))
async def notify_startup(self, renderable, *, title="", banner=None, footnote=None):
self.notes.append(renderable)
async def busy_startup(self, message, coro, *, title="", banner=None, footnote=None):
return await coro
def test_device_picker_smoke_tests_and_reprompts(tmp_path) -> None:
"""A smoke test that fails asks again. MeshTerm remembers a test that passes as confirmed."""
from meshterm.core.device_store import DeviceStore
from meshterm.core.discovery import DiscoveredDevice
from meshterm.ui.device_picker import prompt_device
devices = [DiscoveredDevice(port="COM5", serial_number="SN1", product="Wio SX1262")]
store = DeviceStore(tmp_path / "devices.json")
ui = _PickerUi()
# The first probe fails (not MeshCore). The second probe answers with its own info.
results = [None, {"adv_name": "BaseStation"}]
async def verify(_device, _pin=None):
return results.pop(0)
chosen = asyncio.run(prompt_device(ui, devices, store, verify))
assert chosen is devices[0]
assert len(ui.notes) == 1 # The picker showed the "not a MeshCore device" message one time.
remembered = store.load()
assert remembered is not None and remembered.node_name == "BaseStation"
assert store.is_known(devices[0])
def test_device_picker_leaves_the_copyright_to_the_wordmark(tmp_path) -> None:
"""No splash states the copyright, because the art of the wordmark already has it."""
from meshterm.core.device_store import DeviceStore
from meshterm.core.discovery import DiscoveredDevice
from meshterm.ui.device_picker import prompt_device
devices = [DiscoveredDevice(port="COM5", serial_number="SN1", product="Wio SX1262")]
store = DeviceStore(tmp_path / "devices.json")
footnotes: list = []
class _Ui:
async def select_startup( # noqa: ANN001, ANN201, ANN003
self, title, items, *, default=None, banner=None, footnote=None, **_kw
):
footnotes.append(("select", footnote))
return next(it.value for it in items if isinstance(it, Choice))
async def notify_startup(self, renderable, *, title="", banner=None, footnote=None):
footnotes.append(("notify", footnote))
async def busy_startup(self, message, coro, *, title="", banner=None, footnote=None):
footnotes.append(("busy", footnote))
return await coro
# The first probe fails (the user selects again). The second probe passes. Thus the test
# runs each screen that comes after a selection.
results = [None, {"adv_name": "BaseStation"}]
async def verify(_device, _pin=None):
return results.pop(0)
asyncio.run(prompt_device(_Ui(), devices, store, verify))
# Each splash draws the logo, and the copyright is now in the logo. Thus no splash adds
# a line of its own: not the first picker, not the spinner of the smoke test, not the
# failure notice, and not the picker that opens again.
assert len(footnotes) > 1
assert all(footnote is None for _kind, footnote in footnotes)
def test_device_picker_prompts_and_retries_ble_pin(tmp_path) -> None:
"""A device that has a PIN opens the dialog, asks again for a wrong code, then connects."""
from meshterm.core.connection import DeviceAuthenticationError
from meshterm.core.device_store import DeviceStore
from meshterm.core.discovery import DiscoveredDevice
from meshterm.ui.device_picker import prompt_device
devices = [
DiscoveredDevice(transport="ble", address="00:11:22:33:44:55", name="MeshCore-Testbench")
]
store = DeviceStore(tmp_path / "devices.json")
entered = iter(["000000", "654321"]) # A wrong code, then the correct code.
errors: list = []
class _Ui:
async def select_startup( # noqa: ANN001, ANN201, ANN003
self, title, items, *, default=None, banner=None, footnote=None, **_kw
):
return next(it.value for it in items if isinstance(it, Choice))
async def notify_startup(self, renderable, *, title="", banner=None, footnote=None):
raise AssertionError("a successful PIN connect shows no failure notice")
async def busy_startup(self, message, coro, *, title="", banner=None, footnote=None):
return await coro
async def prompt_pin_startup(
self, device_name, *, error="", help_text="", banner=None, footnote=None
):
errors.append(error)
return next(entered)
async def verify(_device, pin=None):
if pin != "654321": # The device rejects the first probe (no PIN) and the wrong code.
# The connection names the refusal. The picker puts its hint into the dialog.
hint = "That PIN was rejected — check the code and try again." if pin else ""
raise DeviceAuthenticationError("needs a Bluetooth pairing PIN", hint=hint)
return {"adv_name": "Pinned", "model": "Seeed Tracker T1000-E"}
chosen = asyncio.run(prompt_device(_Ui(), devices, store, verify))
assert chosen is devices[0]
# The picker asked two times: first with no error, then with the hint of the refusal
# after the wrong code.
assert errors[0] == ""
assert "rejected" in errors[1].lower()
remembered = store.load()
assert remembered is not None
assert remembered.node_name == "Pinned"
# MeshTerm learned the model when it connected.
assert remembered.hardware_model == "Seeed Tracker T1000-E"
def test_device_picker_pin_cancel_returns_to_list(tmp_path) -> None:
"""Esc on the PIN dialog goes back to the device list. It does not connect."""
from meshterm.core.connection import DeviceAuthenticationError
from meshterm.core.device_store import DeviceStore
from meshterm.core.discovery import DiscoveredDevice
from meshterm.ui.device_picker import _QUIT, prompt_device
devices = [
DiscoveredDevice(transport="ble", address="00:11:22:33:44:55", name="MeshCore-Testbench")
]
store = DeviceStore(tmp_path / "devices.json")
picks = iter([0, "quit"]) # Select the device one time, then quit the list that opens again.
class _Ui:
async def select_startup( # noqa: ANN001, ANN201, ANN003
self, title, items, *, default=None, banner=None, footnote=None, **_kw
):
choices = [it for it in items if isinstance(it, Choice)]
step = next(picks)
if step == "quit":
return next(it.value for it in choices if it.value is _QUIT)
return choices[step].value
async def busy_startup(self, message, coro, *, title="", banner=None, footnote=None):
return await coro
async def prompt_pin_startup(
self, device_name, *, error="", help_text="", banner=None, footnote=None
):
return None # The user cancels the PIN entry.
async def verify(_device, pin=None):
raise DeviceAuthenticationError("needs a Bluetooth pairing PIN")
result = asyncio.run(prompt_device(_Ui(), devices, store, verify))
assert result is None # The user cancels the PIN and quits, so the picker has no device.
assert store.load() is None # MeshTerm remembered nothing.
def test_device_picker_quit_row_returns_none(tmp_path) -> None:
"""If the user selects the Quit row at the end, the picker returns None. No smoke test runs."""
from meshterm.core.device_store import DeviceStore
from meshterm.core.discovery import DiscoveredDevice
from meshterm.ui.device_picker import _QUIT, prompt_device
devices = [DiscoveredDevice(port="COM5", product="Wio SX1262")]
class _QuitUi:
async def select_startup( # noqa: ANN001, ANN201, ANN003
self, title, items, *, default=None, banner=None, footnote=None, **_kw
):
# The last choice is the Quit row. When the user selects it, the app exits.
quit_choice = [it for it in items if isinstance(it, Choice) and it.value is _QUIT]
assert quit_choice, "the picker offers a Quit row"
return quit_choice[0].value
async def _never(_device):
raise AssertionError("verify must not run when the user quits")
store = DeviceStore(tmp_path / "devices.json")
assert asyncio.run(prompt_device(_QuitUi(), devices, store, _never)) is None
# --- busy splash --------------------------------------------------------------
def test_a_busy_caption_hangs_its_lines_under_its_text() -> None:
"""A line feed starts a line under the caption. A line that is too long for the box does too.
A user reported this card. A long room name pushed the last letter of the count alone
onto a line at column zero. Now the break is on purpose. The second line starts under
the text of the first line, and the card has the width of its widest line.
"""
from rich.text import Text as RichText
from meshterm.ui.tui.screen import BusyDialog
card = BusyDialog("logging in to YJN-ROOM-OBS St-Jean\nalong its 6-hop route… 37 s")
lines = RichText.from_ansi("\n".join(card.render_body(60))).plain.splitlines()
assert lines[0][3:] == "logging in to YJN-ROOM-OBS St-Jean"
assert lines[1] == " along its 6-hop route… 37 s"
assert card.dialog_width == len("logging in to YJN-ROOM-OBS St-Jean") + 8
narrow = RichText.from_ansi("\n".join(card.render_body(24))).plain.splitlines()
assert all(line.startswith(" ") for line in narrow[1:]), narrow
def test_busy_screen_spins_over_its_message() -> None:
"""The busy splash shows an ASCII spinner beside its message, and it advances on a tick."""
from rich.text import Text as RichText
from meshterm.ui.tui.screen import BusyScreen
from meshterm.ui.tui.spinner import Spinner
screen = BusyScreen("Talking to Wio on COM5…")
def glyph() -> str:
"""The first character (the spinner) of the rendered body, with no ANSI codes."""
return RichText.from_ansi("\n".join(screen.render_body(60))).plain.lstrip()[0]
plain = RichText.from_ansi("\n".join(screen.render_body(60))).plain
assert "Talking to Wio on COM5" in plain
assert glyph() in Spinner.BRAILLE # A glyph of the spinner starts the line.
# The ticks cycle through each animation step and return to the first.
seen = {glyph()}
for _ in range(len(Spinner.BRAILLE) - 1):
screen.tick()
seen.add(glyph())
assert seen == set(Spinner.BRAILLE)
def test_spinner_cycles_and_resets() -> None:
"""The reusable Spinner advances through its animation steps, wraps, and resets."""
from meshterm.ui.tui.spinner import Spinner
spinner = Spinner("ab", style="warn")
assert spinner.frame == "a"
assert spinner.text().plain == "a" and spinner.text().style == "warn"
spinner.tick()
assert spinner.frame == "b"
spinner.tick() # It wraps back to the first animation step.
assert spinner.frame == "a"
spinner.tick()
spinner.reset()
assert spinner.frame == "a"
# --- progress ----------------------------------------------------------------
def test_progress_handle_matches_rich_api() -> None:
"""add_task, advance, and update are the same as the part of Rich Progress that tools use."""
screen = ProgressScreen("work")
task = screen.add_task("tracing", total=5)
screen.advance(task)
screen.advance(task, 2)
assert screen._tasks[task].completed == 3
screen.update(task, description="tracing more", completed=5, total=5)
assert screen._tasks[task].description == "tracing more"
assert screen._tasks[task].completed == 5
# The screen renders without an error at a realistic width.
assert screen.render_body(60)
def test_progress_chip_animates_between_advances() -> None:
"""The working chip spins on a tick alone, so a task that seldom advances looks alive.
A trace advances only one time, at the very end. Until then, the meter does not move.
A tick of the dialog must change what the dialog renders, also if the count of the
task does not change.
"""
screen = ProgressScreen("trace")
screen.add_task("tracing", total=1) # It stays at 0/1 for the whole wait.
before = "\n".join(screen.render_body(60))
screen.tick()
after = "\n".join(screen.render_body(60))
assert before != after # The chip moved, but nothing advanced.
def test_progress_completed_task_shows_check_and_indeterminate_has_no_track() -> None:
"""A finished task changes its chip to ✓. A task with an unknown total draws no dead track."""
from rich.text import Text as RichText
screen = ProgressScreen("work")
done = screen.add_task("tracing", total=2)
screen.advance(done, 2)
screen.add_task("optimizing", total=None)
plain = RichText.from_ansi("\n".join(screen.render_body(60))).plain
assert "✓ tracing" in plain
# The row with no known total has only the spinning chip and its count. It never has a
# glyph of a track.
indet_line = next(line for line in plain.splitlines() if "optimizing" in line)
assert "⠶" not in indet_line and "⣿" not in indet_line
# --- session stack -----------------------------------------------------------
async def test_session_runs_and_exits_when_main_returns() -> None:
"""The app starts, runs the main coroutine, and exits cleanly when the coroutine returns."""
with create_pipe_input() as inp:
session = TuiSession(input=inp, output=DummyOutput())
ran = {}
async def main() -> None:
ran["done"] = True
await asyncio.wait_for(session.run(main()), timeout=5)
assert ran["done"] is True
async def test_session_select_dispatches_piped_keys() -> None:
"""A select resolves the value that the piped Down and Enter key presses choose, end to end."""
with create_pipe_input() as inp:
session = TuiSession(input=inp, output=DummyOutput())
captured = {}
async def main() -> None:
captured["value"] = await session.select(
"pick", [Choice("a", 1), Choice("b", 2), Choice("c", 3)]
)
inp.send_text("\x1b[B\x1b[B\r") # Down, Down, Enter: the third choice.
await asyncio.wait_for(session.run(main()), timeout=5)
assert captured["value"] == 3
async def test_session_busy_startup_animates_and_returns() -> None:
"""busy_startup awaits the task behind a spinner splash that has no chrome, then pops it."""
from meshterm.ui.tui.screen import BusyScreen
with create_pipe_input() as inp:
session = TuiSession(input=inp, output=DummyOutput())
captured = {}
async def work() -> str:
# While the task runs, the busy splash is the base screen that has no chrome.
assert isinstance(session.top, BusyScreen)
assert session.top.chrome is False
await asyncio.sleep(0.3) # This is long enough for the spinner to tick one time.
return "ok"
async def main() -> None:
captured["value"] = await session.busy_startup("checking…", work())
await asyncio.wait_for(session.run(main()), timeout=5)
assert captured["value"] == "ok"
assert session.top is None # MeshTerm popped the splash when the task finished.
async def test_busy_screens_spin_at_the_platform_cadence() -> None:
"""Neither busy screen has a spin rate of its own.
A paint on the PicoCalc console costs more than the time that these loops once waited
between paints. Thus a fixed cadence used the event loop to paint the spinner, and it
starved the device read that the spinner covered. The wait became slower because it
was animated. Both screens now use the one decision of the platform, as each other
animated wait in the app does.
"""
import inspect
from meshterm.ui.tui.session import TuiSession as _Session
for name in ("busy_startup", "busy_overlay"):
interval = inspect.signature(getattr(_Session, name)).parameters["interval"]
assert interval.default is None, f"{name} pins its own spin rate: {interval.default!r}"
async def test_busy_startup_keeps_ticking_through_a_slow_await() -> None:
"""The spinner animates while the awaited work runs, not only before and after it.
The animation uses the same event loop as the request that it reports on. Thus a call
that blocks the loop stops the animation. For this reason, MeshTerm runs the slow parts
of a device read (the list of serial ports, the read of the history) in a thread.
"""
from meshterm.ui.tui.screen import BusyScreen
with create_pipe_input() as inp:
session = TuiSession(input=inp, output=DummyOutput())
frames: list[str] = []
async def work() -> str:
screen = session.top
assert isinstance(screen, BusyScreen)
for _ in range(6): # Span more than one spin, and do not block the loop.
await asyncio.sleep(spinner_interval())
frames.append(screen._spinner.frame)
return "ok"
async def main() -> None:
await session.busy_startup("checking…", work(), interval=spinner_interval() / 4)
await asyncio.wait_for(session.run(main()), timeout=15)
assert len(set(frames)) > 1, f"the spinner never advanced: {frames}"
async def test_session_text_dispatches_typed_keys() -> None:
"""A text prompt takes the typed characters and commits on Enter, end to end."""
with create_pipe_input() as inp:
session = TuiSession(input=inp, output=DummyOutput())
captured = {}
async def main() -> None:
captured["value"] = await session.text("name?")
inp.send_text("hi\r")
await asyncio.wait_for(session.run(main()), timeout=5)
assert captured["value"] == "hi"
def test_session_stack_and_float_selection() -> None:
"""The stack tracks the top and the base, and floats only a deeper screen over its parent."""
session = TuiSession()
assert session.top is None
base = ScrollScreen(Text("base"), title="base")
session.push(base)
assert session.top is base
assert session._base_screen() is base
assert not session._has_float() # There is one screen, so there is no float.
dialog = SelectScreen("pick", [Choice("a", 1)])
session.push(dialog)
assert session._has_float() # A deeper screen floats.
assert session._base_screen() is base # The base is the parent under the dialog.
session.pop(dialog)
assert session.top is base
assert not session._has_float()
def test_session_stacks_every_dialog_over_one_full_frame_background() -> None:
"""A dialog over a dialog: both float over the one background, and neither is full-frame.
The old compositor drew only the top float. It rendered the second screen from the top
as the full-frame base. Thus, when a confirm opened over a dialog, that dialog became
as large as the frame. Now the background is the deepest full-frame screen, and each
floating layer above it stays its own box at the centre.
"""
session = TuiSession()
channels = SelectScreen("Channels", [Choice("Ops", 1)]) # The floating list of a tool.
detail = SelectScreen("Ops (private)", [Choice("Clear", "clr")]) # Its item dialog.
confirm = ButtonDialog("Clear Ops?", [("Cancel", 0), ("Clear", 1)], border_style="err")
for screen in (channels, detail, confirm):
session.push(screen)
# The bottom screen (all the screens float) is the one full-frame background. The detail
# dialog and the confirm both float over it. The code does not make the detail the base.
assert session._base_screen() is channels
assert session._float_layers() == [detail, confirm]
session.pop(confirm)
assert session._float_layers() == [detail] # The detail stays a float. It is not full-frame.
assert session._base_screen() is channels
def test_session_background_is_the_topmost_full_frame_screen() -> None:
"""A screen that does not float (a map, a scroll screen) is the background under dialogs."""
session = TuiSession()
full = ScrollScreen(Text("map"), title="map") # floating=False
dialog = ButtonDialog("go?", [("No", 0), ("Yes", 1)])
session.push(full)
session.push(dialog)
assert session._base_screen() is full
assert session._float_layers() == [dialog]
def test_dispatch_promotes_nav_actions_while_right_ctrl_is_held(monkeypatch) -> None:
"""A bare navigation key becomes its Ctrl chord while the user holds right Ctrl.
This is the rescue for keyboard layouts (Canadian Multilingual Standard) that use right
Ctrl as a modifier for characters. These layouts remove the ctrl flag from the arrow
event of the console. Actions that have no Ctrl version pass through with no change.
Each action also passes through with no change after the user releases the key.
"""
from meshterm.ui.tui import session as session_mod
session = TuiSession()
seen: list[str] = []
class Probe(ScrollScreen):
def handle(self, action: str, data: str = "") -> None:
seen.append(action)
session.push(Probe(Text("x")))
monkeypatch.setattr(session_mod, "_right_ctrl_down", lambda: True)
session._dispatch("left")
session._dispatch("home")
# Enter also has a Ctrl version. A terminal cannot spell this key as a chord by itself.
session._dispatch("enter")
session._dispatch("escape") # It has no Ctrl version, so it does not change while held.
monkeypatch.setattr(session_mod, "_right_ctrl_down", lambda: False)
session._dispatch("left")
assert seen == ["ctrl_left", "ctrl_home", "ctrl_enter", "escape", "left"]
def test_dispatch_promotes_letter_chords_while_right_ctrl_is_held(monkeypatch) -> None:
"""A bare letter becomes its Ctrl-letter chord while the user holds right Ctrl.
This is the same rescue as for the navigation keys. It is for the ^R and ^P shortcuts.
If a keyboard layout uses right Ctrl, the layout changes these shortcuts to plain text.
Only the letters in the map change. The code makes them lower case and removes the old
data. Other text stays text, and each letter stays text after the user releases the key.
"""
from meshterm.ui.tui import session as session_mod
session = TuiSession()
seen: list[tuple[str, str]] = []
class Probe(ScrollScreen):
def handle(self, action: str, data: str = "") -> None:
seen.append((action, data))
session.push(Probe(Text("x")))
monkeypatch.setattr(session_mod, "_right_ctrl_down", lambda: True)
session._dispatch("text", "r") # ^R retry.
session._dispatch("text", "P") # ^P paths. The code makes the letter lower case.
session._dispatch("text", "x") # This letter is not in the map, so it stays text while held.
monkeypatch.setattr(session_mod, "_right_ctrl_down", lambda: False)
session._dispatch("text", "r") # The user released the key, so this is plain text again.
assert seen == [("retry", ""), ("paths", ""), ("text", "x"), ("text", "r")]
def test_right_ctrl_rescue_covers_the_sessions_own_chords(monkeypatch) -> None:
"""The right-Ctrl rescue reaches the chords of the session itself, not only screen actions.
The session itself answers ^V and ^C. The rescue must reach them too, for a keyboard
layout that uses right Ctrl. Right Ctrl-V pastes the clipboard into a compose line and
does not type a ``v``. Right Ctrl-C quits. The session never sends either
pseudo-action to a screen.
"""
from meshterm.ui.tui import session as session_mod
session = TuiSession()
seen: list[tuple[str, str]] = []
class Probe(ScrollScreen):
def handle(self, action: str, data: str = "") -> None:
seen.append((action, data))
class FakeApp:
def __init__(self) -> None:
self.exited = False
self.is_running = True
def exit(self) -> None:
self.exited = True
def invalidate(self) -> None:
pass
session.push(Probe(Text("x")))
session._app = FakeApp()
monkeypatch.setattr(session_mod, "_read_clipboard", lambda: "pasted")
monkeypatch.setattr(session_mod, "_right_ctrl_down", lambda: True)
session._dispatch("text", "v") # ^V: the clipboard reaches the screen as a paste.
assert seen == [("paste", "pasted")]
session._dispatch("text", "c") # ^C: the app quits, and nothing reaches the screen.
assert seen == [("paste", "pasted")]
assert session._app.exited is True
monkeypatch.setattr(session_mod, "_right_ctrl_down", lambda: False)
session._dispatch("text", "v") # The user released the key: this is a typed character again.
assert seen[-1] == ("text", "v")
def test_every_ctrl_letter_chord_is_bound_on_both_ctrl_keys() -> None:
"""Each Ctrl-letter chord is bound on both Ctrl keys.
The chord table sets the prompt_toolkit bindings. Thus a chord is never bound for the
left Ctrl without its right-Ctrl rescue. The two tables could once differ, when a
person maintained the letter map by hand.
"""
from prompt_toolkit.keys import Keys
from meshterm.ui.tui.session import _CTRL_LETTER_CHORDS, _KEY_ACTIONS
for letter, action in _CTRL_LETTER_CHORDS.items():
key = getattr(Keys, f"Control{letter.upper()}")
assert _KEY_ACTIONS[key] == action
# The terminal spells Enter, Tab, and Backspace as c-m, c-i, and c-h. If the table used
# those letters, it would bind these keys again.
assert not {"m", "i", "h"} & set(_CTRL_LETTER_CHORDS)
assert _KEY_ACTIONS[Keys.Enter] == "enter"
def test_wide_glyph_detection_flags_emoji_not_marks() -> None:
"""The wide-glyph check flags emoji, and does not flag the marks of the app.
The desync comes only from a glyph of width 2 that the terminal can draw narrower.
The node-type marks, the status marks, and the chart braille have width 1 on each
terminal. Thus they must not cause the check to flag them. We once thought that they
did, and this was a wrong diagnosis.
"""
from meshterm.ui.tui.session import _has_wide_glyph
assert _has_wide_glyph("👋")
assert _has_wide_glyph("Bob 👋 waved")
assert _has_wide_glyph("clock 🕒 sync")
assert _has_wide_glyph("⚡ explore") # An icon of width 2 also counts.
assert not _has_wide_glyph("plain ascii row")
assert not _has_wide_glyph("★ ▲ ● ■ ◉ ○") # Node-type marks: width 1.
assert not _has_wide_glyph("⠿⣿⡇ chart") # Braille: width 1.
assert not _has_wide_glyph("✓ ✗ ⚠ … done") # Status marks: width 1.
def _repaint_harness():
"""A session that uses a fake pt app, and its remembered frame of 3 rows of ``A`` cells."""
import types
from prompt_toolkit.data_structures import Size
from prompt_toolkit.layout.screen import Char
from prompt_toolkit.layout.screen import Screen as PtScreen
remembered = PtScreen()
for row in range(3):
for x in range(10):
remembered.data_buffer[row][x] = Char("A")
session = TuiSession()
session._app = types.SimpleNamespace(
renderer=types.SimpleNamespace(_last_screen=remembered),
output=types.SimpleNamespace(get_size=lambda: Size(rows=10, columns=60)),
invalidate=lambda: None,
)
return session, remembered
def _row_text(screen, row: int) -> str:
"""A row of the remembered frame as plain characters. The sentinel of the scrub shows."""
return "".join(screen.data_buffer[row][x].char for x in range(10))
def test_a_wide_glyph_frame_upgrades_to_a_full_repaint() -> None:
"""A frame that has a wide glyph changes the next paint to a full paint.
prompt_toolkit paints only the differences, with a relative cursor. This is correct
only while each glyph is one cell wide. The terminal can draw a glyph of width 2 (an
emoji in a chat line) in one cell. Then the cursor model of the row is wrong. A later
paint skips the emoji, because it did not change, and it leaves old cells to the right
of it. For such a frame, MeshTerm drops the cached frame of pt. There is then no
remembered frame to compare against, so pt does an erase_down and a new draw. A frame
that has only glyphs of width 1 keeps the fast paint of the differences. This is
true for a glyph in any place, in a floating dialog or not.
"""
session, _remembered = _repaint_harness()
session._emit("Bob 👋 says hi")
assert session._app.renderer._last_screen is None
# A frame that has only glyphs of width 1 (plain text, node marks, chart braille) keeps
# the efficient paint of the differences.
session, remembered = _repaint_harness()
session._emit("★ you ▲ repeater ● node ⠿ chart")
assert session._app.renderer._last_screen is remembered
session._emit("★ you ▲ repeater ● node ⠿ chart · moved") # It changed, but all width 1.
assert session._app.renderer._last_screen is remembered
assert _row_text(remembered, 0) == "A" * 10 # MeshTerm also scrubbed nothing.
def test_only_the_rows_that_changed_are_repainted() -> None:
"""A header that ticks paints the header again, and does not paint the screen under it.
A wide glyph makes this necessary: MeshTerm must write its row whole again, from
column 0. pt goes down one row with a carriage return, so the wrong alignment cannot
reach the rows below. The background has one line for each terminal row. Thus the rows
that changed are exactly the rows that MeshTerm must write again. An idle frame changes
none of them. The 1 Hz refresh once caused flicker because it did not do this.
"""
session, remembered = _repaint_harness()
frame_1 = "🎯 Farthest node\nrow one\nrow two"
session._emit(frame_1)
assert session._app.renderer._last_screen is None # There is nothing to compare against yet.
session._app.renderer._last_screen = remembered
session._emit(frame_1) # The timer tick: it is the same frame, so nothing is touched.
assert session._app.renderer._last_screen is remembered
assert [_row_text(remembered, y) for y in range(3)] == ["A" * 10] * 3
session._emit("🎯 Farthest node\nrow one changed\nrow two")
assert session._app.renderer._last_screen is remembered # No erase and no full draw.
assert _row_text(remembered, 1) == "￿" * 10 # The one row that changed, marked whole.
assert _row_text(remembered, 0) == "A" * 10 # The rows around it stay as they were.
assert _row_text(remembered, 2) == "A" * 10
# A frame that has a different height has no row mapping to trust, so MeshTerm paints
# everything again.
session._emit("🎯 Farthest node\nrow one changed")
assert session._app.renderer._last_screen is None
def test_a_changed_layer_repaints_over_a_still_wide_glyph_frame() -> None:
"""A change in any layer upgrades the paint, while a wide glyph is drawn in any place.
A plain dialog that moves over a base row with an emoji is written again from a model
of that row, and the terminal does not agree with the model. Thus the whole frame
decides, and not the layer that changed. A layer that leaves is also a change. A
dialog that closes only stops rendering. If the paint did not change, MeshTerm would
write the cells that the dialog gives back to the base again, in parts.
"""
import types
from prompt_toolkit.data_structures import Size
from prompt_toolkit.layout.screen import Screen as PtScreen
session = TuiSession()
remembered = PtScreen()
session._app = types.SimpleNamespace(
renderer=types.SimpleNamespace(_last_screen=remembered),
output=types.SimpleNamespace(get_size=lambda: Size(rows=10, columns=60)),
invalidate=lambda: None,
)
session._emit("🎯 the board behind", "base") # A wide glyph on the background.
session._app.renderer._last_screen = remembered
session._emit("plain dialog, frame 1", "float0") # It has no emoji of its own...
assert session._app.renderer._last_screen is None # ...but the frame has one.
session._app.renderer._last_screen = remembered
session._emit("plain dialog, frame 1", "float0") # It did not change, so MeshTerm leaves it.
assert session._app.renderer._last_screen is remembered
# The dialog closes. One screen on the stack means no float layer in this paint, so the
# reconcile drops the layer. The base with the emoji is still drawn, so MeshTerm paints
# the cells that the layer gives back whole, and not in parts.
session.push(Screen())
session._app.renderer._last_screen = remembered
session._reconcile_layers()
assert "float0" not in session._layers
assert session._app.renderer._last_screen is None
# If no wide glyph is drawn, a layer that leaves causes no new paint.
session._layers.clear()
session._emit("plain base", "base")
session._emit("plain dialog", "float0")
session._app.renderer._last_screen = remembered
session._reconcile_layers()
assert session._app.renderer._last_screen is remembered
def test_floating_text_prompt_is_a_popup_over_a_blank_base() -> None:
"""``text(floating=True)`` floats as a dialog at the centre, also on an empty stack.
A modal in the middle of a flow must float like the button dialogs. One example is a
remote-admin password, between the node picker and the admin menu. Thus MeshTerm puts a
blank base under it. The prompt must not fill the frame, as the first entry screen of
a tool does.
"""
session = TuiSession()
async def main() -> None:
task = asyncio.ensure_future(session.text("Password", password=True, floating=True))
for _ in range(5):
await asyncio.sleep(0)
if session._has_float():
break
floats = session._float_layers()
assert len(floats) == 1 and isinstance(floats[0], TextScreen)
assert session._base_screen() is not floats[0] # A blank base is under it.
floats[0].resolve("hunter2")
assert await task == "hunter2"
assert session._stack == [] # MeshTerm removes the blank base with the prompt.
asyncio.run(main())
def test_default_text_prompt_is_the_full_frame_base() -> None:
"""A default ``text`` prompt on an empty stack is the frame. It is the first entry of a tool.
The typed fallback of the Trace target takes the place of the select picker. Thus it
fills the frame. It does not float over a blank base. The floating dialog is optional
(refer to the test above).
"""
session = TuiSession()
async def main() -> None:
task = asyncio.ensure_future(session.text("Target node"))
for _ in range(5):
await asyncio.sleep(0)
if session.top is not None:
break
assert isinstance(session.top, TextScreen)
assert not session._has_float() # There is no float. The prompt is the background.
assert session._base_screen() is session.top
session.top.resolve("Hub")
assert await task == "Hub"
asyncio.run(main())
# --- busy skeleton card ------------------------------------------------------
def test_busy_overlay_renders_only_its_title_chip_and_caption() -> None:
"""A card with a title and a caption shows its heading, the working chip, and the caption.
The card once had a Knight-Rider scanning bar under the caption (JP, 2026-08-29). Now
the chip is the whole animation. Thus the card has two lines, and nothing moves across
it.
"""
import re
from meshterm.ui.tui.overlay import BusyOverlay
overlay = BusyOverlay("reading from Waymarker…", title="Nodes", fade=0.0) # Bright at once.
ansi = overlay.render()
assert "Nodes" in ansi # The heading that names the screen that MeshTerm gets data for.
assert "reading from Waymarker" in ansi # The caption beside the chip.
assert overlay.spinner.frame in ansi # The working chip, which is one cell wide.
plain = re.sub(r"\[[0-9;]*m", "", ansi)
rows = [line for line in plain.splitlines() if line.strip()]
assert len(rows) == 2 # The title and the chip line. There is no bar and no spacer row.
assert "⠶" not in plain # The LED lamps are removed, not only unlit.
def test_busy_overlay_chip_ticks_with_the_animation() -> None:
"""The working chip of the card is the reusable Spinner, and it advances on a tick."""
from meshterm.ui.tui.overlay import BusyOverlay
from meshterm.ui.tui.spinner import Spinner
overlay = BusyOverlay()
assert isinstance(overlay.spinner, Spinner)
first = overlay.spinner.frame
overlay.tick()
assert overlay.spinner.frame != first
def test_busy_overlay_holds_black_then_fades_in() -> None:
"""The brightness is 0 during the hold. Then it climbs to full colour in the fade time."""
from meshterm.ui.tui.overlay import BusyOverlay
overlay = BusyOverlay(hold=0.1, fade=0.2)
assert overlay.brightness == 0.0 # Nothing paints during the hold.
overlay.started_at -= 0.1 # Move to the very end of the hold.
assert overlay.brightness < 0.2 # The glow from black only starts now.
overlay.started_at -= 0.2 # Move past the whole fade time.
assert overlay.brightness == 1.0 # The card is fully lit.
def test_dim_color_scales_hex_toward_black() -> None:
"""The fade dimmer scales the hex channels and keeps attribute words such as ``bold``."""
from meshterm.ui.tui.overlay import dim_color
assert dim_color("#38bdf8", 1.0) == "#38bdf8" # No change at full brightness.
assert dim_color("#ffffff", 0.0) == "#000000" # Black at zero.
# It keeps 'bold', and halves the colour.
assert dim_color("bold #ffffff", 0.5) == "bold #808080"
def test_overlay_fade_restarts_when_re_exposed_after_a_prompt() -> None:
"""A pop back to an empty stack plays the black hold and the fade again.
It does not snap to full brightness.
"""
from meshterm.ui.tui.overlay import BusyOverlay
session = TuiSession()
overlay = BusyOverlay()
session._overlay = overlay
overlay.started_at -= 10 # Act as if the intro already finished.
assert overlay.brightness == 1.0
screen = ScrollScreen(Text("prompt"))
session.push(screen) # A prompt covers the card.
session.pop(screen) # The prompt closes, and the card shows again on the empty stack.
assert overlay.brightness == 0.0 # The fade restarted from black.
async def test_session_busy_overlay_shows_between_screens_and_clears() -> None:
"""The overlay floats while a block runs on an empty stack, and MeshTerm drops it after."""
with create_pipe_input() as inp:
session = TuiSession(input=inp, output=DummyOutput())
seen = {}
async def main() -> None:
async with session.busy_overlay("working…"):
await asyncio.sleep(0.05) # This is still in the first hold.
seen["hidden_during_hold"] = not session._overlay_visible()
await asyncio.sleep(0.3) # This is past the hold of 200 ms. The ring faded in.
seen["active"] = session._overlay is not None
seen["visible_empty_stack"] = session._overlay_visible()
seen["rendered"] = bool(session._render_overlay().value.strip())
# If a screen is on the stack, the ring stays hidden. Thus it cannot cover a
# prompt.
session.push(ScrollScreen(Text("prompt"), title="p"))
seen["hidden_over_screen"] = not session._overlay_visible()
session.pop()
await asyncio.wait_for(session.run(main()), timeout=5)
assert seen["hidden_during_hold"] is True
assert seen["active"] is True
assert seen["visible_empty_stack"] is True
assert seen["rendered"] is True
assert seen["hidden_over_screen"] is True
assert session._overlay is None # MeshTerm cleared it on exit.
# --- horizontal scroll (opt-in) -------------------------------------------------------
def _hscroll_screen(width_of_rows: int = 60) -> SelectScreen:
from meshterm.ui.tui.select import Choice, SelectScreen, Separator
return SelectScreen(
"long",
[
Separator("HEAD-" + "h" * width_of_rows),
Choice("row-one-" + "x" * width_of_rows + "-tail", 1),
Choice("short", 2),
],
hscroll=True,
filterable=True,
)
def _row_plains(screen, width: int) -> list[str]:
import re
return [re.sub(r"\x1b\[[0-9;]*m", "", ln) for ln in screen.render_body(width)]
def test_select_hscroll_shifts_only_the_highlighted_row() -> None:
"""→ slides the highlighted row under its pinned pointer. Other rows and headers stay."""
screen = _hscroll_screen() # The long "row-one" is highlighted by default.
before = _row_plains(screen, 40)
assert any("row-one-" in ln for ln in before)
screen.handle("right")
shifted = _row_plains(screen, 40)
# The head of the highlighted row scrolled off, under the pointer that is still pinned...
assert any(ln.startswith("❯ ") for ln in shifted)
assert not any("row-one-" in ln for ln in shifted)
# ...but the section header did not move, and the short row did not change.
assert any(ln.strip().startswith("HEAD-") for ln in shifted)
assert any("short" in ln for ln in shifted)
screen.handle("left")
assert any("row-one-" in ln for ln in _row_plains(screen, 40))
def test_select_hscroll_clamps_at_the_highlighted_rows_tail() -> None:
"""→ stops when the end of the highlighted row is visible, not the end of the widest row."""
screen = _hscroll_screen()
for _ in range(50):
screen.handle("right")
plains = _row_plains(screen, 40) # The render clamps the shift.
assert any("-tail" in ln for ln in plains) # The end of the highlighted row is visible.
row_len = len("row-one-" + "x" * 60 + "-tail")
# The shift clamps to the first whole step that brings the tail into the lane. The lane
# is the width minus the pointer, minus the cell that a scrolled row uses for its left
# cut mark. If the shift stopped on the exact cell at the right edge, a right mark
# would promise more text.
step = screen._HSCROLL_STEP
assert screen._hshift == -(-(row_len - (40 - 2 - 1)) // step) * step
def test_select_hscroll_resets_when_the_highlight_moves() -> None:
"""The shift belongs to one row. If the highlight moves or the filter changes, it goes to 0."""
screen = _hscroll_screen()
screen.handle("right")
assert screen._hshift > 0
screen.handle("down") # A move to another row drops the scroll of that row.
assert screen._hshift == 0
screen.handle("right")
screen.handle("text", "r") # A change of the filter also resets it.
assert screen._hshift == 0
def test_select_hscroll_only_acts_on_an_overflowing_row() -> None:
"""←→ and their footer atom act only while the highlighted row is wider than the width."""
screen = _hscroll_screen()
screen.render_body(40) # The long row-one is highlighted, and it is wider than 40 cells.
assert "←→ scroll" in screen.footer_hint
screen.handle("down") # The short row fits, so there is nothing to scroll.
screen.render_body(40)
assert "←→ scroll" not in screen.footer_hint
screen.handle("right")
screen.render_body(40)
assert screen._hshift == 0 # A row that fits cannot shift.
def test_select_hscroll_from_pins_the_rows_head_and_slides_only_its_run() -> None:
"""A row that declares a head block keeps it drawn while ←→ scroll all the text after it."""
from meshterm.ui.tui.select import Choice, SelectScreen
lanes = "#1 Aug 09 "
screen = SelectScreen(
"long",
[Choice(lanes + "run-" + "y" * 60 + "-end", 1, hscroll_from=len(lanes))],
hscroll=True,
)
screen.handle("right")
row = _row_plains(screen, 40)[0]
assert row.startswith("❯ " + lanes) # The lanes never move...
assert "run-" not in row # ...while the run behind them slid off to the left.
for _ in range(50):
screen.handle("right")
end = _row_plains(screen, 40)[0]
assert end.startswith("❯ " + lanes) and "-end" in end # The user can reach the tail.
def test_select_scrolls_for_a_row_that_pins_a_head_without_being_told() -> None:
"""A row that declares ``hscroll_from`` turns on the scrolling of its list by itself.
The builders that lay out rows with a label and a description (for example
:func:`~meshterm.ui.menus.menu_rows` and the main menu) never see the screen in which
their list opens, because ``ctx.ui.select`` builds it. Thus the intent must be in the row.
"""
from meshterm.ui.tui.select import Choice, SelectScreen
lanes = "Send advert "
row = Choice(lanes + "Announce this node " + "and then some " * 6, 1, hscroll_from=len(lanes))
screen = SelectScreen("menu", [row]) # There is no hscroll= in any place.
screen.handle("right")
drawn = _row_plains(screen, 40)[0]
assert drawn.startswith("❯ " + lanes) # The name stays pinned...
assert "Announce this node" not in drawn # ...and the description slid under it.
# A list of plain rows still ignores ←→ completely.
plain = SelectScreen("menu", [Choice("x" * 80, 1)])
before = _row_plains(plain, 40)[0]
plain.handle("right")
assert _row_plains(plain, 40)[0] == before
def test_a_list_that_says_no_hscroll_is_not_overruled_by_its_rows() -> None:
"""``hscroll=False`` holds against rows that pin a head, also through a swap of rows.
The editor pages end their lanes at the edge. Their Actions rows pin heads. The rows
once turned on the ←→ scrolling of the whole page, in spite of the list.
"""
from meshterm.ui.tui.select import Choice, SelectScreen
lanes = "Send advert "
row = Choice(lanes + "Announce this node " + "and then some " * 6, 1, hscroll_from=len(lanes))
screen = SelectScreen("editor", [row], hscroll=False)
before = _row_plains(screen, 40)[0]
screen.handle("right")
assert _row_plains(screen, 40)[0] == before
assert "←→" not in screen.footer_hint
screen.replace_items([row]) # A refresh must not turn it on again.
screen.handle("right")
assert _row_plains(screen, 40)[0] == before
def test_menu_rows_pin_their_label_lane_so_only_the_description_slides() -> None:
"""The shared builder for a label and a description gives each row its own head block."""
from meshterm.ui.menus import menu_rows
rows = menu_rows(
[("Sync clock…", "Set the device clock", 1), ("Reboot device…", "Restart the companion", 2)]
)
lane = rows[0].hscroll_from
assert lane and all(row.hscroll_from == lane for row in rows)
# The head block ends exactly where the descriptions start, on each row.
for row in rows:
assert row.label.plain[lane:].startswith(("Set the", "Restart the"))
def test_select_hscroll_hint_is_gated_on_the_run_not_the_whole_row() -> None:
"""A row whose run fits has no ←→ atom, for any width of its pinned head.
The hint can advertise only a key that does something (the rule of the footer). On a
row that pins a head block, the key moves only the run. Thus a long lane block in front
of a short tail is not an overflow. It is only a wide row.
"""
from meshterm.ui.tui.select import Choice, SelectScreen
lanes = "#1 ✓ replied min -6.0 dB 248 ms via "
fits = SelectScreen(
"probe", [Choice(lanes + "3d,f2", 1, hscroll_from=len(lanes))], hscroll=True
)
fits.render_body(60)
assert "←→ scroll" not in fits.footer_hint
overflows = SelectScreen(
"probe",
[Choice(lanes + ",".join(["3d"] * 20), 1, hscroll_from=len(lanes))],
hscroll=True,
)
overflows.render_body(60)
assert "←→ scroll" in overflows.footer_hint
def test_select_hscroll_marks_both_edges_the_run_continues_past() -> None:
"""A scrolled row has a crack or an ellipsis at each side on which its run continues."""
from meshterm.ui.pathline import _ELLIPSIS
from meshterm.ui.tui.select import Choice, SelectScreen
screen = SelectScreen("long", [Choice("z" * 200, 1)], hscroll=True)
screen.handle("right")
row = _row_plains(screen, 40)[0]
# Plain prose has no chip fill to shear, so both marks are the ellipsis.
assert row.startswith("❯ " + _ELLIPSIS) and row.endswith(_ELLIPSIS)
def test_select_without_hscroll_ignores_left_right() -> None:
"""The flag is off by default. ←/→ do nothing, and rows render as before."""
from meshterm.ui.tui.select import Choice, SelectScreen
screen = SelectScreen("plain", [Choice("row", 1)])
before = screen.render_body(40)
screen.handle("right")
screen.handle("left")
assert screen.render_body(40) == before and screen._hshift == 0
# --- row detail lines (opt-in) -------------------------------------------------------
def test_select_choice_detail_hangs_under_its_row() -> None:
"""A Choice.detail draws as a second line with an indent, directly under its title."""
from meshterm.ui.tui.select import Choice, SelectScreen
screen = SelectScreen(
"pick", [Choice("first row", 1, detail="weakest -6.0 dB · 3×"), Choice("second", 2)]
)
lines = _row_plains(screen, 40)
idx = next(i for i, ln in enumerate(lines) if "first row" in ln)
assert lines[idx + 1].startswith(" weakest -6.0 dB")
assert "second" in lines[idx + 2]
def test_select_choice_without_detail_draws_one_line() -> None:
"""A Choice with no detail (the default, or a detail that resolves empty) stays one line."""
from meshterm.ui.tui.select import Choice, SelectScreen
screen = SelectScreen("pick", [Choice("first row", 1), Choice("second", 2, detail="")])
lines = _row_plains(screen, 40)
assert lines[1].strip() == "second"
def test_select_cursor_tracks_the_highlight_past_a_detail_line() -> None:
"""The highlight is on the title line, also when an earlier row has a detail line."""
from meshterm.ui.tui.select import Choice, SelectScreen
screen = SelectScreen(
"pick",
[Choice("first row", 1, detail="has detail"), Choice("second", 2)],
default=2,
)
lines = _row_plains(screen, 40)
assert screen.cursor_line() == next(i for i, ln in enumerate(lines) if "second" in ln)
def test_select_hscroll_leaves_the_detail_line_unshifted() -> None:
"""←→ slide only the title of the highlighted row. Its detail line never scrolls."""
from meshterm.ui.tui.select import Choice, SelectScreen
long_title = "row-one-" + "x" * 60 + "-tail"
screen = SelectScreen("pick", [Choice(long_title, 1, detail="weakest -6.0 dB")], hscroll=True)
screen.handle("right")
lines = _row_plains(screen, 40)
assert "weakest -6.0 dB" in lines[1]
# --- the fast path's frame source ----------------------------------------------------
def _framed_session():
"""A session with enough of an app behind it to compose a frame at 53x26."""
import types
from prompt_toolkit.data_structures import Size
session = TuiSession()
session._app = types.SimpleNamespace(
renderer=types.SimpleNamespace(_last_screen=None),
output=types.SimpleNamespace(get_size=lambda: Size(rows=26, columns=53)),
invalidate=lambda: None,
)
return session
def test_a_dialog_frame_is_composed_here_not_handed_to_prompt_toolkit() -> None:
"""A float once sent the whole frame to the renderer of prompt_toolkit.
This caused a full rebuild of the grid, and a diff, on each key press. It happened in
each confirm, picker, and viewer in the app. The placement of the float is
reproducible (a float with no anchor and no size is centred). Thus the frame source
returns the finished picture with the box merged in. The diff of the rows applies to
dialogs, as it does to the other screens.
"""
session = _framed_session()
session.push(ScrollScreen(Text("the list beneath"), title="Nodes", floating=False))
session.push(ButtonDialog("Remove this contact?", [("Cancel", 0), ("Remove", 1)]))
text = session._plain_frame()
assert text is not None, "a dialog must not fall back to prompt_toolkit"
rows = text.split("\n")
assert len(rows) == 26
body = "\n".join(rows)
assert "Remove this contact?" in _plain(body)
assert "the list beneath" in _plain(body), "the backdrop must show around the box"
def test_a_bare_base_paints_no_chrome_at_all() -> None:
"""The session draws a bare base through compose_bare, with no bars, no lane, and no box."""
screen = ScrollScreen(Text("CODE", justify="center"), title="Share x", floating=False)
screen.bare = True
session = _framed_session()
session.push(screen)
text = session._plain_frame()
assert text is not None
rows = text.split("\n")
assert len(rows) == 26
plain = _plain(text)
assert "CODE" in plain
for chrome in ("MeshTerm", "Share x", "Esc", "F1", "─", "│"):
assert chrome not in plain, f"a bare frame drew {chrome!r}"
def test_the_busy_overlay_is_still_prompt_toolkits_to_place() -> None:
"""This is the one float that MeshTerm does not place.
It is a window that has the size of its content, and the float container measures it.
"""
from meshterm.ui.tui.overlay import BusyOverlay
session = _framed_session()
assert session._plain_frame() is None # An empty stack has no background either.
overlay = BusyOverlay(title="Starting up")
overlay.started_at -= overlay.hold + overlay.fade # This is past the hold, so it shows.
session._overlay = overlay
assert session._overlay_visible()
assert session._plain_frame() is None
def test_the_rows_a_dialog_does_not_reach_come_back_unchanged() -> None:
"""The rows that a dialog does not cover do not change, so the diff skips them.
This is the reason to composite the frame.
"""
session = _framed_session()
session.push(
ScrollScreen(Text("\n".join(f"line {i}" for i in range(40))), title="Nodes", floating=False)
)
first = session._plain_frame().split("\n")
session.push(ButtonDialog("Sure?", [("Cancel", 0), ("Yes", 1)]))
second = session._plain_frame().split("\n")
unchanged = sum(1 for a, b in zip(first, second, strict=True) if a == b)
assert unchanged >= 10, "the box should only rewrite the rows it covers"
# --- the body is drawn one viewport at a time ----------------------------------------
def test_a_long_list_only_rasterizes_the_rows_the_viewport_shows() -> None:
"""A list of 141 contacts that is 150 rows tall shows only twenty of them.
The render of the other rows was a waste, on each key press and on the tick that comes
one time each second. The line count must stay exact. The scroll clamp, the ``↑↓ more``
markers, and the offsets of the sticky headers use it.
"""
from meshterm.ui.tui.screen import LazyLines
drawn: list[int] = []
def title(i: int):
def build():
drawn.append(i)
return f"contact {i:03d}"
return build
screen = SelectScreen("Contacts", [Choice(title(i), i) for i in range(150)])
lines = screen.render_body(53)
assert isinstance(lines, LazyLines)
assert len(lines) == 150 # The count is exact, and it costs nothing.
assert drawn == [], "no row is rasterized until something reads it"
window = lines[0:20]
assert len(window) == 20
assert sorted(drawn) == list(range(20))
assert "contact 000" in _plain(window[0])
lines[5] # A second read uses the memo and never renders again.
assert sorted(drawn) == list(range(20))
def test_a_lazy_body_slices_frames_and_scrolls_exactly_as_a_list_did() -> None:
"""The frame must not see the substitution: it has the same height and the same clip flags.
If MeshTerm defers the drawing of a row, it must never defer the count of the rows.
The scroll clamp, the ``↑↓ more`` subtitle, and the pinned heading use that count.
If the render did not build the tail, each of them would be wrong.
"""
items = [section_heading("Group")]
items += [Choice(f"row {i}", i) for i in range(60)]
screen = SelectScreen("Long", items, footer_hint="Esc back")
screen.floating = False
out = frame.compose_base(Text("hdr"), screen, "Esc back", 53, 26)
assert len(out.split("\n")) == 26
plain = _plain(out)
assert "row 0" in plain and "↓ more" in plain
assert screen._scroll_total == 61 # The screen still measures the whole body.
for _ in range(45): # Walk the highlight past the fold.
screen.handle("down")
plain = _plain(frame.compose_base(Text("hdr"), screen, "Esc back", 53, 26))
assert "row 45" in plain, "the highlight must still be kept in view"
assert "row 0 " not in plain, "the top of the list should have scrolled away"
assert "── Group ──" in plain, "the section heading pins once it scrolls off"
def test_a_blank_line_comes_before_each_section_where_the_rows_allow(monkeypatch) -> None: # noqa: ANN001
"""A terminal of 24 rows or more puts a blank line before each section heading.
The line separates a section from a row or a note above it. It never comes at the top,
after a blank separator, or under another heading, because there it separates nothing.
The heading pins at its own line, not at the blank line. The frame records the rows
before the body renders, so the first paint is already correct, and a terminal of 14
rows (the Cardputer Zero) gets no line. The ``section_spacing_min_rows`` preference
moves the limit, and a change applies at the next paint.
"""
from meshterm.core import preferences
prefs = preferences.Preferences()
monkeypatch.setattr(preferences, "current", lambda: prefs)
items = [
section_heading("A"),
Choice("alpha", 1),
Separator(" a note"),
section_heading("B"),
Separator(" "),
section_heading("C"),
Choice("gamma", 3),
section_heading("D"),
section_heading("E"),
Choice("epsilon", 5),
]
screen = SelectScreen("Sections", items)
screen.floating = False
def body(rows: int) -> list[str]:
frame.compose_base(Text("hdr"), screen, "Esc back", 53, rows)
return [_plain(line).strip() for line in screen.render_body(53)]
assert body(26) == [
"── A ──",
"❯ alpha",
"a note",
"",
"── B ──",
"",
"── C ──",
"gamma",
"",
"── D ──",
"── E ──",
"epsilon",
]
assert [at for at, _ in screen._sticky_headers] == [0, 4, 6, 9, 10]
assert body(14).count("") == 1 # only the blank separator of the list itself
prefs.set("section_spacing_min_rows", 0) # any height
assert body(14).count("") == 3
prefs.set("section_spacing_min_rows", 10_000) # never
assert body(26).count("") == 1
def test_an_off_screen_rows_live_title_is_left_alone() -> None:
"""MeshTerm resolves a callable title of a row only while the row is on the screen."""
calls: list[str] = []
items = [
Choice(lambda: (calls.append("top"), "top row")[1], 0),
*[Choice(f"filler {i}", i) for i in range(1, 40)],
Choice(lambda: (calls.append("bottom"), "bottom row")[1], 40),
]
screen = SelectScreen("Live", items)
screen.floating = False
lines = screen.render_body(53)
lines[0:10]
assert calls == ["top"]
def _button_dialog_hints() -> list[tuple[str, int, int | None, str]]:
"""Each literal ``footer_hint=`` on a button dialog, as (file, line, buttons, hint).
``buttons`` is the count when the call passes a list literal. Otherwise it is ``None``.
A computed row can have any number of buttons. For example, the quit confirm has a
third button on a bonded device. Thus the test applies the rule for many buttons to it.
"""
import ast
from pathlib import Path
found: list[tuple[str, int, int | None, str]] = []
root = Path(__file__).resolve().parent.parent / "meshterm"
for path in sorted(root.rglob("*.py")):
tree = ast.parse(path.read_text(encoding="utf-8"))
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
func = node.func
name = func.attr if isinstance(func, ast.Attribute) else getattr(func, "id", "")
if name not in ("button_dialog", "ButtonDialog"):
continue
hint = next(
(
k.value.value
for k in node.keywords
if k.arg == "footer_hint" and isinstance(k.value, ast.Constant)
),
None,
)
if hint is None:
continue # This is the default hint, which is the generic hint.
row = node.args[1] if len(node.args) > 1 else None
count = len(row.elts) if isinstance(row, ast.List) else None
found.append((path.name, node.lineno, count, hint))
return found
def test_a_button_dialogs_hint_never_names_the_verb_of_one_button() -> None:
"""Enter commits the highlighted button, so a row of buttons can say only ``select``.
The quit confirm once had the hint ``Enter quit · Esc cancel``. This was true only
while the Quit button was highlighted. ←→ move the highlight, and on a bonded device a
third button is between the two (JP, 2026-08-31). The same hint also did not name the
key that changes what Enter does. A list from which the user selects a row can name
its committing verb (``Enter adopt path``), because each row commits in the same way.
A row of buttons cannot do this.
A dialog that has one button is the exception for which the rule is made. A lone
acknowledgement has nothing to choose between. Thus it names its verb (``Enter OK``),
and it does not advertise ←→, which would move nothing.
"""
for where, line, buttons, hint in _button_dialog_hints():
if buttons == 1:
assert "←→" not in hint, f"{where}:{line} offers ←→ over a single button"
continue
assert "Enter select" in hint, (
f"{where}:{line} promises {hint!r} — Enter commits whichever button is "
"highlighted, so the atom stays 'Enter select'"
)
assert "←→" in hint, (
f"{where}:{line} hides ←→, the key that moves the highlight Enter commits"
)
# --- hiding devices on the splash --------------------------------------------
def _hide_store(tmp_path): # noqa: ANN001, ANN202
"""A device registry in a scratch file."""
from meshterm.core.device_store import DeviceStore
return DeviceStore(tmp_path / "devices.json")
def test_hidden_devices_survive_the_session_that_hid_them(tmp_path) -> None: # noqa: ANN001
"""MeshTerm writes the hidden state to the registry file, so a new store still has it."""
store = _hide_store(tmp_path)
assert store.hidden_ids() == set()
store.hide("usb-0001")
store.hide("usb-0002")
store.hide("usb-0001") # A second call has the same result.
assert _hide_store(tmp_path).hidden_ids() == {"usb-0001", "usb-0002"}
assert _hide_store(tmp_path).show_all() == 2
assert _hide_store(tmp_path).hidden_ids() == set()
assert _hide_store(tmp_path).show_all() == 0 # Nothing is left to show again.
def test_hiding_leaves_the_registry_and_its_default_alone(tmp_path) -> None: # noqa: ANN001
"""A hidden device is still remembered, still the default, and the user can reach it by name.
To hide a device is a choice about the list on one screen. Each code path that finds a
device without the splash (``--port``, a profile, the reconnect) must not see the
difference.
"""
from meshterm.core.discovery import serial_device
store = _hide_store(tmp_path)
device = serial_device("COM7", name="A Radio")
store.remember(device, node_name="Wardriver")
store.hide(device.stable_id)
fresh = _hide_store(tmp_path)
assert fresh.hidden_ids() == {device.stable_id}
assert fresh.is_known(device)
remembered = fresh.load()
assert remembered is not None and remembered.node_name == "Wardriver"
def test_connecting_to_a_hidden_device_shows_it_again(tmp_path) -> None: # noqa: ANN001
"""If the user confirms a device, this shows that the device belongs on the list."""
from meshterm.core.discovery import serial_device
store = _hide_store(tmp_path)
device = serial_device("COM7", name="A Radio")
store.hide(device.stable_id)
store.remember(device, node_name="Wardriver")
assert store.hidden_ids() == set()
async def test_the_splash_hides_the_highlighted_device_and_redraws(tmp_path) -> None: # noqa: ANN001
"""The h key removes the row and remembers the device. The new list is the feedback."""
from meshterm.core.discovery import serial_device
from meshterm.ui.device_picker import prompt_device
from meshterm.ui.tui import Choice, KeyRequest
probe = serial_device("COM3", name="A Debug Probe")
radio = serial_device("COM7", name="A Radio")
store = _hide_store(tmp_path)
drawn: list[list] = []
class _Ui:
"""Presses h on the probe, then selects the device that is left."""
def __init__(self) -> None:
self.round = 0
async def select_startup(self, title, items, **kw): # noqa: ANN001, ANN003, ANN201
drawn.append([it for it in items if isinstance(it, Choice)])
self.round += 1
if self.round == 1:
return KeyRequest(kw["keys"]["h"], probe)
return radio
async def busy_startup(self, message, coro, **kw): # noqa: ANN001, ANN003, ANN201
return await coro
async def verify(device, pin): # noqa: ANN001, ANN202
return {"name": "Wardriver"}
chosen = await prompt_device(_Ui(), [probe, radio], store, verify)
assert chosen is radio
assert store.hidden_ids() == {probe.stable_id}
# The first pass offered both devices. The second pass offered only the device that is left.
assert probe in [row.value for row in drawn[0]]
assert probe not in [row.value for row in drawn[1]]
def test_the_splash_says_so_when_it_is_empty_only_because_of_hiding() -> None:
"""The splash says why it is empty, if the only reason is the hidden devices.
"Nothing detected" would be false, and with no rows there is no footer atom either.
"""
from meshterm.core.discovery import serial_device
from meshterm.ui.device_picker import _build_items
lines = [str(it.title) for it in _build_items([], None, {}, hidden=2)]
assert any("2 devices hidden" in line and "⇧H" in line for line in lines)
detected = [str(it.title) for it in _build_items([], None, {}, hidden=0)]
assert any("no companion devices detected" in line for line in detected)
# If the list still has a device, the footer names ⇧H on each row. Then the line would
# use a row of the box to say it two times.
radio = serial_device("COM7", name="A Radio")
rows = [str(it.title) for it in _build_items([radio], None, {}, hidden=2)]
assert not any("hidden" in line for line in rows)
def test_the_splash_action_rows_share_one_icon_column() -> None:
"""The words of "Add a network device" and "Quit" start in the same cell, on both platforms.
The test measures the cell. The rows were once the literal strings ``" 🌐 Add…"`` and
``" 🚪 Quit"``. They lined up only because the two emoji have the same width. They also
kept their icons on the PicoCalc, where each other command row removes its icon lane.
"""
from rich.cells import cell_len
from meshterm.platforms import PICOCALC_LYRA, REGULAR, set_platform
from meshterm.ui.device_picker import _ADD_TCP, _QUIT, _action_rows
from meshterm.ui.tui import Choice
def starts() -> dict:
rows = {it.value: it.title.plain for it in _action_rows() if isinstance(it, Choice)}
words = {_ADD_TCP: "Add a network device…", _QUIT: "Quit"}
return {
value: cell_len(rows[value][: rows[value].index(word)]) for value, word in words.items()
}
assert len(set(starts().values())) == 1
set_platform(PICOCALC_LYRA)
try:
assert set(starts().values()) == {2}, "the icons go, and no padding stays behind"
finally:
set_platform(REGULAR)
def test_hiding_a_device_leaves_the_highlight_where_the_row_was() -> None:
"""The highlight goes to the next device down, or to the device above at the end.
Thus a run of adapters clears in one run.
"""
from meshterm.core.discovery import serial_device
from meshterm.ui.device_picker import _after_hiding
first, middle, last = (serial_device(f"COM{n}", name=f"D{n}") for n in (1, 2, 3))
listed = [first, middle, last]
assert _after_hiding(listed, first) is middle
assert _after_hiding(listed, middle) is last
# At the end there is no place further down, so the highlight steps up. It does not roll
# round to the top, because nothing in the app rolls over.
assert _after_hiding(listed, last) is middle
assert _after_hiding([first], first) is None # A list that is now empty has no row to select.
def test_the_splash_names_a_shortcut_only_where_it_would_act() -> None:
"""The hint names a shortcut only where it acts. This is the rule of Del remove.
The h key is named on a device row only. ⇧H is named only while a device is hidden.
"""
from meshterm.core.discovery import serial_device
from meshterm.ui.device_picker import _ADD_TCP, _QUIT, _shortcut_hint
radio = serial_device("COM7", name="A Radio")
nothing_hidden = _shortcut_hint(0)
# The hint does not offer a way back, because no device is hidden.
assert nothing_hidden(radio) == "h hide"
assert nothing_hidden(_ADD_TCP) == "" # There is nothing to hide on the action rows.
assert nothing_hidden(_QUIT) == ""
assert nothing_hidden(None) == "" # An empty list has no highlight.
with_hidden = _shortcut_hint(2)
assert with_hidden(radio) == "h hide · ⇧H show all"
# ⇧H acts on the whole screen, so the hint shows it on each row that has the highlight.
assert with_hidden(_QUIT) == "⇧H show all"
def test_a_hint_too_long_for_its_box_drops_atoms_rather_than_its_tail() -> None:
"""A hint that is too long for its box drops atoms. It does not drop its tail.
A cut at the edge would remove Esc, and Esc is the one atom that must stay.
"""
from meshterm.ui.tui.frame import fit_hint
full = "↑↓ move · ←→ scroll · Enter select · h hide · ⇧H show all · Esc quit"
assert fit_hint(full, 100) == full # There is room for everything, so nothing changes.
# The function drops atoms from the right, in front of Esc. It never drops Esc.
assert fit_hint(full, 56) == "↑↓ move · ←→ scroll · Enter select · h hide · Esc quit"
assert fit_hint(full, 45) == "↑↓ move · ←→ scroll · Enter select · Esc quit"
assert fit_hint(full, 20).endswith("Esc quit")
# The exception: the caller names the atoms that it can spare first, in the order in
# which it can spare them. The move atom already names the scroll keys. Of the two hide
# keys, the key to keep is the way back.
spared = fit_hint(full, 56, shed_first=("←→ scroll", "h hide"))
assert spared == "↑↓ move · Enter select · h hide · ⇧H show all · Esc quit"
assert (
fit_hint(full, 50, shed_first=("←→ scroll", "h hide"))
== "↑↓ move · Enter select · ⇧H show all · Esc quit"
)
def test_a_panning_list_slides_its_header_and_rows_as_one() -> None:
"""←→ pan a table. The header and each row move by one shift, and the shift stays.
The shift stays when the highlight moves. The scroll for one row slides the
highlighted row alone, and it drops its shift on ↑↓. This is correct for a list of
long lines that are independent. It is wrong for a table. The lanes of a table have
no meaning when they slide 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 has the same layout as 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 the hint names ←→.
screen.handle("right")
screen.handle("right")
panned = lines()
assert panned[0] != first[0] and panned[1] != first[1] and panned[2] != first[2]
# Each line of the table moved by the same amount. The run of dashes in the header
# starts in the same column as the runs of x and y 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()
# The pan clamps at the tail of the widest line, 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 header and the device rows of the splash pan together, and its action rows stay."""
from meshterm.core.device_store import RememberedDevice
from meshterm.core.discovery import serial_device
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, Separator
radio = serial_device("COM7", name="Wardriver")
registry = {
radio.stable_id: RememberedDevice(
stable_id=radio.stable_id,
port="COM7",
label="COM7",
last_connected="2026-09-01T00:00:00+00:00",
node_name="Wardriver",
hardware_model="Seeed Wio Tracker 1110 Development Kit",
)
}
try:
for platform in (REGULAR, PICOCALC_LYRA, CARDPUTER_ZERO):
set_platform(platform)
items = _build_items([radio], registry[radio.stable_id], registry)
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"
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)).split("\n")
assert "Wardriver" in before[1] and "Wardriver" not in after[1], platform.name
# After a pan to the end, the address is under its label.
assert after[0].index("PORT") == after[1].index("COM7"), platform.name
finally:
set_platform(REGULAR)
def test_the_splash_hint_stays_inside_the_box_it_is_drawn_in() -> None:
"""The splash that has no chrome keeps its hint in its own border, on both platforms.
The width of the border is the width of the terminal minus the gutter over which the
box floats. It is not the full readable width. On the PicoCalc it is 47 cells. Thus
each atom here must be conditional, and this is more than tidiness.
"""
from rich.cells import cell_len
from meshterm.core.discovery import serial_device
from meshterm.platforms import PICOCALC_LYRA, REGULAR
from meshterm.ui.device_picker import _shortcut_hint
from meshterm.ui.tui.select import splice_hint
base = "↑↓ move · Enter select · Esc bye" # This is the splash's own farewell.
radio = serial_device("COM7", name="A Radio")
common = splice_hint(base, _shortcut_hint(0)(radio))
for platform in (REGULAR, PICOCALC_LYRA):
budget = platform.readable_cols - platform.dialog_margin - 2
assert cell_len(common) <= budget, (platform.name, common)
def test_a_shortcut_is_only_honoured_where_a_letter_is_free() -> None:
"""A list that filters uses its letters for the query, so it declares no shortcuts.
If it did, the user could not type a device named "Homestead" on a list that claimed h.
"""
from meshterm.ui.tui import Choice, KeyRequest
from meshterm.ui.tui.select import SelectScreen
token = object()
fixed = SelectScreen("fixed", [Choice("a row", 1)], filterable=False, keys={"h": token})
fixed.future = None
resolved: list = []
fixed.resolve = lambda value: resolved.append(value) # type: ignore[method-assign]
fixed.handle("text", "h")
assert resolved == [KeyRequest(token, 1)]
filtering = SelectScreen("filtering", [Choice("Homestead", 1)], keys={"h": token})
filtering.resolve = lambda value: resolved.append(value) # type: ignore[method-assign]
filtering.handle("text", "h")
assert len(resolved) == 1 # The list resolved nothing new.
assert filtering._filter == "h" # The letter went to the query, where it must go.
# -- the splash keeps looking ---------------------------------------------------------
def _drive_picker(tmp_path, monkeypatch, *, serial_rounds, ble_rounds=None, hide=None):
"""Run the rescan of the picker against a scripted sequence of scans.
The fake UI takes the place of the open splash. It gets the ``live`` callable and runs
it until the script ends, and it records each paint. The test replaces discovery. It
does not use a mock at the port level, because the test examines the behaviour of the
rescan (when it paints, and when it stays quiet) and not pyserial.
"""
import asyncio
from meshterm.core.device_store import DeviceStore
from meshterm.ui import device_picker
monkeypatch.setattr(device_picker, "_POLL_S", 0.001)
monkeypatch.setattr(device_picker, "_BLE_EVERY_S", 0.0025)
monkeypatch.setattr(device_picker, "_BLE_WINDOW_S", 0.0)
serial = list(serial_rounds)
ble = list(ble_rounds or [[]])
calls = {"serial": 0, "ble": 0}
def _serial():
calls["serial"] += 1
return list(serial[min(calls["serial"] - 1, len(serial) - 1)])
async def _ble(_timeout):
calls["ble"] += 1
return list(ble[min(calls["ble"] - 1, len(ble) - 1)])
monkeypatch.setattr(device_picker, "discover_devices", _serial)
monkeypatch.setattr(device_picker, "discover_ble_devices", _ble)
store = DeviceStore(tmp_path / "devices.json")
for stable in hide or []:
store.hide(stable)
redraws: list = []
# The number of scans to run: each scripted round, and enough more scans that the
# paint of the last round has happened (a round paints before the next scan starts).
# Also, the Bluetooth duty cycle (one listen for each few polls) must have room to show.
scans = len(serial) + 5
class _Ui:
async def select_startup( # noqa: ANN001, ANN201, ANN003
self, title, items, *, default=None, banner=None, footnote=None, live=None, **_kw
):
task = asyncio.ensure_future(live(redraws.append))
# Wait for the scans, and not for a span of time. A fixed time of 50 ms gave a few
# rounds on a quiet machine. Under a full run it sometimes gave only one round,
# because Windows rounds each sleep of 1 ms up to its timer tick of approximately
# 15 ms.
loop = asyncio.get_running_loop()
deadline = loop.time() + 10.0
while calls["serial"] < scans and loop.time() < deadline:
await asyncio.sleep(0.001)
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
return None
async def _never(_device):
raise AssertionError("verify should not run when selection is skipped")
asyncio.run(device_picker.prompt_device(_Ui(), list(serial[0]), store, _never))
assert calls["serial"] >= scans, f"the rescan ran {calls['serial']} of {scans} scans in 10 s"
return redraws, calls
def _labels(items) -> list[str]:
return [
(it.title.plain if hasattr(it.title, "plain") else str(it.title))
for it in items
if isinstance(it, Choice)
]
def test_splash_redraws_when_a_device_is_plugged_in(tmp_path, monkeypatch) -> None:
"""A radio that the user attaches after the splash opened appears. MeshTerm need not restart."""
from meshterm.core.discovery import DiscoveredDevice
first = DiscoveredDevice(port="COM5", product="Wio SX1262", vid=0x2886)
later = DiscoveredDevice(port="COM9", product="RAK4631", vid=0x239A)
redraws, _ = _drive_picker(tmp_path, monkeypatch, serial_rounds=[[first], [first, later]])
assert redraws, "the splash never redrew after the device appeared"
assert any("COM9" in label for label in _labels(redraws[-1]))
def test_splash_stays_quiet_when_nothing_changed(tmp_path, monkeypatch) -> None:
"""A poll that finds the same ports paints nothing.
This half is important for a user who is in the middle of a move down the list with
the arrows. A paint that the user did not ask for, on a list that did not change, makes
the screen move under the hands of the user for no reason.
"""
from meshterm.core.discovery import DiscoveredDevice
same = [DiscoveredDevice(port="COM5", product="Wio SX1262", vid=0x2886)]
redraws, calls = _drive_picker(tmp_path, monkeypatch, serial_rounds=[same])
assert calls["serial"] > 1, "the rescan never ran"
assert redraws == []
def test_splash_notices_a_device_going_away(tmp_path, monkeypatch) -> None:
"""An unplug removes the row. This is the same question in the other direction."""
from meshterm.core.discovery import DiscoveredDevice
one = DiscoveredDevice(port="COM5", product="Wio SX1262", vid=0x2886)
redraws, _ = _drive_picker(tmp_path, monkeypatch, serial_rounds=[[one], []])
assert redraws, "the splash never redrew after the device went away"
assert not any("COM5" in label for label in _labels(redraws[-1]))
def test_a_rescan_does_not_unhide_what_the_reader_hid(tmp_path, monkeypatch) -> None:
"""A refresh is not ⇧H. A device that `h` hid stays hidden when MeshTerm rebuilds the list."""
from meshterm.core.discovery import DiscoveredDevice
kept = DiscoveredDevice(port="COM5", product="Wio SX1262", vid=0x2886)
buried = DiscoveredDevice(port="COM7", product="FT232R USB UART", vid=0x0403)
later = DiscoveredDevice(port="COM9", product="RAK4631", vid=0x239A)
redraws, _ = _drive_picker(
tmp_path,
monkeypatch,
serial_rounds=[[kept, buried], [kept, buried, later]],
hide=[buried.stable_id],
)
assert redraws, "the splash never redrew"
labels = _labels(redraws[-1])
assert any("COM9" in label for label in labels)
assert not any("COM7" in label for label in labels)
def test_bluetooth_is_not_scanned_on_every_poll(tmp_path, monkeypatch) -> None:
"""Serial is cheap and MeshTerm polls it. Bluetooth is a listen, and it gets a duty cycle.
Each BLE scan keeps the radio in the listen state. A user can leave this screen open
on a handheld that uses a battery, while the user goes to find a cable. Thus the BLE
scan must not be in the poll loop.
"""
from meshterm.core.discovery import DiscoveredDevice
same = [DiscoveredDevice(port="COM5", product="Wio SX1262", vid=0x2886)]
_, calls = _drive_picker(tmp_path, monkeypatch, serial_rounds=[same])
assert calls["serial"] > calls["ble"], (
f"bluetooth scanned {calls['ble']} time(s) against {calls['serial']} serial poll(s)"
)
def test_the_picker_only_passes_arguments_the_splash_surface_accepts(tmp_path) -> None:
"""Each keyword that `prompt_device` sends must exist on `Ui.select_startup`.
A test double hides this type of bug. `prompt_device` calls the surface, and the
surface delegates to the session. If a developer adds an argument to the session only,
the startup path crashes and nothing else does. This is because each fake Ui in this
file takes unknown keywords with `**kwargs`, and it does not fail. Thus the fake here
binds the arguments that it gets against the real signature. A keyword that the surface
does not have raises an error in the same place as in a running MeshTerm.
"""
import inspect
from meshterm.core.device_store import DeviceStore
from meshterm.ui.device_picker import prompt_device
from meshterm.ui.surface import Ui
signature = inspect.signature(Ui.select_startup)
class _Ui:
async def select_startup(self, title, items, **kw): # noqa: ANN001, ANN003, ANN201
# This raises a TypeError for an unknown keyword.
signature.bind(self, title, items, **kw)
return None
async def _never(_device):
raise AssertionError("verify should not run when selection is skipped")
store = DeviceStore(tmp_path / "devices.json")
asyncio.run(prompt_device(_Ui(), [], store, _never))