8 Commits
Author SHA1 Message Date
Jean-Pierre Martineau ce4ff0f57e MeshTerm 0.3.5
Installers / windows-x64 (push) Canceled after 0s
Installers / macos-x64 (push) Canceled after 0s
Installers / macos-arm64 (push) Canceled after 0s
Installers / linux-x64 (push) Canceled after 0s
Installers / linux-arm64 (push) Canceled after 0s
GitHub release / build (push) Canceled after 0s
GitHub release / create the release (push) Canceled after 0s
GitHub release / put the version on meshterm.net (push) Canceled after 0s
2026-09-14 01:50:09 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 e9261569a2 The PayPal donation link is PayPal's own donate address, not a shortener
The Support page's text and QR code and the repository's funding file all pointed at a
tinyurl redirect, which a reader could not audit; they now carry the hosted-button address
itself. The page still renders on both platforms.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 00:56:16 -04:00
Jean-Pierre MartineauandClaude Opus 5 fda25aaa33 Emoji width: a skin tone takes no cell, and a stranded joiner joins nothing
Two real contact names pulled the purge preview's reason lanes out of line, and the
Contacts list and chat picker would do the same, since all three pad names through one
measurement.

"Tech-Info 👨🏻‍💻" carries a skin tone. Rich measures the modifier zero, as the
terminal draws it; wcwidth gives it two, so prompt_toolkit reserved four cells for a
two-cell glyph. The cache now measures a modifier zero at either calibration, which lets
prompt_toolkit fold it into the cell before it the way it folds a variation selector,
and the cluster merge carries it inside a joined run.

"That's So Fetch 🏴‍" is a pirate flag cut at the name's byte limit, just past its
joiner. Both authorities folded whatever followed a joiner into the glyph, and here
that is the lane's padding space, so the name measured a cell short. A joiner now joins
only a pictograph. On the width-2 path Rich's own loop is wrapped for this one case; its
table is still left alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 00:15:36 -04:00
Jean-Pierre MartineauandClaude Opus 5 5128cb23e2 The purge counts in columns, and each reason starts in its own
The target ladder described every rung in a phrase, "keeps 45 · archives 12", so
comparing two rungs meant finding the numbers inside two sentences. The counts are
columns now, right-aligned under a pinned TARGET / KEEPS / ARCHIVES header like the
editors' lane header, one rung lane measured across both sections so the numbers run
straight down the whole ladder. Keeps still leads. A rung that archives nothing shows
a muted 0.

The preview joined its reasons with " · ", so the second and third began wherever the
first happened to end. They sit in three lanes measured against the victim list, and
remeasured when a spared row leaves, under the one WHY IT RANKS LOW header: a lane holds
a contact's n-th weakest reason, whose kind varies from row to row, so a word per lane
would claim a kind the column does not keep.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 00:15:21 -04:00
Jean-Pierre MartineauandClaude Opus 5 e71aa65ab9 The map credit tests read the bottom rule's corners in either shape Rich draws them
Rich draws a panel's corners rounded, and square only where it judges the console
legacy Windows, which a captured pytest run on Windows is. The credit tests looked for
the square corner, so they passed here and failed on every Linux and macOS runner.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 22:59:26 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 4445c3aaf9 The README shows the five screenshots, the PicoCalc on its own under the four captures
Dashboard, map, trace and the Message paths dialog two by two, each with an alt text that
says what it shows, and the PicoCalc photo alone beneath them, downscaled to 1200 px wide so
the README does not load a 7 MB camera frame. The placeholder comment is gone with its job.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 18:31:04 -04:00
Jean-Pierre MartineauandClaude Opus 5 85792c1390 A release puts its version on meshterm.net
github-release.yml gains a website job: once the release is published it rebuilds the meshterm.net site from the tagged tree and pushes it back with a deploy key scoped to that one repository. The release skill says what to do when only that job fails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 18:08:34 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 bca1c83dcd MeshTerm 0.3.4
Installers / windows-x64 (push) Canceled after 0s
Installers / macos-x64 (push) Canceled after 0s
Installers / macos-arm64 (push) Canceled after 0s
Installers / linux-x64 (push) Canceled after 0s
Installers / linux-arm64 (push) Canceled after 0s
GitHub release / build (push) Canceled after 0s
GitHub release / create the release (push) Canceled after 0s
The macOS installers of 0.3.3 never built: the notices step refused pyobjc-core and
pyobjc-framework-libdispatch, whose wheels ship no licence text. Their MIT text is vendored
now, and a test checks every vendored path on every platform.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 15:50:53 -04:00
22 changed files with 445 additions and 80 deletions

No files matched your search

+5 -1
View File
@@ -139,7 +139,11 @@ gh run watch --exit-status
gh release view vX.Y.Z
```
Five platform builds plus the release job, so it takes a few minutes. **Check the notes
Five platform builds, the release job, and `website` — which rebuilds meshterm.net from the
tag so the site shows the new version — so it takes a few minutes. If only `website` fails,
the release itself is fine (it runs after publishing); the usual cause is the
`SITE_DEPLOY_KEY` secret, and once that is fixed `gh run rerun <run-id> --failed` finishes
it. **Check the notes
actually came out of the changelog** — a green run is not proof, because the fallback is a
successful step:
+1 -1
View File
@@ -3,4 +3,4 @@
buy_me_a_coffee: johnputer
custom:
- "https://www.patreon.com/c/MeshTerm"
- "https://tinyurl.com/SupportMeshTerm"
- "https://www.paypal.com/donate/?hosted_button_id=5RATNEJP2K5U6"
+57
View File
@@ -11,6 +11,11 @@
#
# The binaries are built by `installers.yml`, called rather than copied, so the files
# people download are made exactly the way the ones CI checks on every push are.
#
# Once the release exists, `website` puts it on meshterm.net: it rebuilds that site from
# the tagged tree — the landing and About pages carry the version — and pushes the result
# to jpmartineau/meshterm.net, which GitHub Pages serves. It writes with a deploy key
# scoped to that one repository (secret SITE_DEPLOY_KEY); that repo's README makes one.
name: GitHub release
on:
@@ -106,3 +111,55 @@ jobs:
--verify-tag \
$PRERELEASE
echo "https://github.com/${{ github.repository }}/releases/tag/$TAG"
website:
name: put the version on meshterm.net
needs: publish
# A pre-release stays out of "Latest", so it stays off the landing page too.
if: ${{ !inputs.prerelease }}
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Check for the deploy key
env:
SITE_DEPLOY_KEY: ${{ secrets.SITE_DEPLOY_KEY }}
run: |
if [ -z "$SITE_DEPLOY_KEY" ]; then
echo "::error::SITE_DEPLOY_KEY is not set; the meshterm.net README says how to make it."
exit 1
fi
- uses: actions/checkout@v4
with:
ref: ${{ inputs.tag || github.ref }}
path: meshterm
- uses: actions/checkout@v4
with:
repository: jpmartineau/meshterm.net
ssh-key: ${{ secrets.SITE_DEPLOY_KEY }}
path: site
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Rebuild the site from this release
run: |
python -m pip install --quiet -r site/requirements.txt
python site/build.py --meshterm meshterm
- name: Publish it
working-directory: site
env:
TAG: ${{ inputs.tag || github.ref_name }}
run: |
git add -A
if git diff --cached --quiet; then
echo "meshterm.net already shows ${TAG#v}."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898341+github-actions[bot]@users.noreply.github.com"
git commit -m "MeshTerm ${TAG#v}"
git push
+46 -1
View File
@@ -17,6 +17,49 @@ allowed to change behaviour, not just add to it.
## [Unreleased]
## [0.3.5] — 2026-09-14
### Added
- A release now rebuilds [meshterm.net](https://meshterm.net) from the tag, so the site
carries the version it links to.
### Changed
- **The purge preview counts in columns.** The target ladder used to describe each rung in a
phrase — "keeps 45 · archives 12" — so comparing two rungs meant finding the numbers inside
two sentences. Keeps and archives are right-aligned columns now under a pinned
`TARGET / KEEPS / ARCHIVES` header, one lane measured across both sections so the numbers
run straight down the ladder; a rung that archives nothing shows a muted 0. The reasons a
contact ranks low sit in three lanes of their own under one header, instead of running on
from wherever the previous reason ended.
- The PayPal link on the Support page, and in the repository's funding file, is PayPal's own
donate address rather than a shortener redirect, so a reader can see where it goes.
### Fixed
- **A name with a skin tone or a cut-off joiner no longer pulls its row out of line.** Two
real contact names showed it: one carries a skin-tone modifier, which one width table
counted as two cells and the terminal draws as none, so the row reserved four cells for a
two-cell glyph; the other is a flag sequence cut at the name's byte limit just past its
joiner, which swallowed the padding space after it and left the name a cell short. A
modifier now measures zero, and a joiner joins only a pictograph. The purge preview, the
Contacts list and the chat picker all pad names through that one measurement.
## [0.3.4] — 2026-09-13
### Fixed
- **The macOS installers build again.** 0.3.3's release never published: both macOS legs
stopped at the new third-party notices step, which refuses to bundle a dependency whose
wheel carries no licence text — and on macOS bleak's Bluetooth backend pulls in two such
wheels, `pyobjc-core` and `pyobjc-framework-libdispatch`. Their MIT text is vendored
beside pyserial's and pywinrt's now, verified against the wheels themselves, and a test
checks every vendored path on every platform so a gap that only one runner can hit is
caught here first. The 0.3.3 tag stands with no release behind it; this is the same
code with its installers.
## [0.3.3] — 2026-09-13
### Added
@@ -697,7 +740,9 @@ deliberately not reconstructed here.
- Two `TYPE_CHECKING` imports the test suite referenced but never imported, on paths that
happened never to run.
[Unreleased]: https://github.com/jpmartineau/MeshTerm/compare/v0.3.3...HEAD
[Unreleased]: https://github.com/jpmartineau/MeshTerm/compare/v0.3.5...HEAD
[0.3.5]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.3.5
[0.3.4]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.3.4
[0.3.3]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.3.3
[0.3.2]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.3.2
[0.3.1]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.3.1
+14 -18
View File
@@ -26,22 +26,18 @@ your mesh's history is worth.
> MeshTerm is a side project, not a product. Bug reports are very welcome — but please
> open an issue before writing a pull request. [More on how it's run](#how-this-project-is-run).
<!--
Screenshots to take — PNG, a 72-column window, the dark theme — saved under
docs/screenshots/ with these names, and the four links below come alive:
dashboard.png the dashboard on a live mesh: heard-age heat, the braille timelines
map.png the map zoomed on a handful of nodes, basemap under them
trace.png a trace result with its route drawn as chips
picocalc.png a photo of the app on the PicoCalc's 53x26 console
-->
<p align="center">
<img src="docs/screenshots/dashboard.png" alt="The dashboard" width="49%">
<img src="docs/screenshots/map.png" alt="The mesh map" width="49%">
<img src="docs/screenshots/dashboard.png" alt="The dashboard: a packets-per-minute timeline over the last two hours, and every packet class counted" width="49%">
<img src="docs/screenshots/map.png" alt="The map: repeaters named over an OpenStreetMap basemap of the Lachine Canal" width="49%">
</p>
<p align="center">
<img src="docs/screenshots/trace.png" alt="A trace and the route it walked" width="49%">
<img src="docs/screenshots/picocalc.png" alt="MeshTerm on the PicoCalc" width="49%">
<img src="docs/screenshots/trace.png" alt="A trace: the route it walked drawn as chips, then the SNR of every hop" width="49%">
<img src="docs/screenshots/path.png" alt="Message paths: every route a channel message was heard over, drawn on the ground" width="49%">
</p>
<p align="center">
<img src="docs/screenshots/picocalc.png" alt="MeshTerm on the PicoCalc, a 53-column console with a radio wired inside" width="38%">
</p>
<p align="center"><sub>The dashboard, the map, a trace, the paths a message took — and the whole thing on a PicoCalc.</sub></p>
---
@@ -79,14 +75,14 @@ if anything goes wrong at startup the window closes before you can read why.
**Windows** — open Windows Terminal or PowerShell, `cd` to your downloads, then:
```powershell
.\meshterm-0.3.3-windows-x64.exe
.\meshterm-0.3.5-windows-x64.exe
```
**macOS and Linux** — open Terminal, `cd` to your downloads, then:
```bash
chmod +x meshterm-0.3.3-*
./meshterm-0.3.3-*
chmod +x meshterm-0.3.5-*
./meshterm-0.3.5-*
```
### A word about terminals, on Windows
@@ -124,7 +120,7 @@ becomes just `meshterm` from anywhere.
> project.
>
> - **macOS**: *"cannot be opened because the developer cannot be verified"*. Clear it with
> `xattr -d com.apple.quarantine meshterm-0.3.3-*` and run it again.
> `xattr -d com.apple.quarantine meshterm-0.3.5-*` and run it again.
> - **Windows**: *"Windows protected your PC"*. Click **More info → Run anyway**.
>
> Every release ships a `SHA256SUMS` file if you'd rather check the download first.
@@ -134,10 +130,10 @@ becomes just `meshterm` from anywhere.
> MeshTerm uses that same folder. Set `MESHTERM_HOME` to try one in isolation:
>
> ```bash
> MESHTERM_HOME=~/meshterm-test ./meshterm-0.3.3-* # macOS, Linux
> MESHTERM_HOME=~/meshterm-test ./meshterm-0.3.5-* # macOS, Linux
> ```
> ```powershell
> $env:MESHTERM_HOME = "$HOME\meshterm-test"; .\meshterm-0.3.3-windows-x64.exe
> $env:MESHTERM_HOME = "$HOME\meshterm-test"; .\meshterm-0.3.5-windows-x64.exe
> ```
>
> Same variable if you run two radios and want them kept apart.
+1 -1
View File
@@ -1460,7 +1460,7 @@ each link; the scripted face prints the link alone.
**`--json`:**
```json
{"page":"discord","title":"Join Discord","version":"0.3.3","text":"Join the Discord\n Questions, ideas, bug reports, and mesh talk.\n\n • https://discord.gg/AZwe5Uvb3S","links":["https://discord.gg/AZwe5Uvb3S"]}
{"page":"discord","title":"Join Discord","version":"0.3.5","text":"Join the Discord\n Questions, ideas, bug reports, and mesh talk.\n\n • https://discord.gg/AZwe5Uvb3S","links":["https://discord.gg/AZwe5Uvb3S"]}
```
| Field | Type | Meaning |
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

+1 -1
View File
@@ -6,7 +6,7 @@ Supports USB, Bluetooth and TCP companion connection. For Windows, macOS and Lin
from datetime import datetime
__version__ = "0.3.3"
__version__ = "0.3.5"
__author__ = "Jean-Pierre Martineau"
#: The year MeshTerm was first published; the copyright span starts here.
+2 -2
View File
@@ -22,10 +22,10 @@ https://buymeacoffee.com/johnputer
### Donate via Paypal
- *https://tinyurl.com/SupportMeshTerm*
- *https://www.paypal.com/donate/?hosted_button_id=5RATNEJP2K5U6*
```qr
https://tinyurl.com/SupportMeshTerm
https://www.paypal.com/donate/?hosted_button_id=5RATNEJP2K5U6
```
### Become a patron
+107 -32
View File
@@ -53,6 +53,7 @@ import time
from dataclasses import replace
from typing import TYPE_CHECKING
from rich.cells import cell_len
from rich.text import Text
from ..core.connection import ContactNotOnDeviceError
@@ -62,7 +63,7 @@ from ..core.contact_score import (
sweep_candidates,
)
from ..core.models import Contact
from .menus import Lane, column_header, fit_cells, menu_rows, section_heading
from .menus import Lane, column_header, fit_cells, section_heading
from .theme import name_style
from .tui import Choice, Separator
from .tui.screen import CANCEL
@@ -129,7 +130,13 @@ def _pctl_cell(percentile: int) -> Text:
def _preview_header(width: int) -> str:
"""The preview's pinned column header, fitted to the terminal."""
"""The preview's pinned column header, fitted to the terminal.
``WHY IT RANKS LOW`` heads all three reason lanes at once rather than naming each: a
lane holds a contact's *n*-th weakest reason, so its phrases are of mixed kinds — a
message count over an age over a hop count — and a per-lane word would claim a kind the
column does not keep.
"""
return column_header(
[
Lane("PCTL", _PCTL_W),
@@ -140,12 +147,30 @@ def _preview_header(width: int) -> str:
)
def _victim_row(scored: ScoredContact) -> Text:
def _reason_lanes(victims: list[ScoredContact]) -> tuple[int, ...]:
"""Each reason lane's width: the widest phrase any victim puts in it.
Measured against the list rather than fixed, so the lanes are only as wide as the words
in them — and remeasured whenever a spared row leaves, which can only tighten them.
"""
count = max((len(scored.reasons) for scored in victims), default=0)
return tuple(
max((cell_len(s.reasons[i]) for s in victims if len(s.reasons) > i), default=0)
for i in range(count)
)
def _victim_row(scored: ScoredContact, reason_lanes: tuple[int, ...]) -> Text:
"""One preview line: percentile, the contact's name in its own hue, then why it ranks low.
The name keeps its key-derived colour like everywhere else in the app — this is a list
of nodes, and a reader picking one out of thirty rows should not have to read it letter
by letter because the screen it is on happens to be about deleting things.
The reasons sit in lanes of their own (``reason_lanes``, see :func:`_reason_lanes`),
weakest first, so each one starts in the same column on every row instead of wherever
the phrase before it happened to end. The last phrase on a row is left unpadded, as a
trailing lane is: nothing follows it to line up.
"""
contact = scored.contact
row = _pctl_cell(scored.percentile)
@@ -157,23 +182,57 @@ def _victim_row(scored: ScoredContact) -> Text:
fit_cells(name, _NAME_W - 2) + " ",
style=name_style(name, contact.public_key or contact.key_prefix),
)
row.append(" · ".join(scored.reasons), style="muted")
last = len(scored.reasons) - 1
for index, phrase in enumerate(scored.reasons):
cell = phrase if index == last else fit_cells(phrase, reason_lanes[index]) + " "
row.append(cell, style="muted")
return row
def _rung_desc(kept: int, archived: int) -> str:
"""A rung's description lane, in the number the reader is actually choosing.
#: The ladder's two count lanes, headed by these words. Each lane is exactly as wide as its
#: word, and the count right-aligns in it, so a number ends under its header's last letter.
_KEEPS = "KEEPS"
_ARCHIVES = "ARCHIVES"
**How many contacts you keep**, not how many go. The device's contact table is the
scarce thing and the whole reason to sweep at all, so the useful number is the one that
has to fit in it — how many are archived is the arithmetic left over, and it follows in
the muted half rather than leading. A percentile cutoff led this lane for one round and
was the wrong unit twice over: it answered a question nobody asked at this step, and at
53 columns it did not fit anyway.
def _ladder_header(label_w: int, width: int) -> str:
"""The target ladder's pinned column header, over the rung lane and its two counts."""
return column_header(
[
Lane("TARGET", label_w + 2),
Lane(_KEEPS, len(_KEEPS) + 2),
Lane(_ARCHIVES),
],
width,
)
def _rung_row(label: str, kept: int, archived: int, label_w: int) -> Text:
"""One rung: its label, then how many contacts it keeps and how many it archives.
**Keeps leads**, not archives. The device's contact table is the scarce thing and the
whole reason to sweep at all, so the useful number is the one that has to fit in it —
how many are archived is the arithmetic left over, and it follows. A percentile cutoff
led here for one round and was the wrong unit twice over: it answered a question nobody
asked at this step, and at 53 columns it did not fit anyway.
Both are columns rather than a phrase (``keeps 45 · archives 12``), so the ladder reads
down as a table: every count sits right-aligned under its own header, and comparing two
rungs is a glance down a lane rather than finding the number inside each sentence. A
rung that archives nothing shows its ``0`` muted, so the rungs that do something stand
out from the ones that don't.
Args:
label: The rung's own label.
kept: Contacts left on the device, protected ones included.
archived: Contacts the rung would archive.
label_w: The rung lane's width in cells — the widest label across both sections.
"""
if not archived:
return f"keeps all {kept}"
return f"keeps {kept} · archives {archived}"
row = Text(label)
row.append(" " * (label_w - cell_len(label) + 2))
row.append(f"{kept:>{len(_KEEPS)}} ")
row.append(f"{archived:>{len(_ARCHIVES)}}", style=None if archived else "muted")
return row
async def _rank(ctx: AppContext, contacts: list[Contact]) -> list[ScoredContact]:
@@ -340,23 +399,34 @@ def _target_screen(ranked: list[ScoredContact], sweepable: list[ScoredContact]):
from .tui import SelectScreen
protected = len(ranked) - len(sweepable)
sections: list[tuple[str, list[tuple[str, tuple]]]] = [
(
"By standing",
[(f"Keep the strongest {share}%", ("keep", share)) for share in KEEP_RUNGS],
),
(
"Long silent",
[(label, ("age", secs)) for label, secs in AGE_RUNGS]
+ [("Never heard at all", ("age", _NEVER))],
),
]
# One rung lane across both sections, so the counts stand in two columns down the whole
# ladder rather than restarting under each heading.
label_w = max(cell_len(label) for _, rungs in sections for label, _ in rungs)
def rung(label: str, value: tuple) -> tuple:
victims = victims_for(ranked, sweepable, value)
# What remains on the device: every protected contact, plus the sweepable ones this
# rung doesn't take. Counted from the ranking rather than from the rung's own
# percentage, because a protection is never a victim and the two would disagree.
return (label, _rung_desc(len(ranked) - len(victims), len(victims)), value)
items: list = [section_heading("By standing")]
items += menu_rows(
[rung(f"Keep the strongest {share}%", ("keep", share)) for share in KEEP_RUNGS]
)
items.append(section_heading("Long silent"))
items += menu_rows(
[rung(label, ("age", secs)) for label, secs in AGE_RUNGS]
+ [rung("Never heard at all", ("age", _NEVER))]
)
# Pinned like the editors' header (menus.lane_header), so a scrolled ladder keeps its
# column words overhead along with the section it is in.
items: list = [Separator(lambda w: _ladder_header(label_w, w), pinned=True)]
for heading, rungs in sections:
items.append(section_heading(heading))
for label, value in rungs:
victims = victims_for(ranked, sweepable, value)
# What remains on the device: every protected contact, plus the sweepable ones
# this rung doesn't take. Counted from the ranking rather than from the rung's
# own percentage, because a protection is never a victim and the two would
# disagree.
kept = len(ranked) - len(victims)
items.append(Choice(title=_rung_row(label, kept, len(victims), label_w), value=value))
held = f" · {protected} protected" if protected else ""
return SelectScreen(
f"Purge contacts — {len(ranked)} known{held}",
@@ -377,11 +447,16 @@ def _preview_items(victims: list[ScoredContact]) -> list:
percentile lane out of the ``←→`` scroll (``hscroll_from``): the number is the reader's
place in a list ordered by it, and sliding it away to read a long reasons lane would
cost the row its bearing to gain nothing — the percentile is the part that already fits.
The reason lanes are measured here, across every victim, which is also why a spared row
rebuilds the rows through this function rather than dropping one: the lanes it leaves
behind may have been sized by the phrase that just left.
"""
lanes = _reason_lanes(victims)
return [
Separator(_preview_header, pinned=True),
*(
Choice(title=_victim_row(v), value=v, deletable=True, hscroll_from=_PCTL_W)
Choice(title=_victim_row(v, lanes), value=v, deletable=True, hscroll_from=_PCTL_W)
for v in victims
),
# The same shape ``exit_rows`` draws, in this flow's own words: Apply has no key of
+86 -16
View File
@@ -74,6 +74,13 @@ single fragment (:class:`ClusterTextControl`) so it becomes one ``Char`` rather
cluster measures whatever its base measures, so the two curated sets above still reach it: listing a
base in the wide set widens the whole sequence with it.
Two neighbours of that rule are structural too, and corrected at either width. A **skin tone**
(``👍🏽``, or the toned base of ``👨🏻‍💻``) recolours the glyph before it and takes no cell,
as Rich already measures it; wcwidth gives each modifier two, so prompt_toolkit measures it zero
here and a joined run carries it along. And a **stranded joiner** — a MeshCore name cut at its byte
limit just past the ZWJ of ``🏴‍☠️`` — joins nothing (:func:`_joins`): both authorities used to fold
the next character into the flag regardless, and that character is the next lane's padding.
A **bare** codepoint the authorities *already agree* measures one is not a candidate for that set,
however wide the glyph looks in a font book: without a variation selector asking for emoji
presentation, an emoji outside Emoji_Presentation (``🛣`` U+1F6E3, ``🕸`` U+1F578) draws as a
@@ -99,6 +106,16 @@ _ZWJ = "‍"
#: Variation selector 16, the request for emoji presentation.
_VS16 = "️"
#: The five Fitzpatrick skin-tone modifiers (U+1F3FB–U+1F3FF). One following an emoji base
#: *recolours* that glyph rather than drawing one of its own — ``👍🏽`` is a thumb, not a thumb
#: beside a swatch — so it occupies no cell. Rich's table already says zero; wcwidth says two,
#: which is how ``👨🏻‍💻`` came to cost prompt_toolkit four cells for a two-cell glyph.
_MODIFIERS = frozenset(chr(cp) for cp in range(0x1F3FB, 0x1F400))
#: What may trail a base inside one glyph with no joiner in front of it: its variation selector
#: and its skin tone. A joined run gathers these after its base and after every component.
_EXTENDERS = frozenset((_VS16, *_MODIFIERS))
#: Codepoints that never occupy a cell on their own: zero-width joiner and the
#: emoji-presentation variation selector. Skipping them means a base glyph is
#: measured at its own East-Asian width, with no VS16 "promote to 2" step.
@@ -165,6 +182,30 @@ def _is_regional_indicator(char: str) -> bool:
return len(char) == 1 and 0x1F1E6 <= ord(char) <= 0x1F1FF
def _joins(char: str) -> bool:
"""Whether a zero-width joiner in front of ``char`` really joins it to the glyph before.
Every component an emoji ZWJ sequence joins is a pictograph — ``♂``, ``☠``, ``❤``, ``💻``,
all at U+2000 and above — so that is the test. What it rules out is a **stranded** joiner.
A MeshCore advert name is cut at a byte limit, and a name that ended on a joined emoji
(``That's So Fetch 🏴‍☠️``) arrives cut just past the joiner, followed by whatever the row
puts after the name: a space of lane padding, the next lane. Both width authorities used
to fold that character into the flag regardless, so the padding measured nothing while the
terminal still drew it, and every lane after the name started a column late.
"""
return char >= " " and not char.isspace()
def _stranded_out(text: str) -> str:
"""Drop every joiner that joins nothing (see :func:`_joins`), leaving real sequences whole."""
last = len(text) - 1
return "".join(
char
for index, char in enumerate(text)
if char != _ZWJ or (index < last and _joins(text[index + 1]))
)
def _joined_out(text: str) -> str:
"""Drop every codepoint a ZWJ folds into the glyph before it, leaving the bases behind.
@@ -175,17 +216,20 @@ def _joined_out(text: str) -> str:
set, the narrow allowlist, the flag category — measure correctly, so a cluster inherits
whatever its base is worth and every existing lever still reaches it.
Variation selectors are left in place: both authorities already give them zero.
A stranded joiner (see :func:`_joins`) goes on its own: the character after it was never
part of the glyph, and keeps its cell. Variation selectors and skin tones are left in
place — both authorities give them zero.
"""
kept = []
joined = False # the previous codepoint was a joiner, so this one is part of that glyph
joined = False # the previous codepoint was a joiner, so this one may belong to that glyph
for char in text:
if char == _ZWJ:
joined = True
continue
if joined:
joined = False
continue
if _joins(char):
continue
kept.append(char)
return "".join(kept)
@@ -238,8 +282,9 @@ def calibrate(*, force_width: int | None = None) -> None:
# narrow the curated lone-codepoint emoji this terminal also draws in one cell.
# width 2 -> Rich's default already matches, so its table is left alone (narrowing here
# would instead pull a correctly-wide emoji's border a column short) — but prompt_toolkit
# still sums a ZWJ sequence's parts at either width, and that is wrong on every terminal,
# so the cluster rule is installed on its own.
# still sums a ZWJ sequence's parts and a skin tone's cells at either width, and both
# authorities let a stranded joiner swallow the character after it, which is wrong on
# every terminal, so those rules are installed on their own.
# unknown/None -> no terminal to be aligned with; touch nothing.
if width == 1:
_install_terminal_widths(_narrow_lone_set(), _wide_base_set())
@@ -411,6 +456,8 @@ def _make_cell_len(narrow: frozenset[str], wide: frozenset[str]) -> Callable[[st
(:func:`_joined_out`), so ``"🤷‍♂️"`` is measured as the one glyph it is. Rich's stock
loop does this and the selector-skipping replacement above would otherwise lose it,
counting the male sign as a cell of its own and pulling the row's border a column in.
A *stranded* joiner (:func:`_joins`) folds in nothing, where the stock loop would have
swallowed the padding space after a byte-cut name.
Everything else keeps its ``get_character_cell_size`` value, so a lone emoji the terminal
*does* draw two wide (a menu icon like ``📡``, never placed in ``narrow``) is left alone.
@@ -460,6 +507,11 @@ def _make_pt_cache(narrow: frozenset[str], wide: frozenset[str], *, flags: bool
subclass forces such a base to two; the same per-character summation then gives ``"🛩️ hi"`` its
correct width, the trailing selector staying zero.
A **skin tone** (:data:`_MODIFIERS`) measures zero, as Rich already has it: wcwidth calls
each modifier two, so ``👍🏽`` cost four cells here and the joined ``👨🏻‍💻`` four as well.
At zero, prompt_toolkit's own layout folds the modifier into the cell before it, the way it
folds a variation selector — and :class:`ClusterTextControl` carries it inside a joined run.
Args:
narrow: lone codepoints to measure as one cell.
wide: base codepoints to measure as two.
@@ -483,6 +535,9 @@ def _make_pt_cache(narrow: frozenset[str], wide: frozenset[str], *, flags: bool
if len(string) <= self.LONG_STRING_MIN_LEN:
self[string] = total
return total
if string in _MODIFIERS:
self[string] = 0
return 0
if string in wide:
self[string] = 2
return 2
@@ -525,21 +580,35 @@ def _install_terminal_widths(narrow: frozenset[str], wide: frozenset[str]) -> No
def _install_cluster_widths() -> None:
"""Correct the one thing that is wrong at *either* emoji width: a ZWJ sequence.
"""Correct what is wrong at *either* emoji width: joined runs, skin tones, stranded joiners.
A terminal that draws ``☀️`` in two cells needs none of the narrowing above — Rich's table
already matches what it paints, and so does prompt_toolkit's for everything that stands on
its own. A joined sequence is the exception: prompt_toolkit adds up its codepoints wherever
it runs, so ``👨‍👩‍👧`` reserves six cells for a two-cell glyph on the widest terminal as
surely as on the narrowest. Only prompt_toolkit's cache is swapped (with both curated sets
empty and the flag category left alone, since neither has been confirmed here), and Rich is
not touched at all: its stock measurement handles a cluster correctly on its own.
surely as on the narrowest, and a skin tone costs it two more on any of them. So
prompt_toolkit's cache is swapped, with both curated sets empty and the flag category left
alone, since neither has been confirmed here.
Rich's *table* is left as it is — it measures a real cluster and a skin tone right on its
own — and only its loop is wrapped, for the one string it gets wrong: it skips whatever
follows a joiner without asking what that is, so the padding after a byte-cut ``🏴‍``
measured nothing (see :func:`_joins`). The wrapper takes the stranded joiners out before the
stock loop sees the string; every other string reaches it untouched.
"""
global _CLUSTERS
import prompt_toolkit.utils as ptu
import rich.cells as cells
from .render import _ANSI_CACHE
stock = cells._cell_len
def _cell_len(text: str, unicode_version: str = "auto") -> int:
return stock(_stranded_out(text) if _ZWJ in text else text, unicode_version)
cells.cached_cell_len.cache_clear()
cells._cell_len = _cell_len
ptu._CHAR_SIZES_CACHE = _make_pt_cache(frozenset(), frozenset(), flags=False)
_CLUSTERS = True
_ANSI_CACHE.clear()
@@ -573,10 +642,11 @@ def _join_zwj_clusters(line: list) -> list:
The line arrives one codepoint per fragment (that is what
:class:`~prompt_toolkit.formatted_text.ANSI` produces), so a sequence is a run to be
gathered: a base, its optional variation selector, then any number of *joiner + component +
optional selector* groups. A run with no joiner in it is handed back untouched — including a
lone VS16 pair, which prompt_toolkit already folds into the preceding cell on its own, and a
trailing joiner with nothing after it, which is not a sequence at all.
gathered: a base, its optional variation selector or skin tone (:data:`_EXTENDERS`), then any
number of *joiner + component + optional extenders* groups. A run with no joiner in it is
handed back untouched — including a lone VS16 pair or toned emoji, which prompt_toolkit
already folds into the preceding cell on its own, and a joiner with no pictograph after it
(see :func:`_joins`), which is not a sequence at all.
The whole line is handed back unchanged when it holds no joiner, which is almost every line;
the scan is one comparison per cell and runs only when a control's content actually changes.
@@ -588,13 +658,13 @@ def _join_zwj_clusters(line: list) -> list:
count = len(line)
while index < count:
end = index + 1
while end < count and line[end][1] == _VS16:
while end < count and line[end][1] in _EXTENDERS:
end += 1
joined = False
while end + 1 < count and line[end][1] == _ZWJ:
while end + 1 < count and line[end][1] == _ZWJ and _joins(line[end + 1][1]):
joined = True
end += 2 # the joiner and the codepoint it joins
while end < count and line[end][1] == _VS16:
while end < count and line[end][1] in _EXTENDERS:
end += 1
if joined:
text = "".join(item[1] for item in line[index:end])
+9
View File
@@ -63,8 +63,17 @@ ROOT_DISTRIBUTION = "mesh-term"
#: in a Windows build's closure. All nine are generated and published together by the
#: pywinrt project and share the one upstream license file, which none of their
#: wheels include.
#: * ``pyobjc-core`` and ``pyobjc-framework-libdispatch`` — two of the four pyobjc
#: packages bleak's macOS BLE backend (``bleak.backends.corebluetooth``) pulls in on
#: ``sys_platform == "darwin"``, so they only appear in a macOS build's closure, which
#: is where the 0.3.3 release build caught them. Their 12.2.2 wheels ship no license
#: file; the other two (Cocoa, CoreBluetooth) do, and are read from their dist-info
#: like everything else. All four come from the one pyobjc repository and its single
#: MIT text.
_VENDORED_LICENSE_GAPS: dict[str, tuple[str, ...]] = {
"pyserial": ("vendored-licenses/pyserial-LICENSE.txt",),
"pyobjc-core": ("vendored-licenses/pyobjc-LICENSE.txt",),
"pyobjc-framework-libdispatch": ("vendored-licenses/pyobjc-LICENSE.txt",),
"winrt-runtime": ("vendored-licenses/pywinrt-LICENSE.txt",),
"winrt-windows-devices-bluetooth": ("vendored-licenses/pywinrt-LICENSE.txt",),
"winrt-windows-devices-bluetooth-advertisement": ("vendored-licenses/pywinrt-LICENSE.txt",),
@@ -0,0 +1,28 @@
Vendored by packaging/notices.py -- not part of any pyobjc distribution.
Two pyobjc wheels on PyPI ship no license file of any kind: pyobjc-core 12.2.2 and
pyobjc-framework-libdispatch 12.2.2 carry an MIT license classifier and nothing matching
LICENSE*, LICENCE*, COPYING*, or NOTICE* in their dist-info -- verified by inspecting the
macOS universal2 wheels directly (the two sibling wheels bleak also pulls in,
pyobjc-framework-Cocoa and pyobjc-framework-CoreBluetooth, do carry licenses/LICENSE.txt
and are read from there). First caught by the macOS legs of the 0.3.3 release build, which
refused to bundle them unnoticed; they only appear in a macOS build's closure, since bleak
marks them sys_platform == "darwin".
All pyobjc packages are built and published together from the one pyobjc repository and
share its single license text, reproduced below unmodified. Pulled 2026-09-13 from:
https://raw.githubusercontent.com/ronaldoussoren/pyobjc/master/pyobjc-core/License.txt
Its copyright range (2003-2025) covers the pinned 12.2.2 release.
----------------------------------------------------------------------------------------
(This is the MIT license, note that libffi-src is a separate product with its own license)
Copyright 2002, 2003 - Bill Bumgarner, Ronald Oussoren, Steve Majewski, Lele Gaifax, et.al.
Copyright 2003-2025 - Ronald Oussoren
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+3 -2
View File
@@ -481,8 +481,9 @@ async def test_purge_sweeps_under_a_progress_bar_and_reports_in_a_dialog() -> No
# ever takes a whole table.
ladder = await _step_until_screen(session, lambda s: s.title.startswith("Purge contacts"))
body = _screen_text(ladder)
# A rung is described by what it *keeps* — the number that has to fit the device.
assert "keeps" in body
# A rung is counted in columns, and what it *keeps* — the number that has to fit the
# device — leads what it archives.
assert body.index("KEEPS") < body.index("ARCHIVES")
for _ in range(9):
ladder.handle("down")
ladder.handle("enter")
+53
View File
@@ -31,6 +31,10 @@ _WEB = "\U0001f578" # 🕸 its untouched sibling one Trophy board down — the
_SHRUG = "\U0001f937‍♂️" # 🤷‍♂️ a ZWJ sequence: base, joiner, male sign, selector — one glyph
_FAMILY = "\U0001f468‍\U0001f469‍\U0001f467" # 👨‍👩‍👧 three joined people, still one glyph
_ZWJ = "‍" # the joiner itself: the tell that its neighbours are a single glyph
_THUMB = "\U0001f44d\U0001f3fd" # 👍🏽 a base and its skin tone, no joiner — still one glyph
_TECHIE = "\U0001f468\U0001f3fb‍\U0001f4bb" # 👨🏻‍💻 a toned base, then a joined laptop
_FLAG = "\U0001f3f4" # 🏴 a black flag, the base of the pirate flag below
_STRANDED = f"{_FLAG}‍" # 🏴‍☠️ cut after its joiner by a name's byte limit: joins nothing
def test_narrow_lone_set_defaults_extends_and_disables(monkeypatch) -> None:
@@ -226,6 +230,50 @@ def test_calibrate_width2_narrows_nothing_but_still_joins_clusters(monkeypatch)
# The one correction that holds at either width.
assert get_cwidth(_SHRUG) == 2 # was 3
assert get_cwidth(_FAMILY) == 2 # was 6
assert get_cwidth(_TECHIE) == 2 # was 6: a skin tone is no cell, at either width
finally:
_restore(snap)
def test_a_skin_tone_is_part_of_its_glyph_in_both_authorities() -> None:
"""A skin-tone modifier recolours the emoji before it and takes no cell of its own.
wcwidth counts each modifier as two, so prompt_toolkit reserved four cells for ``👍🏽`` and
for the joined ``👨🏻‍💻`` — a two-cell notch in every row naming such a node, and every lane
after the name drawn two columns off. Rich's table already says zero; the cache now agrees,
on both calibration paths.
"""
for narrow, flags in ((frozenset(_WAVE), True), (frozenset(), False)):
cache = ew._make_pt_cache(narrow, frozenset(), flags=flags)
assert cache[_THUMB] == 2
assert cache[_TECHIE] == 2
assert cache[f"Tech {_TECHIE} x"] == len("Tech ") + 2 + len(" x")
assert ew._make_cell_len(frozenset(_WAVE), frozenset())(_TECHIE) == 2
def test_a_stranded_joiner_leaves_the_next_character_its_cell(monkeypatch) -> None:
"""A joiner with no pictograph after it joins nothing, in both authorities and both paths.
A MeshCore name is cut at a byte limit, so ``That's So Fetch 🏴‍☠️`` arrives as the flag and
a bare joiner. Both authorities folded whatever came next into the flag — the padding after
the name — so the name lane measured a cell short and every lane after it began a column
before the terminal drew it.
"""
monkeypatch.delenv("MESHTERM_NARROW_EMOJI", raising=False)
padded = f"{_STRANDED} x" # the flag's two cells, two of padding, then the next lane
cell_len = ew._make_cell_len(frozenset(_WAVE), frozenset())
assert cell_len(padded) == 5
assert ew._make_pt_cache(frozenset(_WAVE), frozenset())[padded] == 5
# A real sequence still joins: the shrug's male sign is a pictograph, not a padding space.
assert cell_len(f"{_SHRUG} x") == 4
snap = _snapshot()
try:
ew._CALIBRATED = False
ew.calibrate(force_width=2)
# Rich's own loop, trusted on this path, skipped the space and counted four.
assert cells.cell_len(padded) == 5
assert cells.cell_len(_SHRUG) == 2 and cells.cell_len(_FAMILY) == 2
finally:
_restore(snap)
@@ -259,6 +307,11 @@ def test_join_zwj_clusters_merges_only_joined_runs() -> None:
assert texts(ew._join_zwj_clusters(line(f"☀️{_SHRUG}"))) == ["☀", "️", _SHRUG]
# A trailing joiner with nothing to join is not a sequence either.
assert texts(ew._join_zwj_clusters(line(f"a{_ZWJ}"))) == ["a", _ZWJ]
# A skin tone on the base belongs to the run the way its selector does.
assert texts(ew._join_zwj_clusters(line(f"|{_TECHIE}|"))) == ["|", _TECHIE, "|"]
# A stranded joiner, followed by the name lane's padding, is not a sequence: the space
# after it keeps a fragment — and so a cell — of its own.
assert texts(ew._join_zwj_clusters(line(f"{_STRANDED} x"))) == [_FLAG, _ZWJ, " ", "x"]
# A merged fragment iterates as the whole sequence, which is what makes prompt_toolkit
# build one Char of it instead of one per codepoint.
+4 -2
View File
@@ -1180,8 +1180,10 @@ def test_gallery_map_shows_the_basemap_credit_where_its_frame_puts_it(
if platform.frame_border:
assert "OpenStreetMap" not in body, "the credit reached the drawing on a bordered frame"
rule = next(ln for ln in reversed(lines) if "└" in ln)
assert rule.endswith(f" {expected} ─┘"), rule
# Rounded corners, or square where Rich judges the console legacy Windows (a
# captured pytest run there) — either is the frame's own bottom rule.
rule = next(ln for ln in reversed(lines) if "╰" in ln or "└" in ln)
assert rule[-1] in "╯┘" and rule[:-1].endswith(f" {expected} ─"), rule
else:
assert body.splitlines()[-1].endswith(expected)
assert any(expected in ln for ln in lines)
+12 -3
View File
@@ -2589,6 +2589,12 @@ def _last_row(screen: MapScreen, cols: int = 80) -> str:
return _plain(screen.render_body(cols)).splitlines()[-1]
#: A panel's bottom corners, in both shapes Rich draws them: rounded wherever the console
#: can take them, square where it judges the console a legacy Windows one — which a
#: captured pytest run on Windows is. Asserting one shape passes on one OS only.
_BOTTOM_CORNERS = ("╰╯", "└┘")
def _bottom_rule(screen: MapScreen, cols: int = 80, rows: int = 24) -> str:
"""The composed frame's bottom border rule — the line the caption is set into."""
from rich.text import Text
@@ -2598,7 +2604,9 @@ def _bottom_rule(screen: MapScreen, cols: int = 80, rows: int = 24) -> str:
screen.note_viewport(max(1, rows - 4))
composed = frame.compose_base(Text(""), screen, screen.footer_hint, cols, rows)
return next(
ln for ln in reversed([Text.from_ansi(x).plain for x in composed.split("\n")]) if "└" in ln
ln
for ln in reversed([Text.from_ansi(x).plain for x in composed.split("\n")])
if any(pair[0] in ln for pair in _BOTTOM_CORNERS)
)
@@ -2651,9 +2659,10 @@ def test_a_bordered_frame_sets_the_credit_in_its_bottom_rule_and_spares_the_draw
set_platform(REGULAR)
screen = _credited_map(72)
# Right-justified with exactly one rule cell before the corner, as a title sits in the
# top rule: "└──… © OpenMapTiles · Data from OpenStreetMap ─┘".
# top rule: "╰──… © OpenMapTiles · Data from OpenStreetMap ─╯".
rule = _bottom_rule(screen, 72)
assert rule.startswith("└") and rule.endswith(f" {attribution.CREDIT_FULL} ─┘")
assert rule[0] + rule[-1] in _BOTTOM_CORNERS, rule
assert rule[:-1].endswith(f" {attribution.CREDIT_FULL} ─"), rule
assert cell_len(rule) == 72
# And nothing of it reached the drawing: the map's own rows are pure picture.
assert "OpenStreetMap" not in _plain(screen.render_body(72))
+16
View File
@@ -146,3 +146,19 @@ def test_write_third_party_notices_writes_utf8_lf(tmp_path: Path) -> None:
raw = out.read_bytes()
assert b"\r\n" not in raw
assert raw.decode("utf-8") == notices.render(notices.collect_notices())
def test_every_vendored_license_gap_has_its_file() -> None:
"""A gap-table entry whose file is missing would fail only on the platform that hits it.
The pyobjc entries are exercised by a macOS build alone and the winrt ones by a
Windows build alone, so a typo in a path would surface on a release runner rather
than here. Check every entry on every platform: the file exists, is non-empty, and
the key is in the canonical (lowercase, hyphenated) form ``collect_notices`` looks up.
"""
for name, paths in notices._VENDORED_LICENSE_GAPS.items():
assert name == name.lower().replace("_", "-"), name
for rel in paths:
path = Path(notices.__file__).parent / rel
assert path.is_file(), f"{name}: {rel} is missing"
assert path.read_text(encoding="utf-8").strip(), f"{name}: {rel} is empty"