Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce4ff0f57e | ||
|
|
e9261569a2 | ||
|
|
fda25aaa33 | ||
|
|
5128cb23e2 | ||
|
|
e71aa65ab9 | ||
|
|
4445c3aaf9 | ||
|
|
85792c1390 |
No files matched your search
@@ -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
@@ -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"
|
||||
@@ -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
|
||||
+32
-1
@@ -17,6 +17,36 @@ 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
|
||||
@@ -710,7 +740,8 @@ 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.4...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
|
||||
|
||||
@@ -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.4-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.4-*
|
||||
./meshterm-0.3.4-*
|
||||
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.4-*` 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.4-* # macOS, Linux
|
||||
> MESHTERM_HOME=~/meshterm-test ./meshterm-0.3.5-* # macOS, Linux
|
||||
> ```
|
||||
> ```powershell
|
||||
> $env:MESHTERM_HOME = "$HOME\meshterm-test"; .\meshterm-0.3.4-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
@@ -1460,7 +1460,7 @@ each link; the scripted face prints the link alone.
|
||||
**`--json`:**
|
||||
|
||||
```json
|
||||
{"page":"discord","title":"Join Discord","version":"0.3.4","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 |
@@ -6,7 +6,7 @@ Supports USB, Bluetooth and TCP companion connection. For Windows, macOS and Lin
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
__version__ = "0.3.4"
|
||||
__version__ = "0.3.5"
|
||||
__author__ = "Jean-Pierre Martineau"
|
||||
|
||||
#: The year MeshTerm was first published; the copyright span starts here.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
Reference in new issue
Block a user