From aee4b73ba1dda316a93eb6810a6c228cb2386748 Mon Sep 17 00:00:00 2001 From: Jean-Pierre Martineau Date: Fri, 2 Oct 2026 23:41:30 -0400 Subject: [PATCH 1/6] The README carries a code signing policy, ahead of SignPath's review SignPath Foundation's application asks for a download page that says the project uses them for signing, and their terms want a "Code signing policy" section on it: their credit line, who holds which role, and a privacy statement. The download note now says Windows signing is on its way instead of that signing costs money, and the new section says how a build gets signed, that one person holds all three roles, and what MeshTerm sends anywhere: map tiles from OpenFreeMap when a map opens, and on the radio only what you ask it to send (the weekly advert is off by default). Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 37 +++++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8136051..f240796 100644 --- a/README.md +++ b/README.md @@ -122,8 +122,12 @@ Whichever you took, you now have a single file: `meshterm` on macOS and Linux, `meshterm-windows-x64.exe` on Windows. Put it somewhere on your `PATH` (renamed to `meshterm.exe` on Windows) and it's just `meshterm` from anywhere. -> **These builds aren't code-signed**, which is why macOS and Windows both push back the -> first time; signing costs real money on both platforms and this is a free side project. +> **These builds aren't code-signed yet**, which is why macOS and Windows both push back +> the first time. **Windows signing is on its way.** MeshTerm has applied to the +> [SignPath Foundation](https://signpath.org), which signs open-source projects for free. +> Once that's set up, every Windows download will be signed. The +> [code signing policy](#code-signing-policy) below explains how. Mac builds will stay +> unsigned, because Apple charges for signing and this is a free side project. > Every release ships a `SHA256SUMS` file if you'd rather check what you got, and > [installing with pip or pipx](#or-install-with-pip) sidesteps the whole business, since > nothing arrives as a downloaded binary. @@ -324,6 +328,35 @@ Also here: [CONTRIBUTING.md](CONTRIBUTING.md) · [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) · [CHANGELOG.md](CHANGELOG.md) +## Code signing policy + +Free code signing provided by [SignPath.io](https://signpath.io), certificate by +[SignPath Foundation](https://signpath.org). + +This is for the Windows download. It's being set up now, so releases up to and including +0.10.2 are still unsigned. + +**How a build gets signed.** Every Windows build is made by GitHub Actions, straight from +the public source code in this repository. SignPath only signs a file it can trace back to +one of those builds, and only after the maintainer approves that release. A file built on +anyone's own computer can't be signed this way, not even the maintainer's. + +**Who does what.** MeshTerm has one maintainer, +[Jean-Pierre Martineau](https://github.com/jpmartineau), who holds all three roles: + +- **Author:** writes the code and commits it. +- **Reviewer:** reviews every change from anyone else before it goes in. +- **Approver:** approves each release before it gets signed. + +**Privacy.** This program will not transfer any information to other networked systems +unless specifically requested by the user or the person installing or operating it. + +In practice, MeshTerm goes online for one thing: map tiles. It downloads them from +[OpenFreeMap](https://openfreemap.org/) when you open a screen that shows a map, and +OpenFreeMap's [privacy policy](https://openfreemap.org/privacy) covers those downloads. It +collects nothing about you and doesn't check for updates. Over the radio, it sends only +what you ask it to send. + ## Acknowledgements & attribution MeshTerm stands on the [MeshCore](https://meshcore.io/) project — its firmware and the From a741edc8d61a3e914f76fcb2d662cfa921fed44a Mon Sep 17 00:00:00 2001 From: Jean-Pierre Martineau Date: Fri, 2 Oct 2026 23:44:53 -0400 Subject: [PATCH 2/6] The Windows build names its product and version, as SignPath requires SignPath Foundation signs a file only when it carries a product name and version, and their signing configuration checks both. The .exe had neither: its Properties showed no Details at all. The spec now builds a version resource from the package's own __version__ (MeshTerm, 0.10.2, the author, the copyright line), on Windows only. Checked on a local freeze: Windows reads every field back, and the binary still runs. Co-Authored-By: Claude Opus 5.5 (1M context) --- packaging/meshterm.spec | 57 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/packaging/meshterm.spec b/packaging/meshterm.spec index 144a998..f57b7c9 100644 --- a/packaging/meshterm.spec +++ b/packaging/meshterm.spec @@ -9,6 +9,7 @@ # Built on the machine it targets. There is no cross-compiling here — the Windows build # comes off a Windows runner, the macOS one off macOS. That is what the workflow is for. +import re import sys from PyInstaller.utils.hooks import collect_submodules @@ -34,6 +35,61 @@ third_party_notices = notices.write_third_party_notices( os.path.join(workpath, "THIRD-PARTY-NOTICES.txt") ) +# The Windows build's version resource: the Details tab of the file's Properties, and the +# name and version Windows shows for it. Without one the .exe is just "meshterm.exe" with +# no product behind it, and SignPath refuses to sign it — their signing configuration +# checks the product name and version written here, so a file that doesn't carry them +# can't be passed off as a MeshTerm release. Built from the package's own `__version__` +# rather than kept as a second copy that would drift. The other platforms have no such +# resource, and PyInstaller warns if handed one there. +version_info = None +if sys.platform == "win32": + from PyInstaller.utils.win32.versioninfo import ( + FixedFileInfo, + StringFileInfo, + StringStruct, + StringTable, + VarFileInfo, + VarStruct, + VSVersionInfo, + ) + + # The checkout being frozen, not whichever MeshTerm the interpreter happens to have + # installed: `Analysis` below reads its sources from here too. + sys.path.insert(0, os.path.dirname(SPECPATH)) + import meshterm # noqa: E402 (import must follow the sys.path edit above) + + # The resource's fixed half holds exactly four numbers: 0.10.2 → (0, 10, 2, 0). A + # suffix such as `rc1` has nowhere to go there and is dropped; the text fields below + # keep the version exactly as written. + numbers = [int(n) for n in re.match(r"\d+(?:\.\d+)*", meshterm.__version__)[0].split(".")] + numbers = tuple((numbers + [0, 0, 0, 0])[:4]) + version_info = VSVersionInfo( + ffi=FixedFileInfo(filevers=numbers, prodvers=numbers), + kids=[ + StringFileInfo( + [ + # 0409 is US English and 04B0 says the strings are Unicode — the + # pairing nearly every Windows program declares. + StringTable( + "040904B0", + [ + StringStruct("CompanyName", meshterm.__author__), + StringStruct("FileDescription", "MeshTerm"), + StringStruct("FileVersion", meshterm.__version__), + StringStruct("InternalName", "meshterm"), + StringStruct("LegalCopyright", meshterm.copyright_notice()), + StringStruct("OriginalFilename", "meshterm.exe"), + StringStruct("ProductName", "MeshTerm"), + StringStruct("ProductVersion", meshterm.__version__), + ], + ) + ] + ), + VarFileInfo([VarStruct("Translation", [0x0409, 1200])]), + ], + ) + # The assets have to land at `meshterm/assets`, because `ui/about.py` finds them with # `Path(__file__).parent.parent / "assets"` and that path has to keep resolving inside # the bundle exactly as it does in a checkout. @@ -105,4 +161,5 @@ exe = EXE( target_arch=None, codesign_identity=None, entitlements_file=None, + version=version_info, ) From 6ff39fba893e974801b772483a428c1ff5e71911 Mon Sep 17 00:00:00 2001 From: Jean-Pierre Martineau Date: Fri, 2 Oct 2026 23:44:53 -0400 Subject: [PATCH 3/6] Windows signing waits an hour for approval, and only for a release SignPath Foundation's terms make a person approve every signing request, so the plan's automatic approval doesn't exist. The action gave up after ten minutes, which would fail a release whenever the maintainer stepped away; it now waits an hour, and the Windows job's timeout grows to make room. Only a tag build or a run started by hand asks to be signed: a push to main that touched the packaging builds unsigned instead of sending an approval request nobody wanted. Still inert until the SignPath variable exists. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/installers.yml | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/.github/workflows/installers.yml b/.github/workflows/installers.yml index 16b8818..e4a29b8 100644 --- a/.github/workflows/installers.yml +++ b/.github/workflows/installers.yml @@ -36,7 +36,9 @@ jobs: build: name: ${{ matrix.label }} runs-on: ${{ matrix.os }} - timeout-minutes: 25 + # Windows gets an extra hour: a signed build waits for the maintainer to approve it on + # SignPath (see "Sign it" below), and that wait is spent inside this job. + timeout-minutes: ${{ matrix.os == 'windows-latest' && 90 || 25 }} strategy: fail-fast: false matrix: @@ -137,9 +139,17 @@ jobs: # Both legs are skipped wholesale until the account exists (the variable is unset # until then), so this file builds unsigned Windows binaries in the meantime rather # than failing, and a fork with no SignPath project of its own is unaffected. + # + # SignPath Foundation's terms make a person approve every signing request, so each + # one waits on the maintainer. Only a release (a tag, arriving through the release + # workflow's call) or a run started by hand asks; a push to `main` that touched the + # packaging builds unsigned, rather than sending an approval request nobody wanted + # and failing the run when it went unanswered. - name: Hand the Windows binary to SignPath id: unsigned - if: runner.os == 'Windows' && vars.SIGNPATH_ORGANIZATION_ID != '' + if: >- + runner.os == 'Windows' && vars.SIGNPATH_ORGANIZATION_ID != '' && + (github.ref_type == 'tag' || github.event_name == 'workflow_dispatch') uses: actions/upload-artifact@v4 with: # Deliberately not `meshterm-*`: the release workflow attaches every artifact @@ -149,7 +159,7 @@ jobs: if-no-files-found: error - name: Sign it - if: runner.os == 'Windows' && vars.SIGNPATH_ORGANIZATION_ID != '' + if: steps.unsigned.outcome == 'success' uses: signpath/github-action-submit-signing-request@v1 with: api-token: ${{ secrets.SIGNPATH_API_TOKEN }} @@ -161,6 +171,11 @@ jobs: # The signed file comes back where the unsigned one was built, under the same # name, so every step after this one is untouched by any of it. wait-for-completion: true + # An hour to approve it, where the action's own default is ten minutes: long + # enough to see SignPath's email and answer it from a phone, so a release + # doesn't fail because the maintainer stepped away. The job's timeout above + # leaves room for it. + wait-for-completion-timeout-in-seconds: 3600 output-artifact-directory: packaging/dist - name: Name it after the version and the platform From 17f57b1bbc43115cfafcedfabd4b4cf96fa8597f Mon Sep 17 00:00:00 2001 From: Jean-Pierre Martineau Date: Sat, 3 Oct 2026 17:13:14 -0400 Subject: [PATCH 4/6] 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", From 51de872f32f3a85e91fffa7f688930bfd10c78d8 Mon Sep 17 00:00:00 2001 From: Jean-Pierre Martineau Date: Sat, 3 Oct 2026 19:44:26 -0400 Subject: [PATCH 5/6] The chat list's columns say LAST and MESSAGE; the live feed keeps TIME The conversation list now reads CONVERSATION, NEW, LAST, MESSAGE: LAST over the last message's age, MESSAGE over the message, shortening to MSG on a narrow terminal. The live feed's timestamp lane is TIME again, and the channel list keeps NEW. Co-Authored-By: Claude Opus 5.5 (1M context) --- meshterm/tools/chat.py | 17 +++++++++-------- meshterm/ui/livefeed_screen.py | 4 ++-- meshterm/ui/menus.py | 2 +- tests/test_chat.py | 6 +++--- tests/test_livefeed.py | 8 ++++---- tests/test_scope_display.py | 2 +- 6 files changed, 20 insertions(+), 19 deletions(-) diff --git a/meshterm/tools/chat.py b/meshterm/tools/chat.py index 6aa834f..61ee6f3 100644 --- a/meshterm/tools/chat.py +++ b/meshterm/tools/chat.py @@ -767,22 +767,23 @@ 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. - ``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. + ``NEW`` counts the unread messages, ``LAST`` is the last message's age, and ``MESSAGE`` + is that message — short words, because the lanes under the first two are a badge and an + age, and a heading longer than its lane runs into the next one's. ``LAST`` 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 - word at a time rather than the line wrapping or losing the label (see + on a terminal too narrow for the whole line, ``MESSAGE`` gives its cells back as ``MSG`` + rather than the line wrapping or losing the label (see :func:`~meshterm.ui.menus.column_header`). """ return column_header( [ Lane("CONVERSATION", lanes.label + 2), Lane("NEW", _BADGE_WIDTH + 2), - Lane("WHEN", _AGE_WIDTH + 2), - Lane(("LAST MESSAGE", "LAST MSG", "LAST")), + Lane("LAST", _AGE_WIDTH + 2), + Lane(("MESSAGE", "MSG")), ], width, indent=2 + lanes.marker, diff --git a/meshterm/ui/livefeed_screen.py b/meshterm/ui/livefeed_screen.py index 9063c5b..b6d11d1 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 ``WHEN`` lane reads down — so + stream, and its one order (newest first) is what the ``TIME`` 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("WHEN", _TIME_LANE)) + header.append(fit_cells("TIME", _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 f204cba..dcdbaa3 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 (``WHEN`` over the chat picker's 3-cell ages). + shifting every lane after it right (``LAST`` 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 46edca9..c08c6ea 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -1806,14 +1806,14 @@ async def test_picker_pins_its_column_header_over_the_group_heading( screen.handle("down") 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 + # Short words over a badge and an age, then the message itself. + assert top[0].split() == ["CONVERSATION", "NEW", "LAST", "MESSAGE"] assert top[1] == "── 👤 Direct ──" assert above is True # Too narrow for the whole line, the trailing label shortens — never wraps. The # lane is sized to these six-cell names now, so the squeeze starts well below any # real terminal: the fallback is what is under test, not the width it happens at. - assert header.text(40).endswith("LAST MSG") + assert header.text(36).endswith(" MSG") assert "\n" not in header.text(30) diff --git a/tests/test_livefeed.py b/tests/test_livefeed.py index 233295b..f613e62 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() == ["WHEN", "CLASS", "SUBJECT", "SCOPE", "SNR", "RSSI"] + assert header.split() == ["TIME", "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, "WHEN") == _col(row, stamp.group(0)) + assert _col(header, "TIME") == _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 "WHEN" in lines[0] + assert "TIME" 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 "WHEN" in body # the pinned column header is still there + assert "TIME" 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 8077832..09c46e5 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] == ["WHEN", "CLASS"] + assert header.split()[:2] == ["TIME", "CLASS"] assert _col(header, "CLASS") == _col(row, "chan text") finally: set_platform(REGULAR) From acc1341883ae7e221b684126120dc1c377c80c7e Mon Sep 17 00:00:00 2001 From: Jean-Pierre Martineau Date: Sat, 3 Oct 2026 20:17:33 -0400 Subject: [PATCH 6/6] An activity chart that ends a row shortens to fit The channel list's activity sparkline is the row's last column, and where the row was too wide for the screen, the PicoCalc's above all, the row's ellipsis cut it off mid-chart. The row now takes the render width and draws the chart in whatever cells the lanes before it leave, dropping its oldest buckets so now stays on the right; with no cell left it is not drawn, and the ACTIVITY heading shortens and then goes with it, so MSGS is never cut instead. A row like that declares itself fitted, so the highlight draws it fitted too rather than as a full line for the arrows to slide. And a row exactly as wide as its list no longer claims to overflow by a cell. CLAUDE.md states the rule for any chart that ends a row. Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 6 +++ meshterm/ui/channels.py | 96 +++++++++++++++++++++++++-------------- meshterm/ui/menus.py | 7 ++- meshterm/ui/tui/select.py | 33 +++++++++++--- tests/test_channels.py | 15 ++++++ tests/test_tui.py | 20 ++++++++ 6 files changed, 134 insertions(+), 43 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6445bf5..7dd84ff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -464,6 +464,12 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow starting `www.`, or ending in a TLD IANA has delegated (`assets/tlds.txt`, the registry verbatim; refreshing it is downloading it again). So `meshterm.net` is a link and `file.txt` is not. A scheme-less link opens, and encodes, as `https://…`. +- **A chart that is a row's last column shortens to fit**, on every platform: drawn in + whatever cells the lanes before it leave, its oldest buckets dropped so now stays on the + right, never cut by the row's ellipsis — and left out when no cell is left. The row + title takes the render width to know it (`Choice.title` as a one-argument callable; + `channels._slot_text` is the reference), and the header's label gives way the same way + (`("ACTIVITY", "ACT", "")`, gone with the chart). - Dialogs anchor slightly above true centre, sized for their populated state. - Radio traffic: single transmissions or a user-chosen sample count with cooldown pacing — never bursts. diff --git a/meshterm/ui/channels.py b/meshterm/ui/channels.py index f0526f7..d4d117b 100644 --- a/meshterm/ui/channels.py +++ b/meshterm/ui/channels.py @@ -473,12 +473,13 @@ _NAME_WIDTH_MAX = 18 #: as wide as the longest scope (never narrower than its ``SCOPE`` label), taking what the #: names leave of this budget: with a name at :data:`_NAME_WIDTH_MAX` it gets eight cells — #: what the 72-column row had spare before the lane existed, less the last cell an -#: ellipsizing row keeps back — so the activity sparkline is never cut to make room; shorter -#: names give a long region name more of its own. A 30-byte region name is ellipsized rather -#: than let widen the row. On the PicoCalc's 53 the row was already cut inside the activity -#: lane, so a scope lane there costs the MSGS count behind it; the detail page states it. -#: The leading SLOT lane is paid for out of this budget too (see :func:`_menu_items`), for -#: the same reason: the sparkline keeps its cells. +#: ellipsizing row keeps back — so the activity sparkline keeps its full width on the +#: desktop; shorter names give a long region name more of its own. A 30-byte region name is +#: ellipsized rather than let widen the row. On the PicoCalc's 53 the sparkline is already +#: drawn shorter to fit (see :func:`_slot_text`), so a scope lane there shortens it further, +#: and costs the MSGS count behind it only once the chart has no cells left; the detail page +#: states it. The leading SLOT lane is paid for out of this budget too (see +#: :func:`_menu_items`), for the same reason: the sparkline keeps its cells. _NAME_SCOPE_BUDGET = _NAME_WIDTH_MAX + 8 #: Narrowest the right-aligned slot-index lane is drawn (two digits: MeshCore's default #: table holds more than ten slots); a wider table widens it to its highest index. @@ -491,22 +492,31 @@ _COUNT_WIDTH = 5 _AGE_WIDTH = 5 -@lru_cache(maxsize=32) -def _activity_sparkline(histogram: tuple[int, ...], peak: float) -> Text: - """The channel's braille activity sparkline over the trailing two hours, now at the right. +#: Cells the activity sparkline takes when the row has room for all of it: two five-minute +#: buckets a braille cell, so the trailing two hours. +_ACTIVITY_CELLS = ACTIVITY_DRAWN_BUCKETS // 2 + + +@lru_cache(maxsize=64) +def _activity_sparkline( + histogram: tuple[int, ...], peak: float, cells: int = _ACTIVITY_CELLS +) -> Text: + """The channel's braille activity sparkline, ``cells`` wide, now at the right. The shared :func:`~meshterm.ui.braillechart.activity_sparkline` over the newest - :data:`ACTIVITY_DRAWN_BUCKETS` five-minute buckets of the repository histogram, - scaled to ``peak`` — the shared ceiling across every channel (see - :meth:`_LiveStats.peak`) — so the whole activity column shares one scale and the - rows' bars are comparable at a glance. The histogram runs deeper than is drawn; the - tail past the drawn buckets shapes ``peak`` but isn't charted. + ``2 × cells`` five-minute buckets of the repository histogram — the trailing two hours + at its full :data:`_ACTIVITY_CELLS`, less where the row has less room (see + :func:`_slot_text`), which drops the *oldest* buckets so now stays on the right edge. + Scaled to ``peak`` — the shared ceiling across every channel (see + :meth:`_LiveStats.peak`) — so the whole activity column shares one scale and the rows' + bars are comparable at a glance. The histogram runs deeper than is drawn; the tail + past the drawn buckets shapes ``peak`` but isn't charted. Memoized on its (hashable) inputs: the row callables rebuild every repaint, but the histogram snapshot only moves once per :class:`_LiveStats` TTL, so between refreshes every slot's sparkline is a cache hit. Callers treat the returned Text as read-only. """ - return activity_sparkline(histogram, ACTIVITY_DRAWN_BUCKETS, peak=peak) + return activity_sparkline(histogram, 2 * cells, peak=peak) # --- menus ------------------------------------------------------------------- @@ -519,12 +529,10 @@ def _lanes_header(slot_w: int, name_w: int, scope_w: int, width: int) -> str: not separators). ``SLOT`` leads — the index every ``channels`` command and the chat's slot references take — and its lane also spans the unlabelled glyph lane after it, *measured* rather than assumed: a channel's glyph is two cells on the desktop and one on - the console, so a hard-coded width put every later label a column off there. ``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 - message count. (No TYPE or HASH lane: the glyph already carries the openness and the - hash lives in Show key, which buys the activity sparkline its room on a 72-column - terminal.) ``SCOPE`` sits between the name and the badge: it says where the channel's + the console, so a hard-coded width put every later label a column off there. ``NEW`` + sits over the unread badge's lane. (No TYPE or HASH lane: the glyph already carries the + openness and the hash lives in Show key, which buys the activity sparkline its room on a + 72-column terminal.) ``SCOPE`` sits between the name and the badge: it says where the channel's messages go, which is the channel's own fact, before the lanes that count its traffic — and only while some channel has one (``scope_w`` is 0 otherwise). ``LAST`` comes before ``MSGS``: how recently a channel spoke is the question a glance down the list asks, and @@ -533,7 +541,7 @@ def _lanes_header(slot_w: int, name_w: int, scope_w: int, width: int) -> str: Resolved against the render width, because the header row is pinned and must stay one row: at 53 columns the full line ran to 54 and wrapped, costing a content row out of twenty-six and leaving the landmark drawn twice over. ``ACTIVITY`` gives its cells back - first (see :func:`~meshterm.ui.menus.column_header`). + first (see :func:`~meshterm.ui.menus.column_header`), as the chart under it does. """ return column_header( [ @@ -546,7 +554,7 @@ def _lanes_header(slot_w: int, name_w: int, scope_w: int, width: int) -> str: # off the digits it names. Lane(f"{'LAST':>{_AGE_WIDTH}}", _AGE_WIDTH + 2), Lane(f"{'MSGS':>{_COUNT_WIDTH}}", _COUNT_WIDTH + 2), - Lane(("ACTIVITY", "ACT")), + Lane(("ACTIVITY", "ACT", "")), # gone with its chart (see _slot_text) ], width, ) @@ -554,20 +562,27 @@ def _lanes_header(slot_w: int, name_w: int, scope_w: int, width: int) -> str: def _slot_row( ctx: AppContext, slot: ChannelSlot, stats: _LiveStats, slot_w: int, name_w: int, scope_w: int -) -> Callable[[], Text]: +) -> Callable[[int], Text]: """Return a list-row title *callable* the select screen re-renders on each repaint. The unread badge, counts, age, and activity sparkline are all read live (see :class:`_LiveStats`), so a message arriving while the list sits open updates the row on - the next repaint — exactly the conversation picker's behavior. + the next repaint — exactly the conversation picker's behavior. It takes the render + width, so the sparkline can fit itself to the row (see :func:`_slot_text`). """ - return lambda: _slot_text(ctx, slot, stats, slot_w, name_w, scope_w) + return lambda width: _slot_text(ctx, slot, stats, slot_w, name_w, scope_w, width) def _slot_text( - ctx: AppContext, slot: ChannelSlot, stats: _LiveStats, slot_w: int, name_w: int, scope_w: int + ctx: AppContext, + slot: ChannelSlot, + stats: _LiveStats, + slot_w: int, + name_w: int, + scope_w: int, + width: int, ) -> Text: - """Build one channel's list row as fixed-width, colour-coded lanes. + """Build one channel's list row as fixed-width, colour-coded lanes, ``width`` cells at most. Alignment carries the readability — slot index (right-aligned, muted), glyph, name, send scope, unread badge, last-message age, total messages, and the activity @@ -583,6 +598,12 @@ def _slot_text( ``scope_w`` 0 (no channel has one) the lane is not drawn at all. The row is always a Rich :class:`~rich.text.Text` so those spans survive under the select screen's row highlight. + + **The sparkline is the last lane, and it shortens to fit** (JP, 2026-10-03): where the + row is narrower than every lane at full width (the PicoCalc and Cardputer's 53 columns, + a long name, a scope lane), the chart is drawn in whatever cells the lanes before it + leave, dropping its oldest buckets so now stays on the right — rather than the row's + ellipsis cutting it off mid-chart. With no cell left for it, it is not drawn at all. """ st = stats.get(slot.identity) muted = _is_muted(ctx, slot) @@ -619,9 +640,13 @@ def _slot_text( # row — what the console folds ``🔕`` to, so a muted channel with no messages drew # the same glyph twice meaning different things. text.append(f"{'○':>{_COUNT_WIDTH}}", style="muted") - text.append(" ") - # The shared peak across all channels, so every row's sparkline uses one scale. - text.append_text(_activity_sparkline(st.histogram if st is not None else (), stats.peak())) + # Whatever the lanes leave after the gap, up to the chart's full width. + cells = min(_ACTIVITY_CELLS, width - text.cell_len - 2) + if cells > 0: + text.append(" ") + # The shared peak across all channels, so every row's sparkline uses one scale. + histogram = st.histogram if st is not None else () + text.append_text(_activity_sparkline(histogram, stats.peak(), cells)) return text @@ -643,7 +668,7 @@ def _menu_items( # No lane at all while no channel has a scope (see :data:`_NAME_SCOPE_BUDGET`); the # slot lane and its gap come out of the same budget, so the sparkline keeps its room. # A drawn scope lane is never narrower than its label — a long name gives up cells - # before the lane would crowd ``SCOPE`` against ``UNREAD``. + # before the lane would crowd ``SCOPE`` against ``NEW``. budget = _NAME_SCOPE_BUDGET - (slot_w + 2) scope_w = max(len("SCOPE"), min(budget - name_w, max(*scopes))) if any(scopes) else 0 name_w = min(name_w, budget - scope_w) @@ -651,9 +676,10 @@ def _menu_items( # so they pin overhead while the slots scroll and give way to Organize/Add a channel. items.append(Separator(lambda w: _lanes_header(slot_w, name_w, scope_w, w), heading=True)) for slot in slots: - items.append( - Choice(title=_slot_row(ctx, slot, stats, slot_w, name_w, scope_w), value=slot.idx) - ) + # Fitted: the row is whole at any width (its chart shortens), so the highlight + # draws it fitted too rather than as a line for ←→ to slide (Choice.fitted). + row = _slot_row(ctx, slot, stats, slot_w, name_w, scope_w) + items.append(Choice(title=row, value=slot.idx, fitted=True)) else: items.append(Separator(" no channels yet — add one below")) diff --git a/meshterm/ui/menus.py b/meshterm/ui/menus.py index dcdbaa3..164f2b1 100644 --- a/meshterm/ui/menus.py +++ b/meshterm/ui/menus.py @@ -388,7 +388,10 @@ def column_header(lanes: Sequence[Lane], width: int, *, indent: int = 2) -> str: A header is one row and stays one row. Where the line overruns ``width`` the lanes fall back to their shorter labels — from the right, since the fixed lanes are padded to their rows' content and only a trailing lane can actually give a cell back — and a line that - still won't fit is ellipsized. It never wraps: a pinned header (see + still won't fit is ellipsized. A trailing lane whose rows can vanish (a chart that + shortens to fit, then goes) ends its forms on ``""``, so its label goes with it rather + than the line being cut inside the lane before it; the padding after the last label drawn + is air, so it never counts against ``width``. It never wraps: a pinned header (see :attr:`~meshterm.ui.tui.select.Separator.pinned`) is drawn outside the body slice, where a second row would cost the content one. @@ -408,7 +411,7 @@ def column_header(lanes: Sequence[Lane], width: int, *, indent: int = 2) -> str: for lane, form in zip(lanes, picked, strict=True): label = lane.forms[form] out += label + " " * max(0, lane.width - cell_len(label)) - return out + return out.rstrip() at = len(lanes) - 1 while at >= 0 and cell_len(line()) > width: diff --git a/meshterm/ui/tui/select.py b/meshterm/ui/tui/select.py index 754a005..3bfb4af 100644 --- a/meshterm/ui/tui/select.py +++ b/meshterm/ui/tui/select.py @@ -74,6 +74,13 @@ class Choice: list's scrolling on* (see :class:`SelectScreen`): a head block is only meaningful as the part that stays put, so a row that pins one is a row built to scroll, wherever the builder's list ends up being shown. + fitted: The width-aware title *is* the row at any width, not a cut of a longer one: + its lanes are fixed and only a trailing chart gives cells back (the Channels + list's activity sparkline, which drops its oldest buckets to fit). The + highlighted row of an ``hscroll`` list then draws that fitted form too, instead + of keeping its natural one for ←→ to slide over — nothing past the edge of such a + row is worth sliding to, and a highlight that flipped the row back to a cut-off + chart would undo the fit on exactly the row being read. """ title: str | Text | Callable[[], str | Text] | Callable[[int], str | Text] @@ -81,6 +88,7 @@ class Choice: deletable: bool = False detail: str | Text | Callable[[], str | Text] | None = None hscroll_from: int = 0 + fitted: bool = False def __post_init__(self) -> None: """Read the title callable's arity once, so no paint has to ask again.""" @@ -98,6 +106,16 @@ class Choice: """The row's *natural* (unbounded) text — what filtering and measuring read.""" return self.text(_UNBOUNDED) + def scroll_text(self, width: int) -> str | Text: + """What this row draws, and ←→ slide, as the highlighted row of an ``hscroll`` list. + + Its natural form, so the whole line can be slid along — or, for a row whose fitted + form is complete (:attr:`fitted`), that form at ``width``, which by construction + leaves nothing to slide. The drawing, the slide's clamp, and the footer's ←→ atom + all read this one answer, so none of them can disagree about whether the row moves. + """ + return self.text(width) if self.fitted else self.label + @property def detail_label(self) -> str | Text | None: """The row's current detail line, resolving a callable on each read.""" @@ -628,11 +646,10 @@ class SelectScreen(Screen): current = self._current_choice() if current is None: return False + avail = max(1, self._last_width - 2) return ( self._max_hshift( - cell_len(_plain(current.label)), - current.hscroll_from, - max(1, self._last_width - 2), + cell_len(_plain(current.scroll_text(avail))), current.hscroll_from, avail ) > 0 ) @@ -702,7 +719,8 @@ class SelectScreen(Screen): # width — against the row's content area (width less the 2-cell pointer). self._last_width = width if self._hscroll and self._hshift: - sel_len = cell_len(_plain(selected.label)) if selected is not None else 0 + avail = max(1, width - 2) + sel_len = cell_len(_plain(selected.scroll_text(avail))) if selected is not None else 0 anchor = selected.hscroll_from if selected is not None else 0 self._hshift = max( 0, min(self._hshift, self._max_hshift(sel_len, anchor, max(1, width - 2))) @@ -789,9 +807,10 @@ class SelectScreen(Screen): # A width-aware title fits itself to the row's content area (the width less # the 2-cell pointer). The exception is the highlighted row of an ``hscroll`` # list, which keeps its natural form: ←→ slide the full line, and a row that - # pre-elided itself would have nothing left to slide over. + # pre-elided itself would have nothing left to slide over — unless the row + # declares its fitted form complete (``Choice.fitted``). if self._hscroll and is_sel: - label = item.label + label = item.scroll_text(max(1, width - 2)) else: label = item.text(max(1, width - 2)) pointer = "❯ " if is_sel else " " @@ -830,6 +849,8 @@ class SelectScreen(Screen): """ lane = max(1, avail - max(0, anchor)) run = max(0, label_cells - max(0, anchor)) + if run <= lane: + return 0 # the whole run is in view unscrolled: nothing to slide to steps = -(-max(0, run - (lane - 1)) // self._HSCROLL_STEP) return steps * self._HSCROLL_STEP diff --git a/tests/test_channels.py b/tests/test_channels.py index b0d7eda..5595afc 100644 --- a/tests/test_channels.py +++ b/tests/test_channels.py @@ -691,6 +691,9 @@ def test_activity_sparkline_packs_24_buckets_into_braille() -> None: # (fullest here) pair in the rightmost cell. ramp = _activity_sparkline((16, 12, 8, 4) + (0,) * 20, 16).plain assert ramp[-2:] == chr(0x2800 | 0x40 | 0xA0) + chr(0x2800 | 0x46 | 0xB8) + # Drawn shorter, it keeps the newest buckets: the same ramp, only less history. + short = _activity_sparkline((16, 12, 8, 4) + (0,) * 20, 16, 3).plain + assert len(short) == 3 and short[-2:] == ramp[-2:] async def test_channel_rows_carry_stats_unread_and_lanes(ctx: AppContext) -> None: @@ -726,6 +729,18 @@ async def test_channel_rows_carry_stats_unread_and_lanes(ctx: AppContext) -> Non label = row.label assert label.spans[-1].style == "ok" # …and its newest cell reads as live traffic + # Narrower than the whole row, the chart shortens to fit instead of being cut: the row + # ends exactly at the edge, on the same newest cell, with less history behind it. + natural = cell_len(plain) + now_cell = chr(0x2800 | 0x40 | 0xB8) + for width in (natural - 1, natural - 5, natural - 11): + fitted = row.text(width).plain + assert cell_len(fitted) == width and "…" not in fitted + assert fitted.endswith(now_cell) + # With no cell left for it, the chart is left out rather than drawn as a stub of gap. + lanes_only = row.text(natural - 13).plain + assert now_cell not in lanes_only and "⣀" not in lanes_only + async def test_standard_public_row_appears_only_while_it_is_absent(ctx: AppContext) -> None: """The 'Standard Public channel' add-action shows until a slot holds the fixed-key default.""" diff --git a/tests/test_tui.py b/tests/test_tui.py index 15c790f..75d926e 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -262,6 +262,26 @@ def test_select_hscroll_highlight_keeps_the_natural_row() -> None: assert "start ⋯ end" in body # the unhighlighted row 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 Channels list's rows shorten their trailing chart to the width; drawn natural under + the highlight, the chart came back cut off on exactly the row being read. Nothing past + such a row's edge is worth sliding to, so it doesn't claim to overflow either: no ←→ + atom, and → leaves it 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 # fitted to the 12-cell content area + 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: """Type-to-filter matches against a callable title's current text.""" screen = SelectScreen("pick", [Choice(lambda: "alpha", 1), Choice("beta", 2)])