From 17f57b1bbc43115cfafcedfabd4b4cf96fa8597f Mon Sep 17 00:00:00 2001 From: Jean-Pierre Martineau Date: Sat, 3 Oct 2026 17:13:14 -0400 Subject: [PATCH] Column headings say NEW and WHEN, not UNREAD and TIME The chat picker's unread count is headed NEW and its last-message age WHEN; the channel list's unread count is NEW; the live feed's timestamp lane is WHEN. Short words over a badge and an age, which UNREAD outgrew by a cell and had to borrow the gap for. The command line's columns keep their names: they are a documented contract of their own. Co-Authored-By: Claude Opus 5.5 (1M context) --- meshterm/tools/chat.py | 12 +++++++----- meshterm/ui/channels.py | 2 +- meshterm/ui/livefeed_screen.py | 4 ++-- meshterm/ui/menus.py | 2 +- tests/test_chat.py | 1 + tests/test_livefeed.py | 8 ++++---- tests/test_scope_display.py | 6 +++--- 7 files changed, 19 insertions(+), 16 deletions(-) diff --git a/meshterm/tools/chat.py b/meshterm/tools/chat.py index 876d667..6aa834f 100644 --- a/meshterm/tools/chat.py +++ b/meshterm/tools/chat.py @@ -695,7 +695,7 @@ _BADGE_WIDTH = 5 _AGE_WIDTH = 3 #: Floor for the conversation lane. The header word is ``CONVERSATION`` (12 cells) and the #: lane carries a two-cell gap after it, so anything narrower than this runs the heading -#: straight into ``UNREAD`` on a mesh whose every name is ``Bob``. +#: straight into ``NEW`` on a mesh whose every name is ``Bob``. _LABEL_MIN = 12 #: Ceiling for it. The lane is sized to the longest name it actually holds (see #: :func:`_lanes`), and this is where that stops paying: one very long name would otherwise @@ -767,8 +767,10 @@ def _picker_header(lanes: _Lanes, width: int) -> str: The indent covers the select screen's pointer column (2 cells, drawn on choice rows but not separators) plus the marker lane, so each header lands exactly over its column. - UNREAD borrows its lane's trailing gap — the badge lane itself is one cell too narrow - for the word — which still leaves a space before the age column. + ``NEW`` counts the unread messages and ``WHEN`` is the last message's age — short words, + because the lanes under them are a badge and an age, and a heading longer than its lane + runs into the next one's. ``WHEN`` still borrows its lane's trailing gap (the age lane is + three cells), which leaves a space before the preview. Resolved against the render width (the header row is pinned, so it must stay one row): on a terminal too narrow for the whole line, ``LAST MESSAGE`` gives its cells back a @@ -778,8 +780,8 @@ def _picker_header(lanes: _Lanes, width: int) -> str: return column_header( [ Lane("CONVERSATION", lanes.label + 2), - Lane("UNREAD", _BADGE_WIDTH + 2), - Lane("TIME", _AGE_WIDTH + 2), + Lane("NEW", _BADGE_WIDTH + 2), + Lane("WHEN", _AGE_WIDTH + 2), Lane(("LAST MESSAGE", "LAST MSG", "LAST")), ], width, diff --git a/meshterm/ui/channels.py b/meshterm/ui/channels.py index 4f21817..f0526f7 100644 --- a/meshterm/ui/channels.py +++ b/meshterm/ui/channels.py @@ -540,7 +540,7 @@ def _lanes_header(slot_w: int, name_w: int, scope_w: int, width: int) -> str: Lane("SLOT", slot_w + 2 + cell_len(channel_glyph("Public", None)) + 1), Lane("CHANNEL", name_w + 2), *([Lane("SCOPE", scope_w + 2)] if scope_w else []), - Lane("UNREAD", _BADGE_WIDTH + 2), + Lane("NEW", _BADGE_WIDTH + 2), # Right-aligned, because the values under them are: an age and a count are # padded to the right edge of their lane, so a left-aligned label would sit # off the digits it names. diff --git a/meshterm/ui/livefeed_screen.py b/meshterm/ui/livefeed_screen.py index b6d11d1..9063c5b 100644 --- a/meshterm/ui/livefeed_screen.py +++ b/meshterm/ui/livefeed_screen.py @@ -588,7 +588,7 @@ class LiveFeedScreen(Screen): Laid out lane for lane against :meth:`_feed_row`, the pointer column included, so every label sits over the values it names. Nothing here sorts — the feed is a - stream, and its one order (newest first) is what the ``TIME`` lane reads down — so + stream, and its one order (newest first) is what the ``WHEN`` lane reads down — so no column carries the contact list's sort triangle or its lit active-column style; these are signposts, not controls. On a terminal too narrow for the class label the header drops its ``CLASS`` too, leaving the icon lane unlabelled rather than @@ -600,7 +600,7 @@ class LiveFeedScreen(Screen): down the whole column. """ header = Text(" ", style="muted") # the pointer lane - header.append(fit_cells("TIME", _TIME_LANE)) + header.append(fit_cells("WHEN", _TIME_LANE)) header.append(fit_cells("CLASS", _ICON_LANE + _FEED_CLASS_WIDTH + _LANE_GAP)) header.append(fit_cells("SUBJECT", lanes.subject + _LANE_GAP)) header.append(fit_cells("SCOPE", _SCOPE_LANE)) diff --git a/meshterm/ui/menus.py b/meshterm/ui/menus.py index 4456a31..f204cba 100644 --- a/meshterm/ui/menus.py +++ b/meshterm/ui/menus.py @@ -364,7 +364,7 @@ class Lane: terminal. Only a lane the line actually overruns is ever shortened. width: Cells the label is padded to — the lane's own width *plus* the gap before the next lane, so a label wider than its column absorbs that gap instead of - shifting every lane after it right (``UNREAD`` over a narrower badge lane). + shifting every lane after it right (``WHEN`` over the chat picker's 3-cell ages). Zero (the default) for a trailing lane, which just runs to the edge. """ diff --git a/tests/test_chat.py b/tests/test_chat.py index 8bfc160..46edca9 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -1807,6 +1807,7 @@ async def test_picker_pins_its_column_header_over_the_group_heading( visible, above, _below = frame._visible_slice(screen, screen.render_body(72), 8) top = [ansi.sub("", row).strip() for row in visible[:2]] assert top[0].startswith("CONVERSATION") and top[0].endswith("LAST MESSAGE") + assert top[0].split()[1:3] == ["NEW", "WHEN"] # short words over a badge and an age assert top[1] == "── 👤 Direct ──" assert above is True # Too narrow for the whole line, the trailing label shortens — never wraps. The diff --git a/tests/test_livefeed.py b/tests/test_livefeed.py index f613e62..233295b 100644 --- a/tests/test_livefeed.py +++ b/tests/test_livefeed.py @@ -263,14 +263,14 @@ def test_livefeed_column_header_sits_over_the_lanes_it_names() -> None: """The header names each lane, and every label lands on the values beneath it.""" screen = _screen(seed=[_obs(node="3d63", kind="telemetry", snr=12.8, rssi=-105.0)]) header, row = _stripped(screen.render_body(90))[:2] - assert header.split() == ["TIME", "CLASS", "SUBJECT", "SCOPE", "SNR", "RSSI"] + assert header.split() == ["WHEN", "CLASS", "SUBJECT", "SCOPE", "SNR", "RSSI"] # Measured in display cells, not characters — the class icon is one character wide # but two cells, so a character index would report every later lane one column early. # The clock is read off the row itself: a literal needle would only match during the # minute it was written in. stamp = re.search(r"\d\d:\d\d:\d\d", row) assert stamp is not None - assert _col(header, "TIME") == _col(row, stamp.group(0)) + assert _col(header, "WHEN") == _col(row, stamp.group(0)) assert _col(header, "CLASS") == _col(row, "telemetry") - _ICON_LANE assert _col(header, "SUBJECT") == _col(row, "Hub") # Each reading's label starts where its lane does, like every other label. @@ -285,7 +285,7 @@ def test_livefeed_column_header_is_pinned_and_carries_no_sort_cue() -> None: screen = _screen(seed=[_obs(node=f"n{i}", age_s=i) for i in range(40)]) screen.note_viewport(12) lines = _stripped(screen.render_body(100)) - assert "TIME" in lines[0] + assert "WHEN" in lines[0] assert not any(mark in lines[0] for mark in ("▲", "▼")) # nothing here sorts screen.handle("end") # the list's last row — Back screen.handle("up") # …and one up from it, the oldest packet: the window scrolls @@ -475,7 +475,7 @@ def test_livefeed_windows_inside_the_fixed_screen() -> None: lines = screen.render_body(100) assert len(lines) <= 24 # column header + feed window == the viewport, never more body = _plain(lines) - assert "TIME" in body # the pinned column header is still there + assert "WHEN" in body # the pinned column header is still there assert "↓" in body and "more" in body # hidden feed rows are counted below diff --git a/tests/test_scope_display.py b/tests/test_scope_display.py index 30ae1f4..8077832 100644 --- a/tests/test_scope_display.py +++ b/tests/test_scope_display.py @@ -218,7 +218,7 @@ def test_the_picocalc_feed_names_the_class_without_an_icon() -> None: try: screen = _feed(_scoped("harbour")) header, row = _stripped(screen.render_body(53))[:2] - assert header.split()[:2] == ["TIME", "CLASS"] + assert header.split()[:2] == ["WHEN", "CLASS"] assert _col(header, "CLASS") == _col(row, "chan text") finally: set_platform(REGULAR) @@ -411,12 +411,12 @@ def _channel_header(scopes: dict[int, str]) -> str: def test_the_channel_list_draws_scope_only_when_a_channel_has_one() -> None: """A column of blanks says nothing; one scoped channel brings the lane back.""" - assert _channel_header({}).split() == ["SLOT", "CHANNEL", "UNREAD", "LAST", "MSGS", "ACTIVITY"] + assert _channel_header({}).split() == ["SLOT", "CHANNEL", "NEW", "LAST", "MSGS", "ACTIVITY"] assert _channel_header({1: "harbour"}).split() == [ "SLOT", "CHANNEL", "SCOPE", - "UNREAD", + "NEW", "LAST", "MSGS", "ACTIVITY",