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) <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 5.5 committed 2026-10-03 17:13:14 -04:00
1 parent 6ff39fba89
commit 17f57b1bbc
7 files changed
+19 -16

No files matched your search

+7 -5
View File
@@ -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,
+1 -1
View File
@@ -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.
+2 -2
View File
@@ -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))
+1 -1
View File
@@ -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.
"""
+1
View File
@@ -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
+4 -4
View File
@@ -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
+3 -3
View File
@@ -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",