From 3fccfd7971c8c4be72fc1747029ec16a2a953182 Mon Sep 17 00:00:00 2001 From: Jean-Pierre Martineau Date: Sun, 27 Sep 2026 23:41:53 -0400 Subject: [PATCH] The Contacts title counts the archived too, abbreviating where it's narrow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Contacts · 198 known + 123 archived", falling back to "Contacts · 198 + 123" where the frame's rule can't hold the words. The fallback is a new Screen.short_title the frame picks through fitted_title, on both the bordered panel and the PicoCalc title bar. Co-Authored-By: Claude Opus 5.5 (1M context) --- meshterm/ui/contacts_screen.py | 18 ++++++++++++++---- meshterm/ui/tui/frame.py | 32 +++++++++++++++++++++++++++----- meshterm/ui/tui/screen.py | 6 ++++++ tests/test_device_config.py | 26 ++++++++++++++++++++++++++ 4 files changed, 73 insertions(+), 9 deletions(-) diff --git a/meshterm/ui/contacts_screen.py b/meshterm/ui/contacts_screen.py index 3028d0a..4255ff4 100644 --- a/meshterm/ui/contacts_screen.py +++ b/meshterm/ui/contacts_screen.py @@ -98,9 +98,10 @@ class ContactsScreen(ContactListScreen): span :data:`~meshterm.ui.contactlist.SORT_COLUMNS` so the hash sort is reachable. archived: How many contacts are archived off this device. Non-zero adds the - ``View archived contacts`` row and shows the count on it, so the tally is - visible without opening the list; zero draws no row, since a screen should - not offer a way into an empty list. + ``View archived contacts`` row and shows the count on it and in the title + (``Contacts · 198 known + 123 archived``), so the tally is visible without + opening the list; zero draws no row and leaves the title at ``… known``, + since a screen should not offer a way into an empty list. locked: The full keys (lowercase hex) of the contacts locked against archiving; each of those rows closes its name lane on a padlock. """ @@ -132,13 +133,22 @@ class ContactsScreen(ContactListScreen): label = marked_label("📂", "View archived contacts", "", lane=lane) label.append(f" · {archived}", style="muted") tail.append(Choice(title=label, value=_ARCHIVED)) + # The title counts both halves of what this device's contacts amount to: those on it + # and those a sweep moved off it. Where the frame is too narrow for the words, the + # figures stay and the words go (``Contacts · 198 + 123``). + known = len(contacts) + title = f"Contacts · {known} known" + if archived: + title += f" + {archived} archived" super().__init__( - f"Contacts · {len(contacts)} known", + title, rows=rows, prefix_bytes=prefix_bytes, sort=sort, tail=tail, ) + if archived: + self.short_title = f"Contacts · {known} + {archived}" def _contact_rows_for(self, locked: frozenset[str]) -> list[ContactRow]: """Our own node, then every contact, each marked locked or not by ``locked``.""" diff --git a/meshterm/ui/tui/frame.py b/meshterm/ui/tui/frame.py index 0918d99..2c51368 100644 --- a/meshterm/ui/tui/frame.py +++ b/meshterm/ui/tui/frame.py @@ -220,6 +220,23 @@ _ESC_ATOM = re.compile(r"(?:^|·\s*)(Esc\s+\S+)\s*$") _MIN_RULE = 2 +def fitted_title(screen: Screen, room: int) -> str: + """The heading to draw in ``room`` cells: the full title, or its short form where it won't fit. + + A title that states figures (``Contacts · 198 known + 123 archived``) would otherwise be + cut by the rule it sits in, and the cut takes the end — the last figure. A screen that + declares a :attr:`~meshterm.ui.tui.screen.Screen.short_title` gets that instead, the same + atoms said tighter; one with none keeps its title and the frame's usual clipping. + + Args: + screen: The screen whose heading is drawn. + room: Cells the heading may take, not counting the space either side of it. + """ + if screen.short_title and cell_len(screen.title) > room: + return screen.short_title + return screen.title + + def _esc_hint(hint: str) -> str: """The ``Esc …`` atom a screen's footer hint ends on, or ``""`` where it has none.""" found = _ESC_ATOM.search(hint or "") @@ -264,7 +281,10 @@ def _title_bar(screen: Screen, cols: int, more_above: bool, more_below: bool) -> """ border = "accent" head_span = 3 # the arrow pair, plus the space parting it from the rule - label_w = cell_len(screen.title) + 2 if screen.title else 0 # a space either side + # The short form is chosen against the bar with no Esc tail at all: the title is what + # the reader came for, so the tail gives way to it before it gives way to the tail. + title = fitted_title(screen, cols - head_span - 2 - 2 * _MIN_RULE) + label_w = cell_len(title) + 2 if title else 0 # a space either side atom = _esc_hint(screen.footer_hint) for esc in (atom, "Esc" if atom else "", ""): tail_span = cell_len(esc) + 1 if esc else 0 # the space in front of the tail @@ -276,12 +296,12 @@ def _title_bar(screen: Screen, cols: int, more_above: bool, more_below: bool) -> bar.append("↑", style=border if more_above else "muted") bar.append("↓", style=border if more_below else "muted") bar.append(" ") - if screen.title: + if title: left = max(1, (rule_span - label_w) // 2) right = max(1, rule_span - label_w - left) bar.append("─" * left, style=border) bar.append(" ", style=None) - bar.append(screen.title, style=title_style(border)) + bar.append(title, style=title_style(border)) bar.append(" ", style=None) bar.append("─" * right, style=border) else: @@ -347,11 +367,13 @@ def compose_base( # a keystroke that need not change a single body line, and a memo without it would # keep serving the rule the reader has already grown out of. caption = base.bottom_caption - key = (cols, rows, base.title, caption, footer_hint, more_above, more_below, *visible) + # Rich sets the padded title into the rule's inner ``cols - 4`` cells. + title = fitted_title(base, cols - 4 - 2) + key = (cols, rows, title, caption, footer_hint, more_above, more_below, *visible) if footer_lane is None and _BASE_BOX_CACHE is not None and _BASE_BOX_CACHE[0] == key: body = _BASE_BOX_CACHE[1] else: - panel = _panel_box(base.title, visible, more_above, more_below, "accent", caption) + panel = _panel_box(title, visible, more_above, more_below, "accent", caption) body = render_lines(Group(panel, footer_row()), cols) if footer_lane is None: _BASE_BOX_CACHE = (key, body) diff --git a/meshterm/ui/tui/screen.py b/meshterm/ui/tui/screen.py index b82ba02..32e892e 100644 --- a/meshterm/ui/tui/screen.py +++ b/meshterm/ui/tui/screen.py @@ -60,6 +60,11 @@ class Screen: Attributes: title: Heading shown at the top of the screen/dialog. + short_title: The abbreviated heading the frame draws instead of :attr:`title` where + the full one would not fit the rule it sits in (see + :func:`~meshterm.ui.tui.frame.fitted_title`) — the same atoms said tighter, so a + narrow frame still carries every figure rather than a title cut off mid-word. + Empty (the default) means the title has no shorter form. footer_hint: One-line key hint shown in the footer. scroll: Current vertical scroll offset into the body's rendered lines. future: Resolved with the screen's result (or :data:`CANCEL`) when it commits. @@ -104,6 +109,7 @@ class Screen: """ title: str = "" + short_title: str = "" footer_hint: str = "Esc back" bottom_caption: str = "" floating: bool = True diff --git a/tests/test_device_config.py b/tests/test_device_config.py index 638e8d4..61b314e 100644 --- a/tests/test_device_config.py +++ b/tests/test_device_config.py @@ -1074,6 +1074,32 @@ def test_the_archived_row_appears_only_when_something_is_archived() -> None: assert _ARCHIVE not in values and values[-1] == _ARCHIVED +def test_contacts_title_counts_the_archived_and_abbreviates_where_narrow() -> None: + """The title carries both tallies, and drops the words before it would drop a figure. + + Nothing archived keeps the plain ``… known`` title with no short form; with some, the + full ``198 known + 123 archived`` draws wherever it fits and ``198 + 123`` where the + rule is too narrow — on both frames, the bordered panel and the one-row title bar. + """ + from meshterm.core.models import Contact + from meshterm.ui.contacts_screen import ContactsScreen + from meshterm.ui.tui.frame import _title_bar, fitted_title + + contacts = [Contact(name=f"n{i}", public_key=f"{i:02x}" * 32) for i in range(198)] + + plain = ContactsScreen("Us", "cc" * 32, contacts, 1, {}, _contacts_sort()) + assert plain.title == "Contacts · 198 known" and not plain.short_title + + both = ContactsScreen("Us", "cc" * 32, contacts, 1, {}, _contacts_sort(), archived=123) + assert both.title == "Contacts · 198 known + 123 archived" + assert both.short_title == "Contacts · 198 + 123" + assert fitted_title(both, 72) == both.title + assert fitted_title(both, 30) == both.short_title + + assert "198 known + 123 archived" in _title_bar(both, 53, False, False).plain + assert "Contacts · 198 + 123 " in _title_bar(both, 40, False, False).plain + + async def test_archiving_one_contact_takes_it_off_the_device_and_keeps_it() -> None: """The single-contact archive: an amber confirm, off the radio, stamped in the store.