16 Commits
Author SHA1 Message Date
Jean-Pierre Martineau 8875a45070 MeshTerm 0.3.1
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
2026-09-09 02:32:47 -04:00
Jean-Pierre Martineau d41111bd5f The missing link refs are cosmetic, and the check now says so 2026-09-09 02:32:47 -04:00
Jean-Pierre Martineau c2fb49a001 Skill: cutting a release, and the two workflows it must never confuse 2026-09-09 02:23:45 -04:00
Jean-Pierre MartineauandClaude Opus 5 398a184eed The splash says bye, and keeps the reader's place when a row goes
Three passes over what landed yesterday.

The hidden-count line stops standing in the list. With devices still
listed the footer already names ⇧H on every row, so the line was a row
of the box spent saying it twice; it stays only for a list hiding has
emptied, where there is no footer atom to read it off and "no
companion devices detected" would be a lie about a machine with a
radio plugged into it.

Hiding now leaves the highlight where the row was - the next device
down, or the one above at the end, never rolling round to the top - so
a run of adapters clears with a run of presses instead of the cursor
jumping back to the remembered default after each one.

And Esc says bye here rather than quit: the splash is the door, and
the reader leaving it has not started anything to quit out of.

One consequence, on the console: the splash border is 47 cells and the
sentence now sheds the hide key too when it must. Between the two
shortcuts the survivor is the way back - ⇧H only appears once
something is hidden, which is exactly when a reader needs to be told
how to undo it, and by then they have found h.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 01:45:19 -04:00
Jean-Pierre MartineauandClaude Opus 5 4109c73522 A full pack should not rescale the pulse it sits beside
The charger cuts out at full, the pack settles back to 99, the charger
restarts - and the companion reports that flip every poll for as long
as the thing is plugged in. The gauge is pinned to the header's right
edge and the pulse takes whatever is left, so the fourth cell 100%
needed came off the sparkline and redrew the whole activity history at
a new scale, twice a minute, on a device sitting still.

A full pack reads `100` now, with no sign: three cells, exactly as the
`99%` it keeps flipping back to. It is the one reading whose `%` can
be inferred - a bare 100 beside a full block is a percentage without
being told - and the sign goes on doing its work at every charge that
could be mistaken for something else.

Only that pair is answered, deliberately. The other width change in
the range, 9 to 10, is a boundary a discharging pack crosses once and
never comes back over: one repaint, no jitter, nothing to fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 01:45:19 -04:00
Jean-Pierre MartineauandClaude Opus 5 3d3506be4f A route that has finished does not end on the mark for "there is more"
The chevron is the ribbon's continuation cue: it is what a wrapped line
closes with to say the path runs on past the edge, and it is the same
shape as every seam between two hops. So the last chip of a finished
route wearing one read as a route cut off mid-walk. Where the font has
the rounded caps nothing changed - they close the lozenge and always
did. Where it hasn't, the widget already squared the *opening* off and
documented that it squares both; only the close never followed. It now
appends nothing at all and the last chip's own pad is the edge.

The cell that frees up goes back to the route. `_measure` used to
report one closing figure for every line, which was true while the
close was always a point; it reports two now - what a line the path
runs on past closes with, and what the line that *ends* it closes with
- so a wrapped path measures its own last line honestly instead of
holding a cell back for a glyph that is no longer drawn. The one caller
that flows a leg of a route rather than a whole one says so, because
its final line is a continuation and still pays the point.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 01:36:03 -04:00
Jean-Pierre MartineauandClaude Opus 5 b2bf305a31 Hide the devices on the splash that are never the one you want
A machine with a debug probe, a programmer and a USB adapter soldered
into it shows all three on every start, in front of the one radio the
user actually came for. h drops the highlighted device from the list
and remembers it in the device registry; ⇧H brings every hidden one
back. Both keys are named in the footer only where they would act -
h on a device row, ⇧H only while something is hidden - which is the
rule Del remove already followed.

Hiding is a listing choice and nothing else: a hidden device is still
remembered, still resolvable by --port, and shows itself again the
moment it is connected to. A row that is simply gone would let the
list misrepresent the machine, so a muted line says how many are
hidden and which key returns them - and that line is also where ⇧H
survives on a console whose splash border is 47 cells.

The mechanics are shared rather than local: a non-filterable list can
declare bare-key shortcuts (a filtering one cannot - every letter
there belongs to the query) and name them per highlighted row, and a
chromeless splash whose hint outgrows its box now drops atoms in front
of Esc rather than being cut off at the end, where Esc is.

Two things the narrow console showed while this was being fitted: the
splash header was a hand-built string that wrapped at 53 columns, and
the DEVICE lane sized itself to the longest name - which for a USB
adapter is its forty-character product string, pushing HARDWARE off
the edge entirely. The header goes through column_header, and the name
lane now yields to keep HARDWARE on screen. What is left of a long
model string scrolls: ←→ read the highlighted row's tail to its end,
with the lanes in front of it pinned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 01:26:06 -04:00
Jean-Pierre MartineauandClaude Opus 5 3991c723ab Two of the seven disciplines wear a one-cell mark
`🛣` and `🕸` sit outside Emoji_Presentation, so Rich and wcwidth both
measure them at one cell where the other five marks measure two — and
they are right, which is why the width machinery must keep leaving them
alone. But the Trophy case wrote `f"{icon} {title}"`, so those two
headings started their titles a column left of their siblings, and on a
terminal that paints the road wider than it advances the single space
after it was overpainted and the gap looked closed.

The marks now pad out to the widest of the seven, so every heading — and
every row of the delete picker — starts its title in one column. It is
the rule menus.icon_lane already writes down for a command row's icon
column, and deliberately not that function: a command row drops its icon
where the platform draws no icon lane, and a board's mark is part of its
name on both platforms. So the lane measures what theme.glyph will
actually draw here and the marks stay.

Measured rather than written down, which is what makes it survive the
PicoCalc's substitution map still being open: `👣` is the one discipline
mark that has no console-font glyph today and folds to two cells, so the
lane there is two as well. Give it a one-cell substitution later and all
seven headings tighten by a cell with nothing else moving.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 01:05:06 -04:00
Jean-Pierre MartineauandClaude Opus 5 18064c44de A rename is not a reason to close the page it renamed
The channel is still there and that is still its page, so the new name
and key are read back into the title, the summary and the rows and the
reader stays put. Only a clear closes it now, which is the one case
where what the page was about is gone. It used to close on both,
because the slot it was holding was a snapshot the rename made stale.

Two other things a slow action left open. The standard Public channel
has one well-known key, so a second copy is a duplicate row rather
than a channel - the menu drops the row once a slot holds it, but the
row dispatches by sentinel, so a press already on its way while the
first write ran added it twice. It is refused where it runs now.

And a reorder is a sequence of writes with no transaction under it, so
a link that drops partway leaves the slots between the two orders. It
says so, at once, because the re-read that follows means the reader is
about to be looking straight at the half-applied layout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 00:53:47 -04:00
Jean-Pierre MartineauandClaude Opus 5 d19e2ec712 Channels goes through the shared row builders it was working around
It was never in the dual-platform gallery, which is why none of this
was caught. Adding it first, then fixing what it showed.

The column header was a hand-built f-string with a five-cell indent
and no idea of the render width: at 53 columns it ran to 54 and
wrapped, costing a content row out of twenty-six and drawing the
pinned landmark twice, and its indent assumed a two-cell channel glyph
where the console draws one. It goes through column_header now, which
measures the glyph and sheds ACTIVITY before it wraps.

The command rows hand-padded their icons, so the one-cell marks (↕ ✎
🗑) started their labels a column left of the two-cell ones - the
Organize row had a two-space hack and a four-line comment about
exactly this. They share a measured icon lane now, and the clear row
tints its mark rather than its words.

The empty message count was ·, which in that same row is also the
status-atom separator and what the console folds 🔕 to: a muted
channel with no messages drew it twice, two cells apart, meaning
different things. It is ○, the app's empty mark.

Also: the detail title was a 42-cell parenthesised blob that filled
the PicoCalc's whole title bar, leaving nowhere to say Esc leaves - it
is "Channel — name", with the openness and hash now leading a
vital-signs line that sheds atoms rather than wrapping. The manager
says "Enter open", which is what its rows do. The slot count chains
with · rather than an em dash. And the four Add rows are replaced by
one empty state when there is no free slot to add into, instead of all
four dead-ending in the same refusal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 00:48:07 -04:00
Jean-Pierre MartineauandClaude Opus 5 4c01e1da7e A short list is not a layout, and a free slot is asked before it is used
The slot probe stops for two very different reasons and returned the
same short list either way. Off the end of the configured slots it is
the truth; a read that simply failed leaves whatever it had, and that
was cached for the session - an empty one reading exactly like a
device with no channels, a truncated one making the next free slot
look free with a channel sitting in it, which the add flows then wrote
straight over. The two endings raise different things, so the probe
now says whether it finished, an unfinished one is answered but not
kept, and every add confirms the slot is empty before anything is
written to it.

The chat service's slot map was also walking the radio itself, and
unbounded - it skipped empty slots all the way to the 64-slot cap, so
on firmware that never rejects an index every edit paid 64 round-trips
before the screen could redraw, on top of the manager's own re-read.
It now builds from the probe the manager was making anyway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 00:41:36 -04:00
Jean-Pierre MartineauandClaude Opus 5 4ba8da7f03 Channels shows the wait instead of announcing the result
Three things, all the same story - the manager was silent while it
worked and talkative once the work was already visible.

The acknowledgement goes. It reported changes the reader had just
watched land in the list in front of them, after the screen showing
them had closed. The count stays in summary, for the run log.

Every device action now reports while it runs, under the modal busy
card: write_channel draws its own (it is the one boundary every
mutation crosses), a reorder draws one card for the batch and retitles
it per move, and the re-read that follows a change draws one rather
than three. Modal is the point - the list underneath stays armed the
whole time it waits, so keys tapped during a slow write were landing
in its filter and coming back as a list showing nothing.

And "a write happened" moves off the change count onto the cache the
write drops. A write that landed and then raised - a QR view unwound
with ^W, a link lost mid-reorder - never reached changes += 1, so the
stale slot list and the chat service's slot map outlived a layout that
had already moved. write_channel now invalidates as it writes, which
the four CLI subcommands were never doing at all, and the manager
compares the epoch across a round.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 00:32:19 -04:00
Jean-Pierre MartineauandClaude Opus 5 0a0dc977e6 A busy card that is actually there, and actually takes the keys
The overlay could not report a hub's own slow work: it paints only on
an empty stack, so over a screen kept up with stay() it draws nothing,
and it is not a screen at all, so it takes no keys either. A visited
hub stays armed the whole time it waits, which means every press
during a slow device write still reaches it - letters into its live
filter, Esc resolving a round nobody is waiting for yet, handed back
the instant the work lands.

BusyDialog is BusyScreen drawn as a box over what it interrupts:
modal, so the press dies on the card instead. Nesting keeps the
outermost one, so a batch of writes is one wait rather than a box per
write, and its caption can change to say which step it is on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 00:23:29 -04:00
Jean-Pierre MartineauandClaude Opus 5 217625dcf7 The channel manager kept a receipt nobody could read
Every action banked a "created X" note that only surfaced once the
manager closed - by which time the refreshed list had already shown
the result. Worse, the notes accumulated: at three changes in one
visit the outcome outgrew the acknowledgement popup's line budget and
came back as the full-frame result window instead, so the same visit
reported itself two different ways depending on how much had been done
in it. The visit now acknowledges once, in the summary the tool
already returned. An error is the exception and is shown when it
happens.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 23:50:13 -04:00
Jean-Pierre MartineauandClaude Opus 5 9341c4c1d9 Twenty-two cells reserved for a name most people spell in five
The chat picker's conversation lane was a flat 22 cells, and every cell
of it past the longest name in the list was padding in front of a short
one — taken straight out of the last-message preview, which is the only
run on the row with something to say. On the PicoCalc's 53 columns that
left the message 12 cells: enough for a sender's name and a colon.

It is measured now, against the names the list actually holds, floored
at the header word and capped at the shape of a repeater-style name.
The preview keeps what it saves: 31 cells to 37 on a 72-column
terminal, 12 to 18 on the console.

What the cap costs is the tail of a long name, so the row gained the way
to read past its own edge that every other lane-and-run list in the app
already had: ←→ slide the message under the pinned columns, one row at a
time (Choice.hscroll_from). The preview had to stop pre-truncating
itself at 40 cells to make that worth pressing — a cut made before the
terminal's own leaves nothing to reveal.

The marker lane was measured too, and was wrong. A contact's dot was
padded to "a channel's double-cell glyph", which the channel glyph is on
regular and is not in the console font: on the PicoCalc every channel
row sat a cell to the left of every direct row, header over neither. It
now pads to what the glyph reports.

The footer had to give a cell back for the scroll atom, since it can
coincide with the delete atom on any thread with history — the same
arithmetic the purge preview does in its own comment. Enter opens a
screen here, so its verb is `open` rather than the default `select`,
which pays for most of it; `Del delete history` spends the rest on an
object the highlighted row is already naming.

The picker joins the gallery, which is what would have caught the marker
lane.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 23:34:15 -04:00
Jean-Pierre MartineauandClaude Opus 5 c008aa5f01 Windows got sixteen million colours, everyone else got 256
prompt_toolkit picks the colour depth per output class, and its two classes
disagree: Windows10_Output returns TRUE_COLOR outright, Vt100_Output returns
DEPTH_8_BIT for every TERM but linux. Nothing ever passed color_depth, so the
same build drawing the same theme was 24-bit on Windows and quantized to the
216-colour cube on macOS and Linux — silently, which is why it went unnoticed:
the app looks fine, just flatter.

It costs exactly what this palette is made of. The seven heat.* steps and the
per-node hue wheel are close pastels chosen to be told apart; snapped to the
cube, neighbours collide and an identity stops being distinguishable by hue.
COLORTERM is not consulted by prompt_toolkit at all — only its own
PROMPT_TOOLKIT_COLOR_DEPTH is — so a terminal announcing truecolor the
conventional way was still handed 256.

The resolver only ever raises the verdict. A terminal that cannot be shown to
do better returns None and keeps precisely the depth it had, so a host this has
never heard of cannot be made worse by it: guessing 24-bit at a terminal
without it costs not a duller palette but the colour entirely. A 16-slot
console is never promoted — the theme addresses its palette by index, and RGB
has nowhere to land there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 18:35:32 -04:00
32 changed files with 2949 additions and 317 deletions

No files matched your search

+136
View File
@@ -0,0 +1,136 @@
---
name: release
description: Cut a MeshTerm release — pick the version, write the changelog entry from the commits since the last tag, bump `__version__`, commit, tag, and push so the GitHub release builds. Use when JP says to release, cut a release, ship a version, tag a version, or bump the version, and when asked what would go into the next release.
---
# Cutting a MeshTerm release
A release is a **rename and a tag**, not a week of git archaeology — that is the whole
point of keeping an `[Unreleased]` section. The one part that takes real work is writing
the entry, because the changelog is prose someone reads, not a list of commit subjects.
Pushing the tag fires [github-release.yml](../../../.github/workflows/github-release.yml),
which calls `installers.yml`, builds all five platforms, attaches them with a combined
`SHA256SUMS`, and takes the release notes **from that version's changelog section**. So the
changelog is not documentation of the release; it *is* the release notes. An empty section
publishes "No changelog entry for X.Y.Z."
## Two things this skill never does
- **Never runs `release.yml`.** That is the **PyPI** publish, and it is deliberately hard
to fire: no automatic trigger, a typed version that must match the build, and a `pypi`
environment gate. PyPI has no rename and no true delete. It runs only when JP says so in
that moment, from the Actions tab, by hand. The name `meshterm` is on PyPI's prohibited
list anyway — the project publishes as `mesh-term`.
- **Never picks the version silently when it isn't a patch.** Default is a **patch** bump
(`0.3.0` → `0.3.1`). Anything else — minor, major — is JP's call, and he says which part.
Bumping one part zeroes the ones after it. While the major is `0`, a **minor** bump is
allowed to change behaviour, not just add to it.
## 1. Preflight
```
python .claude/skills/release/release_check.py
```
It prints the current version, the last tag, every commit since it, the state of the
`[Unreleased]` section, any changelog link refs that are missing, and whether the tree is
clean and in sync with `origin/main`. Read it before touching anything — it is also the
answer to "what would go into the next release?" on its own.
Then the gates `CONTRIBUTING.md` asks contributors to pass:
```
python -m pytest -q && ruff check . && ruff format --check .
```
A release does not go out on a red suite. If something fails, stop and say so.
## 2. Decide the version
Patch unless JP says otherwise. Say the number you arrived at before you write it anywhere,
so a wrong assumption costs a sentence instead of a tag.
## 3. Write the entry
This is the work. Read the commits since the last tag (`git log --stat v<last>..HEAD`) and
write **what changed for someone using MeshTerm**, in the changelog's existing voice:
- **Categories are Keep a Changelog's** — `### Added`, `### Changed`, `### Fixed`,
`### Removed` — in that order, and only the ones that have entries.
- **A bullet opens with a bolded sentence stating the user-visible fact**, then a paragraph
saying what was actually wrong and what it now does. Read the `0.2.6` and `0.2.8` entries
for the register: the failure is described from the reader's side first, the mechanism
second, and the lesson last if there is one.
- **Small entries are one line and no bold.** Not every commit earns a paragraph, and
several commits often collapse into one bullet — group by what the reader experienced,
not by what the diff touched.
- **A commit that changes nothing a user can see does not appear.** Refactors that move
code behind an unchanged surface, test-only commits, skill and tooling commits: leave
them out. The changelog is not a shadow git log.
- Wrap at the file's width (~95 columns) and match its em-dash-and-clause rhythm.
Insert the section directly under the HTML comment at the top of `CHANGELOG.md`:
```markdown
## [Unreleased]
## [X.Y.Z] — YYYY-MM-DD
```
A fresh empty `[Unreleased]` heading goes back in above it — that is what makes the *next*
release a rename. Then add the link refs at the bottom, repointing `[Unreleased]` at the
new tag:
```
[Unreleased]: https://github.com/jpmartineau/MeshTerm/compare/vX.Y.Z...HEAD
[X.Y.Z]: https://github.com/jpmartineau/MeshTerm/releases/tag/vX.Y.Z
```
The date is the real date — check it rather than copying the one above.
## 4. Bump the version
`__version__` in [meshterm/__init__.py](../../../meshterm/__init__.py) is **the only place a
version is written**. `[project] version` in `pyproject.toml` is `dynamic` and hatchling
reads the attribute, so the number in a built wheel cannot drift from the number in the
source. Do not add a second one.
## 5. Commit, tag, push
```
git commit -am "MeshTerm X.Y.Z"
git tag -a vX.Y.Z -m "MeshTerm X.Y.Z"
```
Commit on `main` — never a topic branch. Then **confirm with JP before pushing**, because
the push is the act that builds and publishes the release:
```
git push origin main && git push origin vX.Y.Z
```
This is the one workflow where pushing is expected. It is still asked for, once, in that
moment — the standing rule against unasked pushes is not suspended by the skill, it is
satisfied by the answer.
## 6. Watch it land
```
gh run watch --exit-status
gh release view vX.Y.Z
```
Five platform builds plus the release job, so it takes a few minutes. Report the release
URL and whether the notes came out of the changelog or fell back to "No changelog entry".
If a build fails, the tag is already public: fix forward with a new patch version rather
than deleting and re-pushing a tag people may have fetched.
## At 0.9.0 the changelog resets
The first public release collapses everything below it into a single entry reading "first
public release" with the headline features under it. Every version under 0.9.0 was released
nowhere, and its entries are notes to ourselves about getting ready — nobody arriving on
launch day wants a changelog of the fortnight before it. **Keep the dates and the tags;
replace the prose.** The same note is an HTML comment at the top of `CHANGELOG.md`, where
whoever cuts it will be looking.
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env python3
"""Preflight for a MeshTerm release, and the answer to "what's in the next one?".
Reports the six things worth knowing before a version number is written down, and says
nothing else: the current version and the tag it belongs to, the commits since, whether
there is an `[Unreleased]` section to rename, which changelog link refs are missing, and
whether the working tree is clean and level with `origin/main`.
It changes nothing. Every finding is a line a person acts on, not a gate that blocks.
"""
from __future__ import annotations
import re
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[3]
INIT = ROOT / "meshterm" / "__init__.py"
CHANGELOG = ROOT / "CHANGELOG.md"
OK, WARN, INFO = "ok ", "warn", " "
def git(*args: str) -> str:
"""Run a git command in the repo, returning stripped stdout ('' on failure)."""
done = subprocess.run(["git", "-C", str(ROOT), *args], capture_output=True, text=True)
return done.stdout.strip() if done.returncode == 0 else ""
def say(mark: str, line: str) -> None:
"""Print one finding, indented under its heading; a blank mark is a plain note."""
print(f" {mark} {line}" if mark.strip() else f" {line}")
def current_version() -> str:
"""The version `meshterm/__init__.py` declares - the only place one is written."""
found = re.search(r'^__version__\s*=\s*"([^"]+)"', INIT.read_text(encoding="utf-8"), re.M)
return found.group(1) if found else "?"
def latest_tag() -> str:
"""The highest v-tag by version order, not by date — a re-tag must not reorder this."""
tags = [t for t in git("tag", "-l", "v*").splitlines() if t]
if not tags:
return ""
return sorted(tags, key=lambda t: [int(p) for p in re.findall(r"\d+", t)])[-1]
def next_patch(version: str) -> str:
"""The default bump. Any other part is JP's call, and he says which."""
parts = version.split(".")
if len(parts) != 3 or not parts[2].isdigit():
return "?"
return f"{parts[0]}.{parts[1]}.{int(parts[2]) + 1}"
def main() -> int:
"""Print the report. Nothing here blocks; every line is for a person to act on."""
version, tag = current_version(), latest_tag()
text = CHANGELOG.read_text(encoding="utf-8")
print(f"\nMeshTerm {version} | last tag {tag or '(none)'}\n")
print("Version")
if tag and tag != f"v{version}":
say(WARN, f"__version__ is {version} but the last tag is {tag} - already bumped?")
else:
say(OK, f"__version__ {version} matches the last tag")
say(INFO, f"next patch would be {next_patch(version)} (JP names anything else)")
print("\nCommits since the tag")
log = git("log", "--oneline", f"{tag}..HEAD") if tag else git("log", "--oneline")
lines = [ln for ln in log.splitlines() if ln]
if not lines:
say(WARN, "none - there is nothing to release")
else:
say(INFO, f"{len(lines)} commit(s):")
for ln in lines:
print(f" {ln}")
print("\nChangelog")
if re.search(r"^## \[Unreleased\]", text, re.M):
body = text.split("## [Unreleased]", 1)[1].split("\n## [", 1)[0].strip()
if body:
say(OK, "an [Unreleased] section with entries - rename it to the new version")
else:
say(WARN, "an [Unreleased] heading with nothing under it - the entry is unwritten")
else:
say(WARN, "no [Unreleased] heading - write the section from the commits above")
versions = set(re.findall(r"^## \[(\d[^\]]*)\]", text, re.M))
refs = set(re.findall(r"^\[(\d[^\]]*)\]:", text, re.M))
missing = sorted(versions - refs, key=lambda v: [int(p) for p in re.findall(r"\d+", v)])
if missing:
# Cosmetic only: the heading renders as literal brackets instead of a link. The
# release notes come from the section body, so a published release is unaffected.
say(WARN, f"link refs missing at the bottom for: {', '.join(missing)}")
say(INFO, "(cosmetic - the heading loses its link; release notes are unaffected)")
else:
say(OK, "every version heading has its link ref")
print("\nTree")
dirty = git("status", "--porcelain")
say(
OK if not dirty else WARN,
"clean" if not dirty else f"{len(dirty.splitlines())} uncommitted change(s)",
)
branch = git("rev-parse", "--abbrev-ref", "HEAD")
say(OK if branch == "main" else WARN, f"on {branch}")
git("fetch", "--quiet", "origin")
counts = git("rev-list", "--left-right", "--count", "origin/main...HEAD")
if counts:
behind, ahead = counts.split()
say(OK if behind == "0" else WARN, f"{ahead} ahead / {behind} behind origin/main")
print("\nRun the gates before tagging:")
print(" python -m pytest -q && ruff check . && ruff format --check .\n")
return 0
if __name__ == "__main__":
sys.exit(main())
+129 -1
View File
@@ -15,6 +15,129 @@ allowed to change behaviour, not just add to it.
getting ready. Keep the dates and the tags; replace the prose.
-->
## [Unreleased]
## [0.3.1] — 2026-09-09
### Added
- **Devices you never want to connect to can be hidden from the splash.** A machine with a
debug probe, a programmer and a USB adapter soldered into it listed all three on every
start, in front of the one radio you came for. `h` drops the highlighted device and
remembers it, `⇧H` brings every hidden one back, and both keys are named in the footer
only where they would act.
Hiding is a listing choice and nothing else — a hidden device is still remembered, still
resolvable by `--port`, and shows itself again the moment it is connected to. The
highlight stays where the row was rather than jumping back to the remembered default, so
a run of adapters clears with a run of presses.
### Changed
- **The chat picker's conversation lane is measured against the names the list actually
holds**, instead of a flat 22 cells. Every cell past the longest name was padding in
front of a short one, taken straight out of the last-message preview — the only run on
the row with something to say, and on the PicoCalc's 53 columns it left the message 12
cells. The preview keeps what it saves: 31 cells to 37 on a 72-column terminal, 12 to 18
on the console. What the cap costs is the tail of a long name, so `←→` now slides the
message under the pinned columns to read one past its own edge.
- **Channels shows the wait instead of announcing the result.** Every action banked a
"created X" note that surfaced only once the manager closed — by which time the refreshed
list had already shown the result — and the notes accumulated: at three changes in one
visit the outcome outgrew the acknowledgement popup and came back as the full-frame
result window instead, so the same visit reported itself two different ways depending on
how much had been done in it. Every device action now reports while it runs, under a
modal busy card, and the count stays in the run log.
- Renaming a channel no longer closes the page it renamed. The channel is still there and
that is still its page, so the new name and key are read back into the title and the
rows, and the reader stays put. Only clearing it closes it, which is the one case where
what the page was about is gone.
- `Esc` on the device splash says **bye** rather than quit — the splash is the door, and
nothing has been started there to quit out of.
### Fixed
- **macOS and Linux were drawing the whole app in 256 colours.** prompt_toolkit picks the
colour depth per output class, and its two classes disagree: the Windows one returns
truecolor outright, the VT100 one returns 8-bit for every `TERM` but `linux`. Nothing
ever passed a depth, so the same build drawing the same theme was 24-bit on Windows and
quantized to the 216-colour cube everywhere else — silently, which is why it went
unnoticed. The app looked fine, just flatter.
It costs exactly what this palette is made of. The seven heat steps and the per-node hue
wheel are close pastels chosen to be told apart; snapped to the cube, neighbours collide
and an identity stops being distinguishable by hue. `COLORTERM` is not consulted by
prompt_toolkit at all, so a terminal announcing truecolor the conventional way was still
handed 256.
The resolver only ever raises the verdict. A terminal that cannot be shown to do better
keeps precisely the depth it had, because guessing 24-bit at a terminal without it costs
not a duller palette but the colour entirely. A 16-slot console is never promoted — the
theme addresses its palette by index, and RGB has nowhere to land there.
- **Adding a channel could write straight over one that was already there.** The free-slot
probe stops for two very different reasons and returned the same short list either way:
off the end of the configured slots it is the truth, but a read that simply failed left
whatever it had — and that was cached for the session, an empty one reading exactly like
a device with no channels, a truncated one making the next free slot look free with a
channel sitting in it. The probe now says whether it finished, an unfinished one is
answered but not kept, and every add confirms the slot is empty before anything is
written to it.
- Keys tapped during a slow channel write were landing in the filter of the list
underneath, which then came back showing nothing. The busy card is modal, so the press
dies on the card instead.
- **Channels at 53 columns.** The column header was a hand-built string with no idea of the
render width: it ran to 54 cells and wrapped, costing a content row out of twenty-six and
drawing the pinned landmark twice. The command rows hand-padded their icons, so the
one-cell marks started their labels a column left of the two-cell ones. The empty-message
count was `·`, which in that same row is also the status separator and what the console
folds 🔕 to — a muted channel with no messages drew it twice, two cells apart, meaning
different things; it is `○` now, the app's empty mark. And the detail title was a 42-cell
parenthesised blob that filled the PicoCalc's whole title bar, leaving nowhere to say Esc
leaves.
- Every channel edit paid up to 64 device round-trips before the screen could redraw. The
chat service's slot map was walking the radio itself, unbounded, skipping empty slots all
the way to the 64-slot cap on firmware that never rejects an index — on top of the
manager's own re-read. It builds from the probe that was being made anyway.
- A channel write that landed and then raised never invalidated what it had made stale, so
a slot list and a chat slot map outlived a layout that had already moved. A reorder that
drops partway now says so at once, since the reader is about to be looking straight at
the half-applied layout, and the standard Public channel can no longer be added twice by
a second press already on its way.
- Two of the Trophy case's seven discipline marks started their titles a column left of
their siblings: 🛣 and 🕸 sit outside Emoji_Presentation, so Rich and wcwidth both measure
them at one cell where the other five measure two. The marks now pad out to the widest of
the seven, measured against what the platform will actually draw rather than written
down.
- A route that has finished no longer ends on the chevron that means "the path runs on past
the edge" — it ends square, and the cell that frees up goes back to the route.
- **A fully charged pack redrew the activity sparkline twice a minute.** The charger cuts
out at full, the pack settles back to 99, the charger restarts, and the companion reports
that flip every poll for as long as the thing is plugged in. The gauge is pinned to the
header's right edge and the pulse takes whatever is left, so the fourth cell `100%`
needed came off the sparkline and redrew the whole activity history at a new scale, on a
device sitting still. A full pack reads `100` now, with no sign: three cells, exactly as
the `99%` it keeps flipping back to, and the one reading whose `%` can be inferred.
- On the PicoCalc every channel row in the chat picker sat a cell to the left of every
direct row, with the header over neither — the marker lane padded to a two-cell channel
glyph that the console font draws in one.
- The device splash's own header wrapped at 53 columns, and its DEVICE lane sized itself to
the longest name — which for a USB adapter is a forty-character product string, pushing
HARDWARE off the edge entirely. The name lane yields now, and `←→` reads the rest of a
long model string with the lanes in front of it pinned.
## [0.3.0] — 2026-09-08
The command line was rebuilt around the fact that it has **two readers**, and that trying
@@ -395,8 +518,13 @@ 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.0...HEAD
[Unreleased]: https://github.com/jpmartineau/MeshTerm/compare/v0.3.1...HEAD
[0.3.1]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.3.1
[0.3.0]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.3.0
[0.2.8]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.2.8
[0.2.7]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.2.7
[0.2.6]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.2.6
[0.2.5]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.2.5
[0.2.4]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.2.4
[0.2.3]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.2.3
[0.2.2]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.2.2
+15 -2
View File
@@ -187,8 +187,15 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow
- ≤72 cells. Sentence shape: navigation keys, then action keys, **Esc last**.
- Esc verb by surface: `Esc back` leaves a screen · `Esc close` dismisses a read-only
floating view · `Esc cancel` abandons a prompt/dialog · `Esc keep` leaves a value
picker unchanged · `Esc quit` only at the main menu. While a find-as-you-type filter
is standing the verb becomes `Esc clear`, because that is what the press does then.
picker unchanged · `Esc quit` only at the main menu · `Esc bye` only on the device
splash, which is the door rather than a screen — nothing has been started there to
quit out of. While a find-as-you-type filter is standing the verb becomes `Esc clear`,
because that is what the press does then.
- A hint that outgrows the surface drawing it **sheds atoms rather than being cut off**,
because the cut takes the end and the end is Esc (`frame.fit_hint`, the chromeless
splash). They go from the right, in front of Esc; a screen may name the ones it can
spare first (`spare_hint_atoms`) — a key the reader would find anyway, like the ←→ its
move atom already named, before one that is unguessable and advertised nowhere else.
- Enter verb by action: `Enter open` when the row pushes a screen or dialog ·
`Enter select` when it picks a value or action row · `Enter set` in a value picker.
A more specific committing verb (`Enter adopt path`, `Enter add`) is fine; a synonym
@@ -257,6 +264,12 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow
same fill are the exception the interlock can't draw: the point drops its background so
the page cuts the wedge. An elision breaks the ribbon rather than joining it — bare `⋯`
on the page between a closing point and the next chip's notch, no fill, no padding.
- The ribbon's **outer ends**: the chevron means *the route continues*, so a path that has
finished never wears one. It ends **square** — nothing appended, the last chip's own pad
is the edge — and opens square the same way; a full Nerd Font rounds both ends into a
lozenge instead (`POWERLINE_ROUND_*`, drawn only where `termfont.powerline_full`). A
*wrapped* line the path outruns still closes on the point: there the route really does
go on, which is the cue arrow mode spells with a trailing `→`.
- Concept icons: 📡 advert · 🕒 clock/sync · 🔄 reboot · 💾 backup · 📂 restore ·
🔑 channel/credential key · 🔐 identity/auth secret · 🗑 clear/delete · ✎ compose/edit ·
⚙ parameter · `#` count · ▶ run · ⚡ explore/probe · ★ best/winner · ⭐ watch ·
+1 -1
View File
@@ -5,7 +5,7 @@ Supports USB, Bluetooth and TCP companion connection. For Windows, macOS and Lin
from datetime import datetime
__version__ = "0.3.0"
__version__ = "0.3.1"
__author__ = "Jean-Pierre Martineau"
#: The year MeshTerm was first published; the copyright span starts here.
+74 -16
View File
@@ -11,6 +11,15 @@ The registry also tracks which device was most recently connected (``last``), us
preselect and star a default on the startup splash. Membership in the registry is what marks
a device as a confirmed MeshCore companion — the splash reserves its "MeshCore device" tag
for these, rather than guessing from the USB vendor ID.
Alongside those it keeps ``hidden``: the stable ids the splash has been told to stop
listing. It lives here rather than in the preferences because it is a fact about *this
machine's* hardware — the USB adapters and dev boards permanently plugged into it that are
not companions — and because the ids it holds are the registry's own. Hiding is a **splash**
concern only: nothing else consults it, so ``--port`` and every scripted path still reach a
hidden device by name. A hidden id need not be a remembered device (usually it is the
opposite: a serial adapter nobody wants to see), so the set stands on its own rather than
being a flag on a record.
"""
from __future__ import annotations
@@ -101,8 +110,8 @@ class DeviceStore:
"""
self._path = path
def _read(self) -> tuple[dict[str, RememberedDevice], str | None]:
"""Return the parsed ``(registry, last_id)``; empty on a missing/corrupt file.
def _read(self) -> tuple[dict[str, RememberedDevice], str | None, set[str]]:
"""Return the parsed ``(registry, last_id, hidden_ids)``; empty on a missing file.
A missing or corrupt file is treated as "nothing remembered" rather than an error,
so a stray edit never blocks startup. An old flat-format file (a single record at
@@ -112,16 +121,16 @@ class DeviceStore:
try:
data = json.loads(self._path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}, None
return {}, None, set()
if not isinstance(data, dict):
return {}, None
return {}, None, set()
# Old flat shape: a single record with ``stable_id`` at the top level.
if "devices" not in data and "stable_id" in data:
record = self._record_from(data)
if record is None:
return {}, None
return {record.stable_id: record}, record.stable_id
return {}, None, set()
return {record.stable_id: record}, record.stable_id, set()
registry: dict[str, RememberedDevice] = {}
for entry in (data.get("devices") or {}).values():
@@ -131,7 +140,9 @@ class DeviceStore:
last = data.get("last")
if last not in registry:
last = None
return registry, last
raw_hidden = data.get("hidden")
hidden = {str(x) for x in raw_hidden} if isinstance(raw_hidden, list) else set()
return registry, last, hidden
@staticmethod
def _record_from(entry: object) -> RememberedDevice | None:
@@ -163,17 +174,17 @@ class DeviceStore:
Returns:
The last-connected :class:`RememberedDevice`, or ``None``.
"""
registry, last = self._read()
registry, last, _hidden = self._read()
return registry.get(last) if last is not None else None
def load_all(self) -> dict[str, RememberedDevice]:
"""Return the full registry of confirmed devices, keyed by ``stable_id``."""
registry, _ = self._read()
registry, _last, _hidden = self._read()
return registry
def is_known(self, device: DiscoveredDevice) -> bool:
"""Return whether ``device`` has ever been confirmed as a MeshCore companion."""
registry, _ = self._read()
registry, _last, _hidden = self._read()
return device.stable_id in registry
def remember(
@@ -193,7 +204,7 @@ class DeviceStore:
a reconnect on firmware that couldn't answer the query doesn't erase a model
learned earlier.
"""
registry, _ = self._read()
registry, _last, hidden = self._read()
existing = registry.get(device.stable_id)
if not node_name and existing is not None:
node_name = existing.node_name
@@ -211,7 +222,11 @@ class DeviceStore:
host=device.host or "",
tcp_port=device.tcp_port or 0,
)
self._write(registry, device.stable_id)
# Connecting to a device is the plainest statement that it should be listed, so a
# confirmed one stops being hidden — otherwise it would vanish from the splash the
# moment it proved itself, which is the opposite of what hiding is for.
hidden.discard(device.stable_id)
self._write(registry, device.stable_id, hidden)
def forget(self, stable_id: str) -> bool:
"""Drop a remembered device from the registry.
@@ -230,17 +245,58 @@ class DeviceStore:
Returns:
``True`` if a record was removed, ``False`` if none matched.
"""
registry, last = self._read()
registry, last, hidden = self._read()
if stable_id not in registry:
return False
del registry[stable_id]
if last == stable_id:
last = max(registry, key=lambda k: registry[k].last_connected, default=None)
self._write(registry, last)
hidden.discard(stable_id) # nothing left to hide it from
self._write(registry, last, hidden)
return True
def _write(self, registry: dict[str, RememberedDevice], last: str | None) -> None:
"""Persist the registry, marking ``last`` as the most recently connected device."""
def hidden_ids(self) -> set[str]:
"""Return the stable ids the startup splash has been told not to list."""
_registry, _last, hidden = self._read()
return hidden
def hide(self, stable_id: str) -> None:
"""Stop listing ``stable_id`` on the startup splash, from now until it is shown again.
Kept forever, like the registry itself — the splash is a list of what is plugged into
this machine, and the adapters that are always plugged in and never companions are
the same ones every time. Hiding an id that is not remembered is normal and fine:
those are exactly the rows worth hiding.
Args:
stable_id: The device's :attr:`DiscoveredDevice.stable_id`.
"""
registry, last, hidden = self._read()
if stable_id in hidden:
return
hidden.add(stable_id)
self._write(registry, last, hidden)
def show_all(self) -> int:
"""Un-hide every hidden device; return how many were brought back.
The single way back, deliberately: hiding is per-row and un-hiding is not, because a
hidden row is not on screen to press a key on. Returning the count lets the caller
say what it just did.
"""
registry, last, hidden = self._read()
if not hidden:
return 0
self._write(registry, last, set())
return len(hidden)
def _write(
self,
registry: dict[str, RememberedDevice],
last: str | None,
hidden: set[str],
) -> None:
"""Persist the registry, its ``last`` pointer, and the splash's hidden ids."""
self._path.parent.mkdir(parents=True, exist_ok=True)
payload = {
"devices": {
@@ -259,5 +315,7 @@ class DeviceStore:
for stable_id, record in registry.items()
},
"last": last,
# Sorted so the file does not churn between writes over set ordering alone.
"hidden": sorted(hidden),
}
write_atomically(self._path, json.dumps(payload, indent=2))
+28
View File
@@ -126,6 +126,20 @@ _SILENCE_CHOICES: dict[int, str] = {
#: need to disagree with its platform.
_WIDTH_CHOICES: dict[str, str] = {"auto": "auto", "yes": "yes", "no": "no"}
#: How many colours to send the terminal. ``auto`` is the honest default and the only
#: value that is not itself a depth: it means "take prompt_toolkit's verdict unless we can
#: positively establish better", which is the difference between fixing a host and breaking
#: one. The rest are named for the count a reader can see rather than for the bit depth,
#: because "24-bit" is a fact about the wire and "16 million" is a fact about the screen.
#: See :func:`meshterm.ui.tui.session._color_depth` for why auto is not simply "the most
#: this terminal can do".
_COLOR_DEPTH_CHOICES: dict[str, str] = {
"auto": "auto",
"truecolor": "16 million",
"256": "256",
"16": "16",
}
#: Every preference MeshTerm has, in page order. Adding one here gives it a row on the
#: Preferences page, a key in the YAML file, a ``preferences get``/``set`` CLI face, and a
#: default — nothing else has to follow.
@@ -319,6 +333,20 @@ PREFERENCES: tuple[PrefSpec, ...] = (
default="auto",
choices=_WIDTH_CHOICES,
),
PrefSpec(
key="color_depth",
label="Colours",
help="How many colours to send this terminal",
group="Display",
value_type="enum",
# Auto, because the count a terminal accepts is a fact about the terminal and the
# reader should not have to know it. It is only ever consulted to *raise* the
# verdict prompt_toolkit already reached, never to lower it — a terminal that
# cannot be shown to do better keeps exactly what it had.
default="auto",
choices=_COLOR_DEPTH_CHOICES,
relaunch=True,
),
PrefSpec(
key="console_setup",
label="Console setup",
+10 -14
View File
@@ -19,7 +19,7 @@ from __future__ import annotations
import asyncio
from typing import TYPE_CHECKING
from ..core.channels import CHANNEL_SLOT_PROBE_CAP, channel_identity
from ..core.channels import channel_identity
from ..core.connection import Unsubscribe
from ..core.events import EventKind, MeshEvent
from ..core.models import (
@@ -128,20 +128,16 @@ class ChatService:
The map is what lets an inbound message (which carries only a slot index) be recorded
against its channel's intrinsic identity. It is rebuilt wholesale so a reordered,
renamed, re-keyed, or cleared slot is reflected accurately.
Read through the session cache, which is the same slot probe the channel manager is
about to make anyway — so a channel edit costs *one* walk of the device rather than
this one plus the manager's. Its own walk was also unbounded where the shared probe
is not: it skipped empty slots and kept going to the 64-slot cap, so on firmware that
never rejects an index every single edit paid 64 round-trips before the screen could
redraw. That is most of what "applying a change takes a while" was.
"""
device = await self._ctx.device()
ids: dict[int, str] = {}
for idx in range(CHANNEL_SLOT_PROBE_CAP):
try:
payload = await device.get_channel(idx)
except Exception: # noqa: BLE001 - firmware may not support channel reads
break
if not payload:
continue # an empty slot; keep scanning (slots can be non-contiguous)
name = str(payload.get("channel_name") or "")
secret = bytes(payload.get("channel_secret") or b"\x00" * 16)
ids[idx] = channel_identity(name, secret)
self._channel_ids = ids
slots = await self._ctx.devstate.channel_slots()
self._channel_ids = {slot.idx: channel_identity(slot.name, slot.secret) for slot in slots}
async def channel_id_for(self, idx: int) -> str:
"""Resolve a channel slot index to its intrinsic identity, reading the slot fresh.
+24 -3
View File
@@ -94,6 +94,7 @@ class DeviceState:
self._self_info: dict | None = None
self._path_hash_mode: int | None = None
self._channels: list[ChannelSlot] | None = None
self._channels_epoch = 0
self._channel_capacity: int | None = None
# One lock per slow fetch so overlapping first-access callers (two screens opened in
# quick succession) collapse onto a single round-trip instead of each firing their own.
@@ -276,7 +277,9 @@ class DeviceState:
The underlying probe (:func:`~meshterm.ui.channels.read_channel_slots`) walks every
slot index on the firmware, which is one of the slowest reads on a screen open — so it
is well worth reading once. It is best-effort itself (an unsupported firmware yields an
empty list rather than raising), and that result is cached as-is.
empty list rather than raising), but a probe that ended early because a *read failed*
is answered and then dropped rather than cached — see :func:`~meshterm.ui.channels.
probe_channel_slots` for why a short list is not a layout.
Returns:
One :class:`~meshterm.ui.channels.ChannelSlot` per configured slot, in index order.
@@ -284,10 +287,16 @@ class DeviceState:
if self._channels is None:
async with self._channels_lock:
if self._channels is None:
from ..ui.channels import read_channel_slots
from ..ui.channels import probe_channel_slots
device = await self._ctx.device()
self._channels = await read_channel_slots(device)
slots, complete = await probe_channel_slots(device)
if complete:
self._channels = slots
# An incomplete probe is answered but not kept: one timed-out read used
# to poison the whole session, and the shortfall does not announce
# itself — an empty result reads exactly like a device with no channels.
return slots
return self._channels
async def channel_capacity(self) -> int:
@@ -373,9 +382,21 @@ class DeviceState:
"""Drop the cached path-hash mode — call after writing it in the config editor."""
self._path_hash_mode = None
@property
def channels_epoch(self) -> int:
"""How many times the channel layout has been dropped this session.
A caller that wants to know whether *anything* rewrote a slot while it was busy
compares this across the gap rather than trusting a return value: a write that
landed and then raised on its way back moved the device just as much as one that
returned cleanly, and the count it never got to increment cannot say so.
"""
return self._channels_epoch
def invalidate_channels(self) -> None:
"""Drop the cached channel slots — call after the channel editor saves or clears one."""
self._channels = None
self._channels_epoch += 1
def invalidate_config(self) -> None:
"""Drop everything the config editor can change in one call (self-info + routing mode).
+6 -9
View File
@@ -54,7 +54,7 @@ class ChannelsTool(Tool):
params: A ``cli_action`` with its arguments (CLI), or empty for the menu.
Returns:
A :class:`ToolResult` summarizing what happened.
A :class:`ToolResult` recording what happened (the menu path shows nothing).
"""
action = params.get("cli_action")
if action is not None:
@@ -62,14 +62,11 @@ class ChannelsTool(Tool):
from ..ui.channels import manage_channels
changes = await manage_channels(ctx)
message = (
f"[ok]✓[/ok] applied [brand]{changes}[/brand] "
f"channel change{'' if changes == 1 else 's'}"
if changes
else None
)
return ToolResult(summary={"changes": changes}, message=message)
# No message: the manager acknowledged its work on the way out, which meant telling
# the reader that changes they had just watched land were applied. The count still
# goes to the run log through ``summary``, which is the record of what an invocation
# did rather than anything shown.
return ToolResult(summary={"changes": await manage_channels(ctx)})
# -- CLI --------------------------------------------------------------------
+127 -32
View File
@@ -15,9 +15,11 @@ from __future__ import annotations
import asyncio
import time
from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
import typer
from rich.cells import cell_len
from rich.text import Text
from ..context import AppContext
@@ -127,7 +129,16 @@ class ChatTool(Tool):
picker = SelectScreen(
"Chat — pick a conversation",
await self._picker_items(ctx),
delete_hint="Del delete history",
# Enter pushes the chat screen, so the committing verb is `open`. Neither the
# scroll nor the erase atom is written into the base: the select list splices
# each in exactly when its key would act — ←→ only while the highlighted row
# overflows, Del only on a thread that has history to lose — and the three of
# them together are what has to stay inside 72 cells (the purge preview's list
# is the same arithmetic). `Del erase` is that budget's doing: the row already
# names the conversation, so the atom spends its cells on the verb, and `erase`
# is the word the app uses elsewhere for taking data away.
footer_hint="↑↓ move · type to filter · Enter open · Esc back",
delete_hint="Del erase",
)
opened = 0
shown = 0
@@ -179,35 +190,43 @@ class ChatTool(Tool):
# colour rule); a name no contact or stored advert carries stays muted.
key_of = make_name_key_resolver(contacts, ctx.repo.node_names())
# List contacts by recency — those with messages first, newest exchange at the top —
# then the never-contacted ones alphabetically (see _recency_key).
direct = [Conversation(label=c.name, is_channel=False, contact=c) for c in companions]
direct.sort(key=lambda conv: _recency_key(conv, lasts))
# One measurement for the whole list, headings and rows alike, taken before either is
# built: the name lane is only as wide as the names actually in it, and every cell it
# gives back goes to the message preview (see _lanes).
lanes = _lanes(channels + direct)
# The lane names pin for the whole picker (they mean the same in both groups),
# so scrolling into Direct keeps them overhead with that group's heading under
# them, instead of the header vanishing one row in — see Screen.sticky_rows.
items: list = [
Separator(_picker_header, pinned=True),
Separator(lambda w: _picker_header(lanes, w), pinned=True),
section_heading("📡 Channels"),
]
for conversation in channels:
items.append(
Choice(
title=_row_title(ctx, conversation, live, key_of),
title=_row_title(ctx, conversation, live, key_of, lanes),
value=conversation,
# ←→ slide the message alone; the lanes in front of it hold (_Lanes.head).
hscroll_from=lanes.head,
)
)
items.append(section_heading("👤 Direct"))
if companions:
# List contacts by recency — those with messages first, newest exchange at
# the top — then the never-contacted ones alphabetically (see _recency_key).
direct = [Conversation(label=c.name, is_channel=False, contact=c) for c in companions]
direct.sort(key=lambda conv: _recency_key(conv, lasts))
for conversation in direct:
items.append(
Choice(
title=_row_title(ctx, conversation, live, key_of),
title=_row_title(ctx, conversation, live, key_of, lanes),
value=conversation,
# Del offers to delete this thread's stored history —
# only where there is history to delete.
deletable=lasts.get(conversation.key) is not None,
hscroll_from=lanes.head,
)
)
else:
@@ -630,25 +649,87 @@ class _LiveLasts:
return self._cache.get(key)
#: Column width (display cells) the conversation label is padded/ellipsized to, so the unread
#: badge and message preview line up in fixed lanes down the picker.
_LABEL_WIDTH = 22
#: Width of the unread-badge lane between the label and the age (fits ``● 999``).
_BADGE_WIDTH = 5
#: Width of the relative-age lane between the badge and the preview (right-aligned; fits ``now``
#: and two-digit spans like ``59m`` / ``23h``), so every row's message text starts in one column.
_AGE_WIDTH = 3
#: Longest message preview shown before it is ellipsized.
_PREVIEW_WIDTH = 40
#: 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``.
_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
#: buy padding in front of every *short* one. It holds the long-but-ordinary shape of a
#: repeater-style name (``YUL-Cartierville``), a name past it ellipsizes — the row is a way
#: in to the conversation, whose own screen is titled with the whole name — and every cell
#: the ceiling saves goes to the preview.
_LABEL_MAX = 16
def _picker_header(width: int) -> str:
@dataclass(frozen=True)
class _Lanes:
"""The picker's fixed lane widths, measured once per list build.
Both the column header and every row are laid out from one of these, so the headings
cannot drift off the columns they name. Only the first two vary: the marker is the
platform's own glyph width (a channel glyph is double-cell on regular and single on the
PicoCalc console) and the label lane is sized to the longest name the list actually
holds, within :data:`_LABEL_MIN` / :data:`_LABEL_MAX`.
Attributes:
marker: The leading glyph lane — a channel glyph or contact dot plus its space.
label: Cells the conversation name is padded/ellipsized to.
"""
marker: int
label: int
@property
def head(self) -> int:
"""Cells before the preview: everything ←→ leave pinned (:attr:`Choice.hscroll_from`).
Marker, name, unread badge and age, with the two-cell gap that follows each — the
columns that say *which conversation this row is*. The preview is the only run that
slides, which is the point: the lanes are the reader's place in the list and they
already fit.
"""
return self.marker + self.label + 2 + _BADGE_WIDTH + 2 + _AGE_WIDTH + 2
def _lanes(conversations: list[Conversation]) -> _Lanes:
"""Measure the picker's lanes against the conversations it is about to draw.
The name lane used to be a flat 22 cells, which is a wide claim on a 72-column terminal
and a very wide one on the PicoCalc's 53: every cell it holds past the longest name in
the list is padding taken straight out of the last-message preview. Sized to the content
instead, a mesh of ``Alice`` and ``Bob`` keeps its names inside the header word's own
lane and gives everything else to the messages.
Args:
conversations: Every row the list will hold, channels and direct alike.
Returns:
The lane widths for both the header and the rows.
"""
longest = max((cell_len(c.label) for c in conversations), default=0)
return _Lanes(
# The marker is "glyph + space", and the glyph is the platform's: double-cell on
# regular, single on the console font. Read from the widget rather than assumed, so
# the two groups' lanes line up on both platforms — a contact's dot is padded to
# whatever a channel's glyph measures here (see _append_marker).
marker=cell_len(channel_glyph("Public", None)) + 1,
label=max(_LABEL_MIN, min(_LABEL_MAX, longest)),
)
def _picker_header(lanes: _Lanes, width: int) -> str:
"""Column headers over the picker's fixed lanes (see :func:`_title` for the layout).
The five-cell indent covers the select screen's pointer column (2 cells, drawn on choice
rows but not separators) plus the marker lane (3 cells), 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.
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.
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
@@ -657,13 +738,13 @@ def _picker_header(width: int) -> str:
"""
return column_header(
[
Lane("CONVERSATION", _LABEL_WIDTH + 2),
Lane("CONVERSATION", lanes.label + 2),
Lane("UNREAD", _BADGE_WIDTH + 2),
Lane("TIME", _AGE_WIDTH + 2),
Lane(("LAST MESSAGE", "LAST MSG", "LAST")),
],
width,
indent=5,
indent=2 + lanes.marker,
)
@@ -672,6 +753,7 @@ def _row_title(
conversation: Conversation,
lasts: _LiveLasts,
key_of: NameKeyResolver,
lanes: _Lanes,
) -> Callable[[], str | Text]:
"""Return a picker-row title *callable* the select screen re-renders on each repaint.
@@ -684,11 +766,12 @@ def _row_title(
conversation: The conversation the row represents.
lasts: The self-refreshing latest-message view feeding the preview.
key_of: Maps a sender name back to its node's key, for the preview hues.
lanes: The list's measured lane widths.
Returns:
A zero-argument callable producing the current row title.
"""
return lambda: _title(ctx, conversation, lasts, key_of)
return lambda: _title(ctx, conversation, lasts, key_of, lanes)
def _title(
@@ -696,6 +779,7 @@ def _title(
conversation: Conversation,
lasts: _LiveLasts,
key_of: NameKeyResolver,
lanes: _Lanes,
) -> str | Text:
"""Build a picker row as fixed-width, colour-coded lanes.
@@ -713,6 +797,7 @@ def _title(
conversation: The conversation the row represents.
lasts: The self-refreshing latest-message view.
key_of: Maps a preview sender/mention name back to its node's key.
lanes: The list's measured lane widths.
Returns:
The row title as a styled :class:`~rich.text.Text`.
@@ -720,12 +805,12 @@ def _title(
unread = ctx.chat.unread(conversation.key)
last = lasts.get(conversation.key)
text = Text(no_wrap=True, overflow="ellipsis")
_append_marker(text, conversation, last)
_append_marker(text, conversation, last, lanes)
label_style = ""
if not conversation.is_channel and conversation.contact is not None:
contact = conversation.contact
label_style = name_style(conversation.label, contact.public_key or contact.key_prefix)
text.append(fit_cells(conversation.label, _LABEL_WIDTH), style=label_style or None)
text.append(fit_cells(conversation.label, lanes.label), style=label_style or None)
text.append(" ")
# Unread badge lane (_BADGE_WIDTH cells): a red ● with the count in warn, or blank filler so
# the following lanes still line up on rows with nothing unread.
@@ -751,21 +836,28 @@ def _title(
_COMPANION_DOT_STYLE = _NODE_GLYPHS[NODE_TYPE_CHAT][1]
def _append_marker(text: Text, conversation: Conversation, last: ChatMessage | None) -> None:
"""Prepend the row's leading marker (3 display cells) — a channel glyph or a contact dot.
def _append_marker(
text: Text, conversation: Conversation, last: ChatMessage | None, lanes: _Lanes
) -> None:
"""Prepend the row's leading marker — a channel glyph or a contact dot — in its own lane.
A channel keeps its openness marker (# / 🌐 / 🔒). A contact gets a small circle in the
standard companion pink — filled (``●``) once we've exchanged messages, a hollow ring
(``○``) before any — so the hollow-vs-filled shape marks whether there's history while the
name itself carries the person's key-derived hue. The contact dot is padded to the same
width as a channel's double-cell glyph so the labels line up across both sections.
name itself carries the person's key-derived hue.
The dot is padded out to :attr:`_Lanes.marker`, which is measured from the channel glyph
itself rather than assumed: that glyph is double-cell on regular and single-cell on the
PicoCalc console font, so a hard-coded pad lined the two sections up on one platform and
left every channel row a cell to the left of every direct row on the other.
"""
if conversation.is_channel:
text.append(f"{channel_glyph(conversation.label, conversation.secret)} ")
glyph = channel_glyph(conversation.label, conversation.secret)
text.append(glyph + " " * max(1, lanes.marker - cell_len(glyph)))
else:
dot = "●" if last is not None else "○"
text.append(dot, style=_COMPANION_DOT_STYLE)
text.append(" ")
text.append(" " * max(1, lanes.marker - cell_len(dot)))
def _preview_text(last: ChatMessage, key_of: NameKeyResolver) -> Text:
@@ -774,8 +866,12 @@ def _preview_text(last: ChatMessage, key_of: NameKeyResolver) -> Text:
Mirrors the live transcript: our own messages get a ``you:`` prefix, an inbound channel
message's inline ``Name:`` sender is coloured in its key-derived hue (muted when no known
node carries the name), and every ``@[Name]`` mention reads as a bare ``@Name`` the same
way — so the list and the chat speak the same colour language. The result is clipped to
:data:`_PREVIEW_WIDTH` cells.
way — so the list and the chat speak the same colour language.
The preview is built *whole*, however long the message ran. It used to be clipped to a
fixed 40 cells, which is a cut nothing could undo: the row is what ←→ scroll now, and a
preview pre-truncated to roughly the visible lane would have had nothing left to reveal.
The list cuts it at the right edge like any other row; the scroll walks past that.
"""
body_raw = last.text.replace("\n", " ")
text = Text()
@@ -792,7 +888,6 @@ def _preview_text(last: ChatMessage, key_of: NameKeyResolver) -> Text:
_append_body(text, body_raw, key_of)
else:
_append_body(text, body_raw, key_of)
text.truncate(_PREVIEW_WIDTH, overflow="ellipsis")
return text
+398 -114
View File
@@ -40,6 +40,7 @@ from dataclasses import dataclass
from functools import lru_cache
from typing import TYPE_CHECKING
from rich.cells import cell_len
from rich.console import Group
from rich.text import Text
@@ -62,9 +63,20 @@ from ..core.channels import (
from ..core.connection import Device
from ..core.models import Conversation
from ..persistence.repository import ACTIVITY_DRAWN_BUCKETS
from ..platforms import get_platform
from .braillechart import activity_peak, activity_sparkline
from .menus import command_label, fit_cells, menu_rows, run_steps, section_heading
from .menus import (
Lane,
column_header,
fit_cells,
icon_lane,
marked_label,
menu_rows,
run_steps,
section_heading,
)
from .qr import share_popup
from .theme import glyph
from .tui import CANCEL, Choice, SelectScreen, Separator
from .widgets import _age_seconds, _format_age, channel_glyph, format_ago
@@ -72,6 +84,9 @@ if TYPE_CHECKING:
from ..context import AppContext
from ..persistence.repository import ChannelStats
#: The manager's footer sentence: navigation, then the filter, then the action, Esc last.
_MANAGER_HINT = "↑↓ move · type to filter · Enter open · Esc back"
# Top-menu action sentinels (distinct from a plain slot index, which selects that channel).
_CREATE = "__create__"
_PUBLIC = "__public__"
@@ -146,11 +161,24 @@ class ChannelSlot:
async def manage_channels(ctx: AppContext) -> int:
"""Run the interactive channel manager until the user backs out.
The visit says **nothing** on the way out. It used to bank a note per action and then an
"✓ applied 3 channel changes" line on top of those, shown once the manager had already
closed — an acknowledgement for changes the reader had just watched land in the list in
front of them, arriving as a tidy popup or as a full-frame window depending on how many
lines had piled up. An error is the exception, and is shown when it happens (see
:func:`_import_link`).
What the reader gets instead is the wait itself: every device action reports *while it
runs*, under the modal busy card (:meth:`~meshterm.ui.surface.Ui.busy_dialog`) — see
:func:`write_channel` and ``settle`` below for where those are drawn, and why the card
has to be modal.
Args:
ctx: Shared application context (provides the connected device and UI surface).
Returns:
The number of channels created, changed, or cleared during the session.
How many channels were created, changed, or cleared — the run log's record of what
the visit did (``ToolResult.summary``), not anything the reader is shown.
"""
device = await ctx.device()
# The firmware's slot count is fixed for the session, so discover it once (a read-only
@@ -164,7 +192,6 @@ async def manage_channels(ctx: AppContext) -> int:
stats = _LiveStats(ctx)
changes = 0
highlight: object | None = None
slots: list = []
async def reload() -> tuple[str, list]:
@@ -180,13 +207,38 @@ async def manage_channels(ctx: AppContext) -> int:
slots = list(await ctx.devstate.channel_slots())
return _menu_items(ctx, slots, capacity, stats)
async def settle(*, moved: bool = False) -> tuple[str, list]:
"""Re-probe the layout — and, when a slot actually moved, re-file what points at it.
Two device round-trips back to back when something changed (the chat service's slot
map and the slot probe ``reload`` calls one of the slowest reads the app makes), so
they report as a single wait rather than boxes flashing in sequence — and while the
card is up the reader cannot type into a list whose rows are being replaced
underneath them.
Args:
moved: Whether a slot's occupant changed during the round just finished. The
caller decides that by comparing ``devstate.channels_epoch``, not by trusting
a count (see :func:`write_channel`, which drops the cache as it writes).
"""
async with ctx.ui.busy_dialog("reading channels…", title="Channels"):
title_and_items = await reload()
if moved:
# Inbound messages carry only a slot index, which the chat service maps to a
# channel identity through a cache keyed by slot; refresh it now so a message
# on a reused/re-keyed slot is filed under the channel that's actually there
# and not the one that used to be — otherwise its transcript surfaces in the
# wrong chat. After ``reload``, deliberately: it reads the same cached probe,
# so this way round the pair costs one walk of the device instead of two.
await _refresh_chat_channels(ctx)
return title_and_items
async def handle(choice: object) -> bool:
"""Dispatch one menu choice (over the still-pushed list); ``False`` exits."""
nonlocal changes, highlight
if choice is None: # Esc
return False
highlight = choice
before = changes
if choice == _CREATE:
changes += await _create_private(ctx, device, slots, capacity)
elif choice == _DEFAULT_PUBLIC:
@@ -203,17 +255,6 @@ async def manage_channels(ctx: AppContext) -> int:
slot = next((s for s in slots if s.idx == choice), None)
if slot is not None:
changes += await _channel_detail(ctx, device, slot, stats)
if changes > before:
# A slot's occupant changed (created, re-keyed, cleared, or moved). Drop the
# session cache's channel list so the dashboard (and anything else reading it)
# re-reads the new layout instead of a stale copy.
ctx.devstate.invalidate_channels()
# Inbound messages carry only a slot index, which the chat service maps to a
# channel identity through a cache keyed by slot; refresh it now so a message
# on a reused/re-keyed slot is filed under the channel that's actually there
# and not the one that used to be — otherwise its transcript surfaces in the
# wrong chat.
await _refresh_chat_channels(ctx)
return True
# The list stays pushed for the whole visit: every sub-flow — creating a channel,
@@ -221,20 +262,41 @@ async def manage_channels(ctx: AppContext) -> int:
# swapped in place afterwards, so the highlight (and any typed filter) survives a layout
# that changed under it. A surface with no session — the plain CLI, scripted tests — has
# no stack to stay on and runs the same dispatcher a round at a time.
title, items = await reload()
title, items = await settle()
session = getattr(ctx.ui, "session", None)
if session is None:
while True:
if not await _menu_round(ctx, title, items, default=highlight, handle=handle):
epoch = ctx.devstate.channels_epoch
try:
keep = await _menu_round(ctx, title, items, default=highlight, handle=handle)
except BaseException:
# A write that landed and then raised — or a ^W unwinding out through the QR
# view opened after it — moved the device just as much as one that returned
# cleanly. The epoch says so where the action's return value never got the
# chance to, so the chat map is re-filed on the way past.
if ctx.devstate.channels_epoch != epoch:
await _refresh_chat_channels(ctx)
raise
if not keep:
return changes
title, items = await reload()
menu = SelectScreen(title, items)
title, items = await settle(moved=ctx.devstate.channels_epoch != epoch)
# ``Enter open``, not the list default: every slot row pushes the channel's detail
# screen and Reorder pushes the reorder screen, which is what the chat picker — the same
# channels, one screen over — has always said.
menu = SelectScreen(title, items, footer_hint=_MANAGER_HINT)
async with session.stay(menu) as visit:
while True:
choice = await visit.result()
if not await handle(None if choice is CANCEL else choice):
epoch = ctx.devstate.channels_epoch
try:
keep = await handle(None if choice is CANCEL else choice)
except BaseException:
if ctx.devstate.channels_epoch != epoch: # see the note on the other loop
await _refresh_chat_channels(ctx)
raise
if not keep:
return changes
title, items = await reload()
title, items = await settle(moved=ctx.devstate.channels_epoch != epoch)
menu.replace_items(items, title=title)
@@ -300,13 +362,43 @@ async def read_channel_slots(device: Device) -> list[ChannelSlot]:
Returns:
One :class:`ChannelSlot` per configured slot (an empty slot is skipped).
"""
slots, _complete = await probe_channel_slots(device)
return slots
async def probe_channel_slots(device: Device) -> tuple[list[ChannelSlot], bool]:
"""The probe behind :func:`read_channel_slots`, reporting whether it *finished*.
The scan stops for two very different reasons and the plain list cannot tell them
apart. Off the end of the configured slots (a rejected index, or a long enough run of
empty ones) the answer is complete and worth keeping. A read that simply *failed* —
a timeout, a link hiccup, and then every subsequent read failing too because the
firmware's reply no longer matches the slot asked for — ends the scan early with
whatever it happened to have, which is not the device's layout and must not be cached
or acted on as if it were: an empty one reads as "no channels configured", and a
truncated one makes the next free slot look free when a channel is sitting in it.
The two endings raise different things, and that is what separates them: the firmware
*answering* "no such slot" surfaces as a plain rejection, while a link that stopped
answering surfaces as a timeout, a :class:`~meshterm.core.connection.DeviceCommandError`
(whose whole subject is a companion that did not reply in time), or one of the dropped-
link signatures :func:`~meshterm.core.connection.is_connection_lost` knows. Only the
first ending is a layout. A read that fails for some fourth reason is treated as a
rejection, which is the safe way round: the list is used but the cache re-probes.
Args:
device: The connected device to query.
Returns:
The slots read, and whether the scan ran to a clean end.
"""
slots: list[ChannelSlot] = []
empty_run = 0
for idx in range(CHANNEL_SLOT_PROBE_CAP):
try:
payload = await device.get_channel(idx)
except Exception: # noqa: BLE001 - firmware may not support channel reads
break
except Exception as exc: # noqa: BLE001 - a rejected index, or a read that failed
return slots, not _read_failed(exc)
if payload and payload.get("channel_name"):
empty_run = 0
slots.append(
@@ -320,7 +412,19 @@ async def read_channel_slots(device: Device) -> list[ChannelSlot]:
empty_run += 1
if empty_run >= CHANNEL_SLOT_EMPTY_RUN:
break # off the end of a never-rejecting firmware; nothing more to find
return slots
return slots, True
def _read_failed(exc: BaseException) -> bool:
"""Whether ``exc`` means the *read* failed, rather than the firmware refusing a slot.
A refusal is the probe's ordinary ending and leaves a complete list behind it. A
failure leaves a short one that looks exactly the same — which is the whole reason
this distinction has to be drawn somewhere (see :func:`probe_channel_slots`).
"""
from ..core.connection import DeviceCommandError, is_connection_lost
return isinstance(exc, (TimeoutError, DeviceCommandError)) or is_connection_lost(exc)
def _next_free_slot(slots: list[ChannelSlot], capacity: int) -> int | None:
@@ -344,6 +448,11 @@ async def write_channel(
a name-derived channel's derived key so it can later be replayed as-is. Remembering is
best-effort: it never blocks or fails the actual device write.
Being the one boundary, it is also where the *wait* is reported: the write and the
identity probe behind it are a device round-trip each, and until the card went up the
list sat there looking idle and fully interactive while they ran. A batch (a reorder)
nests inside its caller's card rather than flashing one box per slot.
Args:
ctx: Shared application context (for the channel store and cached self-info).
device: The connected device to write to.
@@ -351,6 +460,21 @@ async def write_channel(
name: The channel name (empty clears the slot).
secret: The 16-byte secret, or ``None`` to let the firmware derive it from the name.
"""
caption = f"clearing slot {idx}…" if not name.strip() else f"saving {name.strip()}…"
async with ctx.ui.busy_dialog(caption, title="Channels"):
await _write_and_remember(ctx, device, idx, name, secret)
# The layout on the device is no longer what anything cached, so say so here rather
# than leaving each caller to remember: the menu's manager did (and lost it whenever an
# action raised on its way back), and the four CLI subcommands never did at all. The
# bump also *is* the signal a caller compares across a round — see
# :attr:`~meshterm.services.device_state.DeviceState.channels_epoch`.
ctx.devstate.invalidate_channels()
async def _write_and_remember(
ctx: AppContext, device: Device, idx: int, name: str, secret: bytes | None
) -> None:
"""Do the write itself and the channel-store bookkeeping (see :func:`write_channel`)."""
await device.set_channel(idx, name, secret)
store = ctx.channel_store
if store is None:
@@ -501,24 +625,36 @@ def _activity_sparkline(histogram: tuple[int, ...], peak: float) -> Text:
# --- menus -------------------------------------------------------------------
def _lanes_header(name_w: int) -> str:
def _lanes_header(name_w: int, width: int) -> str:
"""Column headers over the channel list's fixed lanes (see :func:`_slot_text`).
The leading spaces cover the select screen's pointer column (2 cells) plus the glyph
lane (3 cells), 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 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.)
The indent covers the select screen's pointer column (2 cells, drawn on choice rows but
not separators) plus the glyph lane, *measured* rather than assumed: a channel's glyph
is two cells on the desktop and one on the console, so a hard-coded indent put every
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.)
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`).
"""
return (
" "
+ "CHANNEL".ljust(name_w + 2)
+ "UNREAD".ljust(_BADGE_WIDTH + 2)
+ f"{'MSGS':>{_COUNT_WIDTH}}"
+ " "
+ f"{'LAST':>{_AGE_WIDTH}}"
+ " ACTIVITY"
return column_header(
[
Lane("CHANNEL", name_w + 2),
Lane("UNREAD", _BADGE_WIDTH + 2),
# Right-aligned, because the values under them are: a count and an age are
# padded to the right edge of their lane, so a left-aligned label would sit
# off the digits it names.
Lane(f"{'MSGS':>{_COUNT_WIDTH}}", _COUNT_WIDTH + 2),
Lane(f"{'LAST':>{_AGE_WIDTH}}", _AGE_WIDTH + 2),
Lane(("ACTIVITY", "ACT")),
],
width,
indent=2 + cell_len(channel_glyph("Public", None)) + 1,
)
@@ -555,8 +691,9 @@ def _slot_text(ctx: AppContext, slot: ChannelSlot, stats: _LiveStats, name_w: in
text.append(fit_cells(slot.name, name_w))
text.append(" ")
if muted:
text.append("🔕", style="muted") # 2 cells; pad the rest of the lane
text.append(" " * (_BADGE_WIDTH - 2))
mark = glyph("🔕") # two cells on the desktop, one on the console
text.append(mark, style="muted")
text.append(" " * (_BADGE_WIDTH - cell_len(mark)))
elif unread:
text.append("●", style="err")
text.append(f" {unread}".ljust(_BADGE_WIDTH - 1), style="warn")
@@ -568,7 +705,11 @@ def _slot_text(ctx: AppContext, slot: ChannelSlot, stats: _LiveStats, name_w: in
# Clamped so a pathological backlog can't push the row out of its lanes.
text.append(f"{min(total, 99999):>{_COUNT_WIDTH}}")
else:
text.append(f"{'·':>{_COUNT_WIDTH}}", style="muted")
# ``○`` is the app's empty mark. ``·`` here was three things at once: the separator
# that chains status atoms, the picker's unknown-sender stand-in, and — two cells
# away in this very 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(" ")
age = _format_age(_age_seconds(st.last_at)) if st is not None and st.last_at else ""
text.append(f"{age:>{_AGE_WIDTH}}", style="muted")
@@ -593,56 +734,99 @@ def _menu_items(
name_w = min(_NAME_WIDTH_MAX, max(len("CHANNEL"), *(len(s.name) for s in slots)))
# The lane names are this block's only landmark (its section carries no ── heading ──),
# so they pin overhead while the slots scroll and give way to Organize/Add a channel.
items.append(Separator(_lanes_header(name_w), heading=True))
items.append(Separator(lambda w: _lanes_header(name_w, w), heading=True))
for slot in slots:
items.append(Choice(title=_slot_row(ctx, slot, stats, name_w), value=slot.idx))
else:
items.append(Separator(" no channels configured yet"))
items.append(Separator(" no channels yet — add one below"))
# One measured icon column for every command row on this screen, so the one-cell marks
# (↕) start their labels in the same column as the two-cell ones (+ # 🌐 🔑 🔗) instead
# of a column early — which the Organize row used to fix by hand, with two spaces and a
# four-line comment. Empty where the platform draws no icons, and the labels take the
# cells back.
lane = icon_lane(("↕", "🌐", "+", "#", "🔑", "🔗"))
if len(slots) > 1:
items.append(section_heading("Organize"))
# Two spaces after the arrow: ↕ (East-Asian-ambiguous width) renders one cell where
# the sibling rows' glyphs (+ # 🔑 🔗) render two, so the extra space keeps this
# label's text column-aligned with theirs. Both go together where the platform
# draws no icon lane at all.
items.append(Choice(title=command_label("↕ Reorder channels"), value=_REORDER))
reorder = marked_label("↕", "Reorder channels", "", lane=lane)
items.append(Choice(title=reorder, value=_REORDER))
items.append(section_heading("Add a channel"))
if _next_free_slot(slots, capacity) is None:
# The list already knows there is nowhere to put one, so it says so here rather than
# offering four rows that all dead-end in the same refusal.
items.append(Separator(" every slot is full — clear one first"))
return f"Channels · {len(slots)}/{capacity} slots", items
rows = []
# The firmware's built-in fixed-key Public channel has one well-known secret, so it's the
# same channel on every slot — offer to restore it only while no slot already holds it.
if not any(s.secret == DEFAULT_PUBLIC_SECRET for s in slots):
rows.append(
("🌐 Standard Public channel", "MeshCore's built-in meshwide channel", _DEFAULT_PUBLIC)
(
marked_label("🌐", "Standard Public channel", "", lane=lane),
"MeshCore's built-in meshwide channel",
_DEFAULT_PUBLIC,
)
)
rows.extend(
[
("+ New private channel…", "A fresh random key", _CREATE),
("# Public channel…", "Key derived from its name", _PUBLIC),
("🔑 Join with a key…", "Paste a channel's 32-hex key", _JOIN),
("🔗 Import a link…", "Paste a meshcore:// share link", _IMPORT),
(
marked_label("+", "New private channel…", "", lane=lane),
"A fresh random key",
_CREATE,
),
(
marked_label("#", "Public channel…", "", lane=lane),
"Key derived from its name",
_PUBLIC,
),
(
marked_label("🔑", "Join with a key…", "", lane=lane),
"Paste a channel's 32-hex key",
_JOIN,
),
(
marked_label("🔗", "Import a link…", "", lane=lane),
"Paste a meshcore:// share link",
_IMPORT,
),
]
)
items.extend(menu_rows(rows))
return f"Channels — {len(slots)}/{capacity} slots", items
# ``·`` chains a status atom; ``—`` would introduce a subject, and the slot count is not
# what this screen is about (see Contacts, which reads "Contacts · 12 known").
return f"Channels · {len(slots)}/{capacity} slots", items
def _detail_summary(ctx: AppContext, slot: ChannelSlot, stats: _LiveStats) -> str:
"""One line of vital signs for the detail screen: slot, totals, unread, mute, last activity."""
"""One line of vital signs for the detail screen: what it is, then how it has been used.
The openness and the hash lead, having moved here out of the screen's title, which was
carrying them in a parenthesised blob and running to 42 cells — the whole of the
PicoCalc's borderless title bar, leaving no room for the one place that says Esc leaves.
The line is one line. Atoms are shed from the right until it fits the platform's
readable width, because the ones on the left identify the channel and the ones on the
right describe traffic the reader can also see in the row they came from.
"""
st = stats.get(slot.identity)
unread = ctx.chat.unread(slot.conversation.key)
muted = _is_muted(ctx, slot)
kind = "public" if slot.is_public else "private"
parts = [kind, f"hash {slot.hash}", f"slot {slot.idx}"]
if st is None or not st.total:
base = f"Slot {slot.idx} · no messages recorded yet"
return f"{base} · muted" if muted else base
parts = [f"Slot {slot.idx}", f"{st.total} message{'' if st.total == 1 else 's'}"]
if unread:
parts.append(f"{unread} unread")
if muted:
parts.append("no messages yet")
else:
parts.append(f"{st.total} msg{'' if st.total == 1 else 's'}")
if unread:
parts.append(f"{unread} unread")
if _is_muted(ctx, slot):
parts.append("muted")
if st.last_at is not None:
parts.append(f"last message {format_ago(_age_seconds(st.last_at))}")
if st is not None and st.last_at is not None:
parts.append(f"last {format_ago(_age_seconds(st.last_at))}")
width = get_platform().readable_cols
while len(parts) > 1 and cell_len(" · ".join(parts)) > width:
parts.pop()
return " · ".join(parts)
@@ -657,31 +841,49 @@ def _detail_items(ctx: AppContext, slot: ChannelSlot) -> list:
err-tinted label so it reads as such.
"""
unread = ctx.chat.unread(slot.conversation.key)
chat_label = Text("💬 Open in chat")
# ✎ and 🗑 are one cell where 📱 🔑 💬 🔔 🔕 are two, so the column is measured once and
# every mark padded out to it — otherwise the edit and clear rows start their labels a
# column left of the rows above them.
lane = icon_lane(("📱", "🔑", "💬", "🔔", "🔕", "✎", "🗑"))
chat_label = marked_label("💬", "Open in chat", "", lane=lane)
if unread:
chat_label.append(" ●", style="err")
chat_label.append(f" {unread}", style="warn")
if _is_muted(ctx, slot):
mute_row = (
"🔔 Unmute notifications",
marked_label("🔔", "Unmute notifications", "", lane=lane),
"Show new messages here in the unread badge",
_MUTE,
)
else:
mute_row = (
"🔕 Mute notifications",
marked_label("🔕", "Mute notifications", "", lane=lane),
"Hide new messages here from the unread badge",
_MUTE,
)
items = menu_rows(
[
("📱 Show QR code", "Share this channel as a scannable code", _QR),
("🔑 Show key", "The name, key, hash, and share link", _KEY),
(
marked_label("📱", "Show QR code", "", lane=lane),
"Share this channel as a scannable code",
_QR,
),
(
marked_label("🔑", "Show key", "", lane=lane),
"The name, key, hash, and share link",
_KEY,
),
(chat_label, "Read and send messages on this channel", _CHAT),
mute_row,
("✎ Rename / change key…", "Edit the name or paste a different key", _EDIT),
(
Text("🗑 Clear this slot…", style="err"),
marked_label("✎", "Rename / change key…", "", lane=lane),
"Edit the name or paste a different key",
_EDIT,
),
(
# The tint goes on the mark, not the words — and falls back to the words only
# where the platform draws no mark at all (see menus.marked_label).
marked_label("🗑", "Clear this slot…", "err", lane=lane),
"Remove the channel from this device",
_CLEAR,
),
@@ -700,17 +902,34 @@ async def _channel_detail(
pushed while each action's prompts float over it, and the rows and the vital-signs line
are swapped in place afterwards rather than the screen being drawn again. Both are live
(the mute row is a toggle; the summary counts unread and ages the last message), so both
are re-read each round, and the highlight stays on the row that was just used. A
rename/re-key or clear closes the detail (the slot's occupant changed, so the caller
re-reads the device); the read-only actions loop back here.
are re-read each round, and the highlight stays on the row that was just used.
A **clear** closes the page, because the channel it was about is gone. A **rename or
re-key does not**: the channel is still there and this is still its page, so the new name
and key are read back into the title, the summary and the rows, and the reader stays
where they were. The page used to close on both, for no better reason than ``slot`` being
a snapshot that the rename had made stale.
"""
kind = "public" if slot.is_public else "private"
title = f"{slot.name} ({kind}, hash {slot.hash})"
changes = 0
def title_for() -> str:
"""``Feature — subject``: the openness and the hash lead the summary line instead."""
return f"Channel — {slot.name}"
async def reread() -> bool:
"""Re-point ``slot`` at what now occupies its index; ``False`` if nothing does."""
nonlocal slot
fresh = next((s for s in await ctx.devstate.channel_slots() if s.idx == slot.idx), None)
if fresh is None:
return False
slot = fresh
return True
async def handle(choice: object) -> int | None:
"""Run one action; an int closes the detail with that many changes, ``None`` stays."""
nonlocal changes
if choice is None: # Esc
return 0
return changes
if choice == _QR:
await _show_share(ctx, slot.name, slot.secret)
elif choice == _KEY:
@@ -722,9 +941,11 @@ async def _channel_detail(
# detail (whose rows re-render to the new state) without counting a channel change.
_toggle_mute(ctx, slot)
elif choice == _EDIT and await _edit(ctx, device, slot):
return 1
changes += 1
if not await reread(): # pragma: no cover - the slot we just wrote is there
return changes
elif choice == _CLEAR and await _clear(ctx, device, slot):
return 1
return changes + 1 # the channel this page is about is gone; so is the page
return None
# A surface with no session (the plain CLI, scripted tests) has no stack to stay on and
@@ -732,12 +953,12 @@ async def _channel_detail(
session = getattr(ctx.ui, "session", None)
if session is None:
while True:
result = await _menu_round(ctx, title, _detail_items(ctx, slot), handle=handle)
result = await _menu_round(ctx, title_for(), _detail_items(ctx, slot), handle=handle)
if result is not None:
return result
menu = SelectScreen(
title,
title_for(),
_detail_items(ctx, slot),
prompt=_detail_summary(ctx, slot, stats),
)
@@ -747,7 +968,11 @@ async def _channel_detail(
result = await handle(None if choice is CANCEL else choice)
if result is not None:
return result
menu.replace_items(_detail_items(ctx, slot), prompt=_detail_summary(ctx, slot, stats))
menu.replace_items(
_detail_items(ctx, slot),
title=title_for(),
prompt=_detail_summary(ctx, slot, stats),
)
# --- create / join flows -----------------------------------------------------
@@ -757,7 +982,7 @@ async def _create_private(
ctx: AppContext, device: Device, slots: list[ChannelSlot], capacity: int
) -> int:
"""Create a private channel with a fresh random key on the next free slot."""
idx = await _pick_free_slot(ctx, slots, capacity)
idx = await _pick_free_slot(ctx, device, slots, capacity)
if idx is None:
return 0
name = await ctx.ui.text("Channel name:", validate=_nonblank)
@@ -765,7 +990,6 @@ async def _create_private(
return 0
secret = random_secret()
await write_channel(ctx, device, idx, name.strip(), secret)
ctx.ui.note(f"[ok]✓[/ok] created private channel [brand]{name.strip()}[/brand]")
await _show_share(ctx, name.strip(), secret, intro="Share this channel:")
return 1
@@ -773,12 +997,19 @@ async def _create_private(
async def _add_default_public(
ctx: AppContext, device: Device, slots: list[ChannelSlot], capacity: int
) -> int:
"""Add MeshCore's built-in fixed-key ``Public`` channel on the next free slot."""
idx = await _pick_free_slot(ctx, slots, capacity)
"""Add MeshCore's built-in fixed-key ``Public`` channel on the next free slot.
One well-known secret means it is the same channel on every slot, so a second copy is
not a channel but a duplicate row. The menu already drops this action once a slot holds
it; this is the same check where the action *runs*, for a press that was on its way while
the first one was still being written.
"""
if any(s.secret == DEFAULT_PUBLIC_SECRET for s in slots):
return 0
idx = await _pick_free_slot(ctx, device, slots, capacity)
if idx is None:
return 0
await write_channel(ctx, device, idx, "Public", DEFAULT_PUBLIC_SECRET)
ctx.ui.note("[ok]✓[/ok] added the standard [brand]Public[/brand] channel")
return 1
@@ -786,7 +1017,7 @@ async def _add_public(
ctx: AppContext, device: Device, slots: list[ChannelSlot], capacity: int
) -> int:
"""Create a public channel whose key is derived from its (``#``-prefixed) name."""
idx = await _pick_free_slot(ctx, slots, capacity)
idx = await _pick_free_slot(ctx, device, slots, capacity)
if idx is None:
return 0
raw = await ctx.ui.text(
@@ -801,7 +1032,6 @@ async def _add_public(
name = f"#{name}"
secret = derive_secret(name) # what the firmware will compute; kept for the QR/share
await write_channel(ctx, device, idx, name, None) # None => firmware derives the key from name
ctx.ui.note(f"[ok]✓[/ok] added public channel [brand]{name}[/brand]")
await _show_share(ctx, name, secret, intro="Share this channel:")
return 1
@@ -815,7 +1045,7 @@ async def _join_with_key(
steps back to the name with what was typed still in the field, rather than throwing
both away — a 32-hex key is a long thing to mistype.
"""
idx = await _pick_free_slot(ctx, slots, capacity)
idx = await _pick_free_slot(ctx, device, slots, capacity)
if idx is None:
return 0
answers = await run_steps(
@@ -833,7 +1063,6 @@ async def _join_with_key(
name, key = answers
secret = normalize_secret(key)
await write_channel(ctx, device, idx, name.strip(), secret)
ctx.ui.note(f"[ok]✓[/ok] joined [brand]{name.strip()}[/brand]")
return 1
@@ -841,7 +1070,7 @@ async def _import_link(
ctx: AppContext, device: Device, slots: list[ChannelSlot], capacity: int
) -> int:
"""Import a channel from a pasted ``meshcore://channel/add`` link."""
idx = await _pick_free_slot(ctx, slots, capacity)
idx = await _pick_free_slot(ctx, device, slots, capacity)
if idx is None:
return 0
url = await ctx.ui.text("Paste a meshcore:// channel link:", validate=_valid_link)
@@ -849,11 +1078,10 @@ async def _import_link(
return 0
parsed = parse_share_url(url)
if parsed is None: # pragma: no cover - guarded by the validator
ctx.ui.note("[err]not a valid channel link[/err]")
await _say(ctx, "not a valid channel link", "err")
return 0
name, secret = parsed
await write_channel(ctx, device, idx, name, secret)
ctx.ui.note(f"[ok]✓[/ok] imported [brand]{name}[/brand]")
return 1
@@ -890,7 +1118,6 @@ async def _edit(ctx: AppContext, device: Device, slot: ChannelSlot) -> bool:
name = name.strip()
secret = normalize_secret(key) if key.strip() else None
await write_channel(ctx, device, slot.idx, name, secret)
ctx.ui.note(f"[ok]✓[/ok] updated channel [brand]{name}[/brand]")
return True
@@ -913,7 +1140,6 @@ async def _clear(ctx: AppContext, device: Device, slot: ChannelSlot) -> bool:
ctx.chat.set_active(slot.conversation.key) # drop its unread before the slot goes away
ctx.chat.set_active(None)
await write_channel(ctx, device, slot.idx, "", None) # empty name => the slot reads as unused
ctx.ui.note(f"[warn]cleared channel {slot.name}[/warn]")
return True
@@ -938,14 +1164,22 @@ async def _apply_order(
in-memory snapshot, so the interleaved writes never clobber a not-yet-placed channel.
"""
indices = sorted(s.idx for s in slots) # the physical slots to fill, ascending
changes = 0
for target_idx, pos in zip(indices, order, strict=True):
slot = slots[pos]
if slot.idx == target_idx:
continue # already in place; no write needed
await _write_slot(ctx, device, target_idx, slot)
changes += 1
return changes
moves = [
(target_idx, slots[pos])
for target_idx, pos in zip(indices, order, strict=True)
if slots[pos].idx != target_idx # already in place; no write needed
]
if not moves:
return 0
# One card for the whole batch, retitled as it walks: this is the longest thing the
# manager does — a write per moved channel, each a device round-trip — and the one place
# where a count is worth showing, because the reader can see it is progressing rather
# than stuck. The per-write cards inside nest into this one.
async with ctx.ui.busy_dialog("reordering channels…", title="Channels") as busy:
for position, (target_idx, slot) in enumerate(moves, start=1):
busy.message = f"moving {slot.name} · {position}/{len(moves)}"
await _write_slot(ctx, device, target_idx, slot)
return len(moves)
async def _reorder_channels(ctx: AppContext, device: Device, slots: list[ChannelSlot]) -> int:
@@ -960,7 +1194,16 @@ async def _reorder_channels(ctx: AppContext, device: Device, slots: list[Channel
# the channels automatically — reordering needs no history migration. The slot→identity
# cache the chat service resolves inbound messages through is refreshed centrally by
# manage_channels once any change lands (see :func:`_refresh_chat_channels`).
return await _apply_order(ctx, device, slots, order)
try:
return await _apply_order(ctx, device, slots, order)
except Exception as exc: # noqa: BLE001 - reported here, where the half-done state is
# A relay is a sequence of writes with no transaction under it, so a link that drops
# partway leaves the slots between the two orders. That is worth saying plainly and
# at once: the manager re-reads on the way out of this round, so what the reader is
# about to look at *is* the half-applied layout, and nothing else would tell them so.
ctx.log.debug("channels: reorder stopped partway: %s", exc)
await _say(ctx, f"reorder stopped partway — {exc}", "err")
return 0
# --- shared views ------------------------------------------------------------
@@ -1016,19 +1259,60 @@ async def _open_chat(ctx: AppContext, slot: ChannelSlot) -> None:
await open_chat(ctx, slot.conversation)
async def _pick_free_slot(ctx: AppContext, slots: list[ChannelSlot], capacity: int) -> int | None:
"""Return the next free slot, warning (and returning ``None``) if all are full."""
async def _pick_free_slot(
ctx: AppContext, device: Device, slots: list[ChannelSlot], capacity: int
) -> int | None:
"""Return a slot that is *confirmed* empty, or ``None`` (having said why) if there is none.
The list this picks from is a snapshot, and a snapshot can be short of the truth: a slot
probe that failed partway returns the slots it managed to read, so a slot holding a
channel the probe never reached looks free. Every add flow lands here, and what follows
it is an unconditional write — so the slot is read back one more time before it is handed
out. A channel is not something to overwrite on the strength of a list that might be
missing a row.
Args:
ctx: Shared application context (for the message surfaces).
device: The connected device, for the confirming read.
slots: The slots as last read.
capacity: The device's slot count.
Returns:
A free slot index, or ``None`` when there is none to give.
"""
idx = _next_free_slot(slots, capacity)
if idx is None:
await ctx.ui.view(
Text.from_markup(
"[warn]All channel slots are full.[/warn] Clear one first, then try again."
),
title="No free slot",
)
await _say(ctx, "all channel slots are full — clear one first, then try again", "warn")
return None
try:
async with ctx.ui.busy_dialog(f"checking slot {idx}…", title="Channels"):
occupant = await device.get_channel(idx)
except Exception as exc: # noqa: BLE001 - unreadable is not provably free
ctx.log.debug("channels: could not confirm slot %s is free: %s", idx, exc)
await _say(ctx, f"could not read slot {idx} — nothing was written", "err")
return None
if occupant and occupant.get("channel_name"):
await _say(ctx, f"slot {idx} is in use — reopen Channels to see what is there", "err")
return None
return idx
async def _say(ctx: AppContext, text: str, style: str) -> None:
"""Tell the reader something *now*, in a popup over the manager it happened in.
An in-visit outcome is not a tool result: it belongs to the action in front of the
reader, so it goes to the dialog surface rather than into the buffer the menu drains
once the whole tool has finished (which is where a failed import used to leave it).
"""
mark = {"err": "✗", "warn": "⚠"}.get(style, "")
body = Text(f"{mark} {text}" if mark else text, style=style)
session = getattr(ctx.ui, "session", None)
if session is None: # the scripted CLI has no dialog surface; print it
ctx.ui.note(f"[{style}]{body.plain}[/{style}]")
return
await session.message_dialog(body, title="Channels")
# --- validators --------------------------------------------------------------
+208 -21
View File
@@ -15,6 +15,14 @@ merely detected). Everything else follows in discovery order.
Selecting a device runs an immediate smoke test (via the ``verify`` callback): a genuine
MeshCore companion is remembered — forever — as confirmed and becomes the session's active
device; anything else sends the user back to the list to choose another.
A machine with things permanently plugged into it that are not companions — a debug probe, a
programmer, a serial adapter — shows them here every single time, in front of the one device
the user actually wants. So a row can be **hidden**: ``h`` drops the highlighted device from
this list and remembers that (see :meth:`~meshterm.core.device_store.DeviceStore.hide`), and
``⇧H`` brings every hidden one back. It is a *listing* choice and nothing more: a hidden
device is still remembered, still reachable by ``--port``, and un-hides itself the moment it
is connected to again.
"""
from __future__ import annotations
@@ -35,8 +43,10 @@ from ..core.discovery import (
serial_device,
tcp_device,
)
from ..platforms import get_platform
from .logo import load_logo
from .tui import Choice, DeleteRequest, Separator
from .menus import Lane, column_header, fit_cells
from .tui import Choice, DeleteRequest, KeyRequest, Separator
if TYPE_CHECKING:
from .surface import Ui
@@ -57,6 +67,13 @@ _QUIT = object()
#: companions aren't discoverable, so they're named by hand) and smoke-tests the result.
_ADD_TCP = object()
#: The two shortcut tokens the splash declares, and the keys that raise them. ``h`` acts on
#: the highlighted row; ``⇧H`` is screen-wide, because a hidden row is not there to press a
#: key on — the way back has to be reachable from anywhere in the list.
_HIDE = object()
_SHOW_ALL = object()
_SHORTCUTS = {"h": _HIDE, "H": _SHOW_ALL}
#: TYPE-column glyphs marking how a device connects. Kept as module constants so the splash's
#: look can be retuned without touching the row-building logic. ``ᛒ`` is the *Bjarkan* rune the
#: Bluetooth logo is drawn from — rendered white on the Bluetooth blue (see the ``bluetooth``
@@ -280,6 +297,9 @@ async def prompt_device(
which the caller treats as a request to exit.
"""
scanned = list(devices)
#: The row the *next* redraw should open on, when the pass just finished moved the list
#: under the reader (see :func:`_after_hiding`). Cleared as soon as it is spent.
focus: DiscoveredDevice | None = None
while True:
remembered = store.load()
# The full registry (not just the single last device) so *every* confirmed companion
@@ -297,23 +317,40 @@ async def prompt_device(
# produced by the scan, so fold those in too — after the scanned set, so a port
# pyserial *does* enumerate keeps its richer scanned row rather than the bare profile.
listed += _profile_serial_devices(listed, profiles)
# Preselect the remembered "last known good" device when it is currently attached/in range.
default = next((d for d in listed if remembered and remembered.matches(d)), None)
# Devices the user has told this splash to stop showing (h). Read each pass, so a
# row hidden a moment ago is gone the next time the list is drawn — which is the
# only feedback hiding needs.
hidden = store.hidden_ids()
# In display order, so a shortcut acting on a row can say what the row *after* it is.
ordered = _order([d for d in listed if d.stable_id not in hidden], registry)
# Preselect the remembered "last known good" device when it is currently attached/in
# range — unless the last pass asked for a particular row, which a hide does so the
# highlight lands where the vanished row was rather than jumping back to the default.
default = next((d for d in ordered if remembered and remembered.matches(d)), None)
if focus is not None:
default = focus if focus in ordered else default
focus = None
# Build the rows once so the same list can be redrawn as the backdrop behind a
# removal confirm (so it floats over the picker rather than replacing it).
items = _build_items(listed, remembered, registry)
items = _build_items(ordered, remembered, registry, hidden=len(hidden))
chosen = await ui.select_startup(
"Select a companion device",
items,
default=default,
banner=load_logo(),
keys=_SHORTCUTS,
key_hint=_shortcut_hint(len(hidden)),
)
# Esc (``None``) and the Quit row both mean "leave the picker" — surface that to the
# caller as ``None`` so it can exit the program instead of continuing device-less.
if chosen is None or chosen is _QUIT:
return None
if isinstance(chosen, KeyRequest):
focus = _run_shortcut(store, chosen, ordered)
continue
if chosen is _ADD_TCP:
# Name a network companion by hand and smoke-test it. On success it's returned like
# any picked device; on cancel/failure we loop back to the list.
@@ -344,6 +381,93 @@ async def prompt_device(
return chosen
def _run_shortcut(
store: DeviceStore, request: KeyRequest, listed: list[DiscoveredDevice]
) -> DiscoveredDevice | None:
"""Act on ``h`` / ``⇧H``, and say which row the redrawn list should open on.
Nothing is *said* about either: after a hide the row is gone, after a show-all the hidden
rows are back, and a dialog acknowledging a change the reader is looking straight at is
the acknowledgement this app takes out of screens rather than adding to them. What the
redraw does owe them is their place — see :func:`_after_hiding`.
Args:
store: The registry the hidden set lives in.
request: The shortcut the splash resolved with.
listed: The devices as displayed, in display order.
Returns:
The device to highlight when the list is redrawn, or ``None`` to let the remembered
default decide (which is what a show-all wants: the list it restores is a different
list, and the reader's place in the old one means nothing in it).
"""
if request.action is _SHOW_ALL:
store.show_all()
return None
if request.action is _HIDE and isinstance(request.value, DiscoveredDevice):
# Only a device row can be hidden — ``h`` on Add a network device or Quit is inert,
# the way Delete is on a row that never opted into removal.
store.hide(request.value.stable_id)
return _after_hiding(listed, request.value)
return None
def _after_hiding(
listed: list[DiscoveredDevice], hidden: DiscoveredDevice
) -> DiscoveredDevice | None:
"""The row the highlight should land on once ``hidden`` leaves the list.
The next device down, so a run of adapters can be cleared with a run of presses without
the hand moving; the one above where there is no next, because the highlight has reached
the end and there is nowhere further down to go. Neither ever rolls round to the top —
nothing in the app does — and an emptied list has nothing to land on at all.
"""
remaining = [d for d in listed if d.stable_id != hidden.stable_id]
if not remaining:
return None
at = next((i for i, d in enumerate(listed) if d.stable_id == hidden.stable_id), 0)
return remaining[min(at, len(remaining) - 1)]
def _shortcut_hint(hidden: int) -> Callable[[object], str]:
"""Name the hide shortcuts that would actually do something right here, right now.
Asked of the highlighted row on every paint (see ``key_hint`` on
:class:`~meshterm.ui.tui.select.SelectScreen`), so the footer follows the same rule
``Del remove`` does: a key is named where it acts and nowhere else.
* ``h hide`` only on a device row — on *Add a network device* or *Quit* there is nothing
to hide, and the key is inert there.
* ``⇧H show all`` only while something is actually hidden. It is screen-wide rather than
per-row (a hidden row is not there to press a key on), so it rides whichever row the
reader is standing on — but it stays off the line entirely while it would do nothing,
which is also what keeps the splash from ever naming a key nobody could act on.
"""
def atoms_for(value: object) -> str:
atoms = []
if isinstance(value, DiscoveredDevice):
atoms.append("h hide")
if hidden:
atoms.append("⇧H show all")
return " · ".join(atoms)
return atoms_for
def _hidden_note(hidden: int) -> Separator:
"""The muted row that stands in for a list hiding has emptied.
Only then. With devices still listed, the footer already names ``⇧H`` on every row and a
standing line saying the same thing is a row of the box spent on chrome. With *nothing*
listed there is no footer atom to read it off — and "no companion devices detected" would
be a lie about a machine with a radio plugged into it.
"""
it = "it" if hidden == 1 else "them"
device = "device" if hidden == 1 else "devices"
return Separator(f" {hidden} {device} hidden — press ⇧H to show {it}")
def _where_phrase(device: DiscoveredDevice) -> str:
"""A human phrase for a device's transport, woven into the smoke-test spinner line."""
if device.is_tcp:
@@ -541,6 +665,40 @@ async def _remove_network_device(
store.forget(device.stable_id)
#: The narrowest the HARDWARE column is worth keeping before the name lane has to give way.
#: Below this a model string says nothing at all, and the lane is better spent on the name.
_HARDWARE_MIN = 10
def _name_width(
devices: list[DiscoveredDevice], registry: dict[str, RememberedDevice], other_lanes: int
) -> int:
"""The DEVICE lane's width: the longest name, capped so HARDWARE keeps a readable column.
A USB adapter's own product string runs to forty-odd characters ("CP2102 USB to UART
Bridge Controller"), and the lane used to size itself to whichever of those was longest.
On a 53-column console that pushed every other lane off the right-hand edge — the row
ended mid-port, and the HARDWARE column that says what the thing *is* was not on screen
at all, nor reachable by scrolling, because the pinned head was already wider than the
box. So the name is the lane that gives: it ellipsizes, and the columns after it survive.
Args:
devices: The devices being listed.
registry: Confirmed companions, for their remembered names.
other_lanes: Cells the PORT and TYPE lanes take between them.
Returns:
The name lane's width in cells.
"""
platform = get_platform()
# What the splash's box gives a row: the terminal, less the gutter it floats over, less
# its own border and padding, less the pointer and star columns the row leads with.
content = platform.readable_cols - platform.dialog_margin - 4 - 4
fixed = other_lanes + 6 # the three two-cell gaps between the four lanes
longest = max(cell_len(_display_name(d, registry)) for d in devices)
return max(len("DEVICE"), min(longest, content - fixed - _HARDWARE_MIN))
def _order(
devices: list[DiscoveredDevice], registry: dict[str, RememberedDevice]
) -> list[DiscoveredDevice]:
@@ -560,6 +718,8 @@ def _build_items(
devices: list[DiscoveredDevice],
remembered: RememberedDevice | None,
registry: dict[str, RememberedDevice],
*,
hidden: int = 0,
) -> list:
"""Build the aligned splash rows (a muted header + one :class:`Choice` per device).
@@ -569,17 +729,33 @@ def _build_items(
vendor); columns are padded to a shared width so they align. Confirmed companions sort to
the top (most-recent first), wear their name in white and a bright tag; the remembered
default is starred. Trailing rows let the user name a network device by hand and quit here.
A device row's fixed lanes are pinned and its HARDWARE tail scrolls: a firmware model
string is as long as its vendor felt like making it, and on a narrow terminal the column
that says *what the thing actually is* was the one being cut off. ←→ read it to its end,
on the highlighted row alone, and only while that row overflows.
Args:
devices: The devices to list — already less anything hidden.
remembered: The last-connected device, starred and preselected when present.
registry: Every confirmed companion, keyed by stable id.
hidden: How many devices were left out for being hidden. Only the empty state uses
it, to tell "nothing is plugged in" apart from "you hid all of it".
"""
if not devices:
# Nothing attached or in range — but a TCP companion can still be reached by hand, so
# show a muted note over the same action rows rather than a dead-end.
note = Separator(" no companion devices detected — add a network device, or quit")
# show a muted note over the same action rows rather than a dead-end. When the list is
# empty *because everything in it is hidden*, say that instead: "nothing detected"
# would be a lie, and the way back is a key the reader has to be told about.
note = (
_hidden_note(hidden)
if hidden
else Separator(" no companion devices detected — add a network device, or quit")
)
return [note, *_action_rows()]
devices = _order(devices, registry)
known: set[str] = set(registry)
name_w = max(cell_len(_display_name(d, registry)) for d in devices)
name_w = max(name_w, len("DEVICE"))
# The middle column holds a serial port, a BLE address, or a TCP host:port; label it for
# whichever kinds are present so a non-serial endpoint never sits under a bare "PORT"
# heading (a BLE address and a network host:port both read as an "address").
@@ -593,21 +769,26 @@ def _build_items(
# The TYPE column holds a small transport badge (at most 3 cells); its heading is wider,
# so the four-cell "TYPE" label sets the column width and every badge pads out to it.
type_w = len("TYPE")
name_w = _name_width(devices, registry, port_w + type_w)
hardware_w = max(cell_len(_hardware_label(d, registry)) for d in devices)
hardware_w = max(hardware_w, len("HARDWARE"))
# A muted, aligned header. The leading spaces mirror the row pointer (2) and the star
# column (2) so the labels sit above their columns. It leads the device rows as their
# landmark, so a long detection list keeps the lane names overhead as it scrolls.
# A muted, aligned header, resolved against the render width because it is *pinned*: it
# leads the device rows as their landmark, so a long detection list keeps the lane names
# overhead as it scrolls — and a pinned row that wraps is drawn outside the body slice,
# where the second line costs the list a device. The indent mirrors the row pointer (2)
# and the star column (2) so each label sits over its own lane.
header = Separator(
" "
+ _pad("DEVICE", name_w)
+ " "
+ _pad(port_label, port_w)
+ " "
+ _pad("TYPE", type_w)
+ " "
+ "HARDWARE",
lambda width: column_header(
[
Lane("DEVICE", name_w + 2),
Lane((port_label, "PORT" if has_serial else "ADDRESS"), port_w + 2),
Lane("TYPE", type_w + 2),
Lane(("HARDWARE", "HW")),
],
width,
indent=4,
),
heading=True,
)
@@ -620,7 +801,8 @@ def _build_items(
row.append(" ")
# A confirmed companion wears its name in white so it stands out from mere detections.
row.append(
_pad(_display_name(device, registry), name_w), style="device.known" if is_known else ""
_pad(fit_cells(_display_name(device, registry), name_w), name_w),
style="device.known" if is_known else "",
)
row.append(" ")
row.append(_pad(_where(device), port_w), style="muted")
@@ -633,6 +815,11 @@ def _build_items(
row.append_text(cell)
row.append(" " * max(0, type_w - cell.cell_len))
row.append(" ")
# Everything up to here is the row's fixed lanes — the reader's place in the list —
# so it is pinned and only what follows slides under ←→ (see Choice.hscroll_from).
# What follows is the hardware model and its tag, which is the part that runs long:
# a firmware model string is as long as its vendor felt like making it.
head = row.cell_len
row.append(_pad(_hardware_label(device, registry), hardware_w), style="muted")
# Only devices we've actually confirmed are billed as MeshCore companions; a USB
# vendor ID (or a BLE advert) is a sort hint, not a claim. A bare serial bridge earns
@@ -652,7 +839,7 @@ def _build_items(
# Only a network device opts into Delete-to-remove: it's listed solely from its
# remembered endpoint, so forgetting it is the only way it leaves the picker. A scanned
# serial/BLE device would just reappear, so Delete stays inert on those rows.
items.append(Choice(title=row, value=device, deletable=device.is_tcp))
items.append(Choice(title=row, value=device, deletable=device.is_tcp, hscroll_from=head))
items.extend(_action_rows())
return items
+57 -28
View File
@@ -527,7 +527,7 @@ class PathLine:
if seam is not None and len(groups) > 1:
# The outbound leg's own last line still continues into the return, so it
# holds the cue back where the whole path's final line doesn't.
legs = self._flow(self._hops[:seam], budget, plain, len(cue), len(cue))
legs = self._flow(self._hops[:seam], budget, plain, len(cue), len(cue), ends=False)
legs += self._flow(self._hops[seam:], budget, plain, len(cue), 0, carry_in=True)
if len(legs) <= len(groups): # the turn folds for free — take it
groups = legs
@@ -582,7 +582,7 @@ class PathLine:
full = self.text().cell_len
if not self._hops:
return Measurement(full, full)
cells, _join, tail, lead, _head = self._measure(
cells, _join, tail, lead, _head, _foot = self._measure(
self._hops, self._resolved_mode() != "powerline"
)
least = max(cells) + tail + lead + WRAP_OFFSET
@@ -590,7 +590,9 @@ class PathLine:
# --- where the breaks fall --------------------------------------------------------
def _measure(self, hops: list[PathHop], plain: bool) -> tuple[list[int], int, int, int, int]:
def _measure(
self, hops: list[PathHop], plain: bool
) -> tuple[list[int], int, int, int, int, int]:
"""Each hop's own width, plus what joining, opening and closing a line costs.
Fitting hops to a column is arithmetic once every hop has been measured once —
@@ -602,24 +604,26 @@ class PathLine:
plain: Measure as arrow text rather than as chips.
Returns:
``(cells, join, tail, lead, head)`` — each hop's cells, the cells one join
between two hops costs (chip mode's seam is the single interlocked chevron —
and so is either side of an elision gap, so the figure holds there too), the
cells a line always closes with (chip mode's edge; nothing in arrow mode), the
cells every line *after the first* opens with (chip mode's notch, the seam
redrawn), and the cells the *first* line opens with (chip mode's rounded cap,
nothing where the font has none).
``(cells, join, tail, lead, head, foot)`` — each hop's cells, the cells one
join between two hops costs (chip mode's seam is the single interlocked
chevron — and so is either side of an elision gap, so the figure holds there
too), the cells a line the path *runs on past* closes with (chip mode's
point; nothing in arrow mode), the cells every line *after the first* opens
with (chip mode's notch, the seam redrawn), and the two outer caps: what the
*first* line opens with and what the line that *ends* the path closes with —
one cell each where the font has the rounded caps, nothing where it hasn't,
a squared end being drawn by appending nothing at all.
"""
if plain:
plain_cells = [self._plain_hop(hop).cell_len for hop in hops]
return plain_cells, cell_len(self._separator), 0, 0, 0
return plain_cells, cell_len(self._separator), 0, 0, 0, 0
widths = [
cell_len(hop.label) if hop.gap else self._chip(hop, self._chip_fill(hop)).cell_len
for hop in hops
]
sep = cell_len(POWERLINE_SEP)
head = cell_len(POWERLINE_ROUND_OPEN) if powerline_full() else 0
return widths, sep, sep, sep, head
cap = cell_len(POWERLINE_ROUND_OPEN) if powerline_full() else 0
return widths, sep, sep, sep, cap, cap
@staticmethod
def _fill(
@@ -631,13 +635,15 @@ class PathLine:
last: int,
lead: int = 0,
head: int = 0,
foot: int = 0,
ends: bool = True,
) -> list[int]:
"""Pack hop widths into lines of ``budget`` cells, greedily, never splitting one.
Args:
cells: Each hop's width, in order (see :meth:`_measure`).
join: Cells one join between two hops costs.
tail: Cells every line closes with.
tail: Cells a line the path runs on past closes with.
budget: The content column's width in cells.
reserve: Cells held back on a line for the continuation cue it will carry.
last: Cells held back on a line ending at the *final* hop — ``0`` when the
@@ -646,6 +652,12 @@ class PathLine:
lead: Cells every line but the first opens with — the reopened seam plus
the :data:`WRAP_OFFSET` step it hangs past the indent.
head: Cells the first line opens with (the rounded cap, when drawn).
foot: Cells the line that *ends the path* closes with (the rounded cap, when
drawn) — charged in place of ``tail``, a finished path closing on a cap
or, where the font has none, on nothing at all.
ends: These hops finish the path, so their last line pays ``foot``. ``False``
where they are one leg of a route that goes on and every line, the last
included, still closes on the point.
Returns:
How many hops each line takes; a hop too wide for ``budget`` gets a line of
@@ -655,9 +667,11 @@ class PathLine:
count = 0
width = head
for i, cell in enumerate(cells):
final = i == len(cells) - 1
grown = width + cell + (join if count else 0)
held = last if i == len(cells) - 1 else reserve
if count and grown + tail + held > budget:
held = last if final else reserve
close = foot if final and ends else tail
if count and grown + close + held > budget:
sizes.append(count)
width, count = lead + cell, 1
else:
@@ -673,6 +687,7 @@ class PathLine:
reserve: int,
last: int,
carry_in: bool = False,
ends: bool = True,
) -> list[list[PathHop]]:
"""Break ``hops`` into the fewest lines, then even those lines out.
@@ -692,25 +707,31 @@ class PathLine:
last: Cells held back on the line the hops end on (see :meth:`_fill`).
carry_in: These hops are a *later* leg of a path already under way, so
even their first line opens as a continuation, not as a beginning.
ends: These hops finish the path, so their last line closes on the outer cap
rather than on the continuation point (see :meth:`_fill`).
Returns:
The hops grouped per line.
"""
cells, join, tail, lead, head = self._measure(hops, plain)
cells, join, tail, lead, head, foot = self._measure(hops, plain)
lead += WRAP_OFFSET # every continuation steps in past the hanging indent
if carry_in:
head = lead
lines = len(self._fill(cells, join, tail, budget, reserve, last, lead, head))
def fill(column: int) -> list[int]:
return self._fill(cells, join, tail, column, reserve, last, lead, head, foot, ends)
lines = len(fill(budget))
low, high = 1, budget
while low < high: # the narrowest column still taking `lines` rows
mid = (low + high) // 2
if len(self._fill(cells, join, tail, mid, reserve, last, lead, head)) <= lines:
if len(fill(mid)) <= lines:
high = mid
else:
low = mid + 1
groups: list[list[PathHop]] = []
at = 0
for size in self._fill(cells, join, tail, low, reserve, last, lead, head):
for size in fill(low):
groups.append(hops[at : at + size])
at += size
return groups
@@ -825,12 +846,18 @@ class PathLine:
"""Powerline chips: each hop filled with its hue, a two-cell gap at every seam.
The two outer ends say whether this line *is* the path or only part of it. A
line that opens the path opens rounded (squared off where the font has no
rounded caps); one that continues a wrapped path opens on the break's other
half — which is exactly the seam's own second cell, so a fold looks like the
seam it interrupted. A line that ends the path closes rounded; one the path
outruns closes on the point, the same "goes on" cue arrow mode spells with a
trailing ``→``.
line that opens the path opens rounded; one that continues a wrapped path opens
on the break's other half — which is exactly the seam's own second cell, so a
fold looks like the seam it interrupted. A line that ends the path closes
rounded; one the path outruns closes on the point, the same "goes on" cue arrow
mode spells with a trailing ``→``.
Where the font has no rounded caps both outer ends are drawn **square** — the
chip's own pad is the edge, and nothing is appended. Square is not a degraded
cap, it is the one shape left that says *stop*: the point is spoken for as the
continuation cue, so closing a finished path on one claimed a hop had been cut
off (JP, 2026-09-09). Only the *opening* used to square itself off, which left
every route on a core-only terminal ending on the mark for "there is more".
An elision (:attr:`PathHop.gap`) interrupts the ribbon rather than joining it: the
chip before it closes on the page, the mark sits on the page bare, and the chip
@@ -863,8 +890,10 @@ class PathLine:
text.append_text(self._chip(hop, fills[i]))
if hops[-1].gap:
return text # the line ended on the page; there is no chip left to close
close = POWERLINE_SEP if carry_on or not rounded else POWERLINE_ROUND_CLOSE
text.append(close, style=fills[-1]) # the edge into the page: pointed or round
if carry_on:
text.append(POWERLINE_SEP, style=fills[-1]) # the point: the path goes on
elif rounded:
text.append(POWERLINE_ROUND_CLOSE, style=fills[-1]) # the lozenge's far end
return text
@staticmethod
+43 -3
View File
@@ -51,7 +51,7 @@ from .mapcanvas import RGB, MapCanvas
from .menus import fit_cells, marked_label, section_heading
from .pathgraph import PathLayer, render_path_graph
from .pathline import path_line
from .theme import name_style, snr_style
from .theme import glyph, name_style, snr_style
from .tui.render import render_hanging, render_to_ansi
from .tui.screen import Screen
from .widgets import (
@@ -102,6 +102,42 @@ _SIDE_BY_SIDE_MIN = 44
_LABEL_W = 12
def discipline_label(category: Category, lane: int) -> str:
"""One discipline's mark padded to ``lane`` cells, then its title.
THE way a board's name is written, so the seven headings — and the seven rows of the
delete picker — start their titles in one column instead of two. The marks are not all
the same width: ``🛣`` and ``🕸`` sit outside Emoji_Presentation and are one cell where
the other five are two, which is what left ``🛣 Longest distance`` a column adrift of
``🎯 Farthest node``, and its gap looking closed on a terminal that paints the road
wider than it advances (JP, 2026-09-09).
The same rule as a command row's icon column (:func:`~meshterm.ui.menus.icon_lane`) and
deliberately not that function: a command row *drops* its icon where the platform draws
no icon lane, and a board's mark is part of its name on both platforms. So the lane is
measured over what :func:`~meshterm.ui.theme.glyph` will actually draw here, and the
marks stay.
Args:
category: The discipline being named.
lane: The mark column's width, from :func:`discipline_lane`.
Returns:
``"<mark><pad><title>"``, its trailing separator space included in the pad.
"""
mark = glyph(category.icon)
return f"{mark}{' ' * max(1, lane - cell_len(mark) + 1)}{category.title}"
def discipline_lane() -> int:
"""Cells the widest discipline mark claims on this platform (see :func:`discipline_label`).
Measured, never written down: the marks change with the platform, and a substitution
that later narrows simply tightens every heading by a cell without anything else moving.
"""
return max(cell_len(glyph(category.icon)) for category in CATEGORIES)
def _drawn_rows(lines: list[str]) -> list[str]:
"""``lines`` with the rows nothing actually landed on stripped from either end.
@@ -577,6 +613,9 @@ async def open_records(ctx: AppContext) -> dict:
# its far node). Read once here; the far node is matched against these keys per record.
target_counts = ctx.repo.target_trace_counts()
# The mark column every board heading writes its title after (see discipline_label).
mark_lane = discipline_lane()
# Node positions and types for a record's area drawing, gathered live the same way the
# trace tools gather them to score a walk: adverts we've heard, contacts over them. A
# record stores canonical ids, so match those the resolver's way (a prefix either side).
@@ -694,7 +733,7 @@ async def open_records(ctx: AppContext) -> dict:
def describe(category: Category) -> list:
"""The heading and its word-wrapped description as non-selectable rows."""
rows: list = [section_heading(f"{category.icon} {category.title}")]
rows: list = [section_heading(discipline_label(category, mark_lane))]
desc = category.description
if category.needs_positions and self_pos is None:
desc += " — needs your location (set it in Config) to score"
@@ -776,12 +815,13 @@ async def open_records(ctx: AppContext) -> dict:
async def delete_category_flow() -> None:
"""Pick a discipline, confirm, and delete its records (every width)."""
rows: list = []
lane = discipline_lane()
for category in CATEGORIES:
count = len(ctx.repo.discoveries(category.id))
rows.append(
Choice(
title=Text.assemble(
(f"{category.icon} {category.title} ", ""),
(f"{discipline_label(category, lane)} ", ""),
(f"{count} record{'s' if count != 1 else ''}, all widths", "muted"),
),
value=category.id,
+58 -3
View File
@@ -17,7 +17,7 @@ only), so :class:`PlainUi` leaves them unsupported.
from __future__ import annotations
from collections.abc import AsyncIterator, Callable
from collections.abc import AsyncIterator, Callable, Mapping
from contextlib import asynccontextmanager
from typing import Any
@@ -52,6 +52,14 @@ def _dialog_max_cells() -> int:
return _DIALOG_MAX_CELLS if get_platform().frame_border else 43
class _NullBusy:
"""The scripted surface's stand-in for a busy card: a caption that goes nowhere."""
def __init__(self) -> None:
"""Start with an empty caption; assigning to it is the whole of the contract."""
self.message = ""
def _collapse_to_message(buffered: list[RenderableType]) -> Text | None:
"""Collapse small, text-only buffered output into one dialog message.
@@ -134,6 +142,18 @@ class Ui:
"""
raise NotImplementedError
def busy_dialog(self, message: str = "", *, title: str = ""): # noqa: ANN201 - async CM, varies by backend
"""Return an async context manager that floats a **modal** busy card over a screen.
The counterpart to :meth:`busy_overlay`, for slow work a *screen* starts rather than
work that happens in the gap between screens. The overlay is not a screen at all: it
draws only on an empty stack and it takes no keys, so a hub that stays pushed while
it works gets neither the card nor the protection. This one is pushed and modal — see
:meth:`~meshterm.ui.tui.session.TuiSession.busy_dialog` for what that buys. In
scripted CLI mode it does nothing, as the overlay does.
"""
raise NotImplementedError
async def select(
self,
title: str,
@@ -161,8 +181,17 @@ class Ui:
default: Any = None,
banner: Any = None,
footnote: str | None = None,
footer_hint: str | None = None,
keys: Mapping[str, Any] | None = None,
key_hint: Callable[[Any], str] | None = None,
) -> Any:
"""Choose one item on a chromeless startup splash; ``None`` if skipped."""
"""Choose one item on a chromeless startup splash; ``None`` if skipped.
``keys`` declares bare-key shortcuts the splash answers, resolving with a
:class:`~meshterm.ui.tui.select.KeyRequest` (the picker's hide/show-all pair), and
``key_hint`` names them per highlighted row so the footer only advertises a key
where it would act. ``footer_hint`` replaces the base sentence.
"""
raise NotImplementedError
async def confirm_startup(
@@ -407,6 +436,15 @@ class PlainUi(Ui):
"""Do nothing: the scripted CLI has no full-screen surface to float a skeleton over."""
yield
@asynccontextmanager
async def busy_dialog(self, message: str = "", *, title: str = "") -> AsyncIterator[_NullBusy]:
"""Do nothing: there is no screen to interrupt and no keyboard to take.
Still yields something with a settable caption, because a caller that retitles the
card mid-batch must not have to ask which surface it is running on.
"""
yield _NullBusy()
def _no_prompt(self) -> RuntimeError:
"""Build the error raised if a rich prompt is reached on the non-interactive path."""
return RuntimeError("interactive prompts are only available in the menu")
@@ -432,6 +470,9 @@ class PlainUi(Ui):
default: Any = None,
banner: Any = None,
footnote: str | None = None,
footer_hint: str | None = None,
keys: Mapping[str, Any] | None = None,
key_hint: Callable[[Any], str] | None = None,
) -> Any:
"""Unsupported in scripted CLI mode."""
raise self._no_prompt()
@@ -656,6 +697,10 @@ class TuiUi(Ui):
"""Float the session's top-most skeleton card while the wrapped block runs."""
return self.session.busy_overlay(message, title=title)
def busy_dialog(self, message: str = "", *, title: str = ""): # noqa: ANN201
"""Push the session's modal busy card over the current screen while the block runs."""
return self.session.busy_dialog(message, title=title)
# --- input ---------------------------------------------------------------
async def select(
@@ -686,10 +731,20 @@ class TuiUi(Ui):
default: Any = None,
banner: Any = None,
footnote: str | None = None,
footer_hint: str | None = None,
keys: Mapping[str, Any] | None = None,
key_hint: Callable[[Any], str] | None = None,
) -> Any:
"""Delegate to the session's chromeless startup select splash."""
return await self.session.select_startup(
title, items, default=default, banner=banner, footnote=footnote
title,
items,
default=default,
banner=banner,
footnote=footnote,
keys=keys,
key_hint=key_hint,
**({} if footer_hint is None else {"footer_hint": footer_hint}),
)
async def confirm_startup(
+4 -2
View File
@@ -32,8 +32,8 @@ from .prompt import (
TypedConfirmDialog,
byte_counter,
)
from .screen import CANCEL, BusyScreen, PopToMenu, Screen, ScrollScreen
from .select import Choice, DeleteRequest, SelectScreen, Separator
from .screen import CANCEL, BusyDialog, BusyScreen, PopToMenu, Screen, ScrollScreen
from .select import Choice, DeleteRequest, KeyRequest, SelectScreen, Separator
from .session import TuiSession, Visit
from .spinner import Spinner
@@ -41,10 +41,12 @@ __all__ = [
"TuiSession",
"Screen",
"ScrollScreen",
"BusyDialog",
"BusyScreen",
"SelectScreen",
"Choice",
"DeleteRequest",
"KeyRequest",
"Separator",
"TextScreen",
"ConfirmScreen",
+48 -2
View File
@@ -376,6 +376,48 @@ def _banner_lines(banner: Sequence[str], cols: int) -> list[str]:
return _center(padded, cols)
def fit_hint(hint: str, width: int, *, shed_first: Sequence[str] = ()) -> str:
"""Drop ``·`` atoms from a footer hint, right to left, until it fits ``width``.
The chromeless splash has one row of border for its hint and a box no wider than the
terminal less its gutter — 47 cells on the PicoCalc — while the hint itself grows as the
highlight moves onto a row with more keys to offer. Cutting the line at the edge takes
the *end* of it, and the end is ``Esc``: the one atom that must survive, being the only
way out. So atoms are dropped instead, from the right and never the last one, which
spends the cells on the keys nearest the front of the sentence — the ones that move and
commit — and gives up the optional ones a screen advertises elsewhere.
``shed_first`` overrides that order for atoms whose key the reader would find anyway. A
scroll atom is the example: ←→ are the keys already named by the leading move atom, so
trying them costs nothing and teaches the rest, while a bare-letter shortcut is
unguessable and has nowhere else to be advertised on a chromeless splash. Given the
choice between those two, the sentence keeps the letter.
Args:
hint: The composed hint sentence.
width: Cells available.
shed_first: Atoms to drop before any others, in the order given.
Returns:
The hint, or as much of its head plus its final atom as fits (ellipsized only if
even those cannot).
"""
if cell_len(hint) <= width:
return hint
atoms = hint.split(" · ")
for spare in shed_first:
if cell_len(" · ".join(atoms)) <= width:
break
if spare in atoms:
atoms.remove(spare)
while len(atoms) > 2 and cell_len(" · ".join(atoms)) > width:
del atoms[-2] # the last atom is Esc's; drop what sits in front of it
fitted = " · ".join(atoms)
if cell_len(fitted) <= width:
return fitted
return crop_cells(Text(fitted), 0, max(1, width - 1)).plain + "…"
def compose_startup(screen: Screen, cols: int, rows: int) -> str:
"""Compose a chromeless splash: a centered wordmark above a content-sized panel.
@@ -416,6 +458,10 @@ def compose_startup(screen: Screen, cols: int, rows: int) -> str:
sizing_footer = getattr(screen, "sizing_footer_hint", screen.footer_hint)
inner_w = max(measured, cell_len(screen.title), cell_len(sizing_footer))
inner_w = max(10, min(inner_w, cols - 6))
# The hint is fitted to the box rather than cut off by it (see :func:`fit_hint`).
hint_text = fit_hint(
screen.footer_hint, inner_w, shed_first=getattr(screen, "spare_hint_atoms", ())
)
# The probe render is the answer whenever it was already made at the width we settled
# on — the common case, since a body narrower than the probe *is* what set inner_w.
@@ -455,10 +501,10 @@ def compose_startup(screen: Screen, cols: int, rows: int) -> str:
body = Text.from_ansi("\n".join(visible))
hint = hint_style("accent")
subtitle = f"[{hint}]{screen.footer_hint}[/]"
subtitle = f"[{hint}]{hint_text}[/]"
if more_above or more_below:
arrow = ("↑" if more_above else " ") + ("↓" if more_below else " ")
subtitle = f"[{hint}]{arrow} · {screen.footer_hint}[/]"
subtitle = f"[{hint}]{arrow} · {fit_hint(hint_text, inner_w - 5)}[/]"
panel = Panel(
body,
title=f"[{title_style('accent')}]{screen.title}[/]" if screen.title else None,
+45
View File
@@ -17,6 +17,7 @@ from collections.abc import Callable, Sequence
from collections.abc import Sequence as _SequenceABC
from typing import Any
from rich.cells import cell_len
from rich.console import RenderableType
from rich.text import Text
@@ -721,3 +722,47 @@ class BusyScreen(Screen):
def handle(self, action: str, data: str = "") -> None:
"""Swallow all keys: the splash dismisses itself when the task finishes."""
return
class BusyDialog(BusyScreen):
"""A busy splash drawn as a box over the screen it interrupts.
:class:`BusyScreen`'s twin, for work started *from* a screen rather than instead of one:
same spinner, same caption, same swallow-everything :meth:`~BusyScreen.handle` — but
``floating``, so the hub that started the work stays visible beneath it as a backdrop
instead of being replaced.
Modal by inheritance, and that is the half that matters. A hub screen kept on the stack
with ``session.stay`` is *armed the whole time* (see
:meth:`~meshterm.ui.tui.session.Visit._arm`), so while it sits there waiting for a slow
device write every key a reader presses still reaches it: letters land in its live filter
and Esc resolves it, to be handed back the instant the work finishes. A modal screen on
top is what stops that — the key reaches this dialog and dies here.
:meth:`~meshterm.ui.tui.session.TuiSession.busy_overlay` cannot do the job: it is not a
screen, so it is never consulted for a keypress, and it paints only when the stack is
empty — over a pushed hub it draws nothing at all.
"""
floating = True
@property
def dialog_width(self) -> int:
"""Hug the caption rather than stretching to the dialog cap.
A spinner and four words have no business in a 94-cell box. The compositor still
caps this to the terminal (see :func:`~meshterm.ui.tui.frame._dialog_layout`), and
the width ratchets, so a caption that grows mid-batch never makes the box shrink
back and jitter.
"""
return cell_len(self._message) + 8 # spinner, its two spaces, and the padded border
@property
def message(self) -> str:
"""The caption beside the spinner."""
return self._message
@message.setter
def message(self, text: str) -> None:
"""Retitle the card mid-batch (a sequence of writes reporting which one it is on)."""
self._message = text
+54 -1
View File
@@ -9,7 +9,7 @@ arbitrary objects, so the same screen drives the main menu (tool names), the dev
from __future__ import annotations
import inspect
from collections.abc import Callable
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from typing import Any
@@ -119,6 +119,25 @@ class DeleteRequest:
value: Any
@dataclass
class KeyRequest:
"""A shortcut key pressed on a select list, with the row it was pressed on.
The sibling of :class:`DeleteRequest`, for a list that declares its own bare-key
shortcuts (see :class:`SelectScreen`'s ``keys``): the list resolves with this rather
than with a row's value, so the caller can tell "the user pressed *this key*" apart
from "the user chose this row" and act accordingly before re-opening the list.
Attributes:
action: The token the pressed key was declared for.
value: The :attr:`Choice.value` of the highlighted row, or ``None`` where the list
had no rows to highlight. A screen-wide shortcut simply ignores it.
"""
action: Any
value: Any = None
def _plain(label: str | Text) -> str:
"""The plain-text form of a row label, for filtering (a :class:`Text` keeps its ``plain``)."""
return label.plain if isinstance(label, Text) else label
@@ -300,6 +319,8 @@ class SelectScreen(Screen):
filterable: bool = True,
hscroll: bool = False,
hscroll_hint: str = "←→ scroll",
keys: Mapping[str, Any] | None = None,
key_hint: Callable[[Any], str] | None = None,
) -> None:
"""Build a select screen.
@@ -331,6 +352,22 @@ class SelectScreen(Screen):
the move atom) while ``hscroll`` is on and the highlighted row overflows —
so ←→ advertises itself exactly when it would do something. Ignored when
``hscroll`` is off.
keys: Bare-key shortcuts this list answers, mapping the character pressed to a
token naming what it means — the idiom
:meth:`~meshterm.ui.tui.session.TuiSession.button_dialog` already uses for a
dialog's y/n accelerators. The list resolves with a :class:`KeyRequest`
carrying the token and the highlighted row's value, so the caller can act
and re-open. **Only honoured on a non-filterable list**, where a bare letter
has nothing else to do: on a filtering list every letter belongs to the
query, and a shortcut stealing one would be a key that silently stops the
reader typing a name.
key_hint: What those shortcuts are called in the footer, asked of the *highlighted
row's value* on every paint and spliced in just before the trailing ``Esc``
clause. Return one ` · `-joined run of atoms, or ``""`` for a row none of the
shortcuts would touch — the same rule ``delete_hint`` follows, and for the
same reason: the footer must never name a key that would do nothing where the
reader is standing. The policy lives with the caller because only it knows
which rows its keys mean anything on.
"""
super().__init__()
self.title = title
@@ -342,6 +379,10 @@ class SelectScreen(Screen):
# wherever it is opened, ``ctx.ui.select`` included.
self._hscroll = hscroll or any(getattr(item, "hscroll_from", 0) > 0 for item in items)
self._hscroll_hint = hscroll_hint
# Shortcuts are the non-filterable list's compensation for having no filter: the
# letters are free, so a list may spend them (see the ``keys`` argument).
self._keys: dict[str, Any] = {} if filterable else dict(keys or {})
self._key_hint = key_hint
self._hshift = 0
self._last_width = 0 # the last render width, for the footer's overflow probe
if footer_hint is None:
@@ -502,6 +543,8 @@ class SelectScreen(Screen):
* the :attr:`_delete_hint` atom, spliced just before the trailing ``Esc`` clause
(see :func:`_splice_hint`) while the highlighted row is :attr:`Choice.deletable`;
* whatever ``key_hint`` names for the highlighted row's value — the shortcut keys
(``keys``) that would act on *it*, spliced the same way;
* the :attr:`_hscroll_hint` atom (``hscroll`` lists only), inserted right after the
move atom (see :func:`_insert_atom`) while the highlighted row overflows the width
— a short row scrolls nowhere, so ←→ stays hidden on it.
@@ -513,6 +556,10 @@ class SelectScreen(Screen):
current = self._current_choice()
if self._delete_hint and current is not None and current.deletable:
base = _splice_hint(base, self._delete_hint)
if self._key_hint is not None:
atoms = self._key_hint(current.value if current is not None else None)
if atoms:
base = _splice_hint(base, atoms)
if self._hscroll and self._hscroll_hint and self._selected_overflows():
base = _insert_atom(base, self._hscroll_hint)
if self._filter:
@@ -858,6 +905,12 @@ class SelectScreen(Screen):
self._filter = self._filter[:-1]
self._index = 0
self._hshift = 0
elif action == "text" and data in self._keys:
# A declared shortcut resolves the list with what was pressed and where the
# highlight was standing; only a non-filterable list can declare any.
self.resolve(
KeyRequest(self._keys[data], choices[self._index].value if choices else None)
)
elif action == "text" and self._filterable and data.isprintable():
# A leading space is ignored (the filter never begins with whitespace); a
# trailing one is dropped when matching (see _rows), so spaces count only
+180 -5
View File
@@ -14,7 +14,7 @@ from __future__ import annotations
import asyncio
import os
import sys
from collections.abc import AsyncIterator, Callable, Sequence
from collections.abc import AsyncIterator, Callable, Mapping, Sequence
from contextlib import asynccontextmanager
from typing import Any
@@ -25,6 +25,7 @@ from prompt_toolkit.formatted_text import ANSI
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.keys import Keys
from prompt_toolkit.layout import ConditionalContainer, Float, FloatContainer, Layout, Window
from prompt_toolkit.output.color_depth import ColorDepth
from prompt_toolkit.utils import get_cwidth
from rich.console import RenderableType
from rich.text import Text
@@ -45,7 +46,7 @@ from .prompt import (
TypedConfirmDialog,
Validator,
)
from .screen import CANCEL, POP_ALL, BusyScreen, PopToMenu, Screen, ScrollScreen
from .screen import CANCEL, POP_ALL, BusyDialog, BusyScreen, PopToMenu, Screen, ScrollScreen
from .select import Choice, ReorderScreen, SelectScreen, Separator
from .spinner import spinner_interval
@@ -252,6 +253,70 @@ def _reclaim_last_column() -> bool:
return get_platform().width_reclaim
#: What each ``color_depth`` preference value asks for. ``auto`` is deliberately absent: it
#: is the one answer that is not a depth, and :func:`_color_depth` resolves it separately.
_DEPTH_BY_NAME = {
"truecolor": ColorDepth.DEPTH_24_BIT,
"256": ColorDepth.DEPTH_8_BIT,
"16": ColorDepth.DEPTH_4_BIT,
}
#: What ``COLORTERM`` says on a terminal that means it about 24-bit colour. There is no
#: in-band way to ask — a terminal that cannot do truecolor answers a truecolor SGR by
#: quietly approximating it, which looks from here exactly like one that can — so this
#: convention, which every truecolor terminal follows, is the whole of the evidence.
_TRUECOLOR_COLORTERM = frozenset({"truecolor", "24bit"})
def _color_depth() -> ColorDepth | None:
"""How many colours to send this terminal, or ``None`` to keep prompt_toolkit's verdict.
prompt_toolkit picks a depth per *output class*, and its two classes disagree:
``Windows10_Output`` returns ``TRUE_COLOR`` outright, while ``Vt100_Output`` returns
``DEPTH_8_BIT`` for every ``TERM`` but ``linux`` and a dumb terminal. So the same build
of MeshTerm, drawing the same theme, is 24-bit on Windows and 256 colours on macOS and
Linux — and it is quantized *silently*, which is why this went unnoticed: the app looks
fine, just flatter. It costs exactly what this palette is made of. The seven ``heat.*``
steps and the per-node hue wheel (:func:`~meshterm.ui.theme.node_style`) are close
pastels chosen to be told apart; snapped to the 216-colour cube, neighbours collide and
identities stop being distinguishable by hue. ``COLORTERM`` is not consulted by
prompt_toolkit at all — only ``PROMPT_TOOLKIT_COLOR_DEPTH`` is — so a terminal
announcing truecolor in the conventional way is still handed 256.
Three sources, in confidence order, the same shape as :func:`_reclaim_last_column`:
``MESHTERM_COLOR_DEPTH`` for a one-off, then the ``color_depth`` preference, then a
reading of the environment.
The reading only ever *raises* the verdict, never lowers it. A terminal we cannot get a
positive claim from returns ``None`` and keeps precisely the depth it had, so a host
this function has never heard of cannot be made worse by it — which matters more than
being right everywhere, because the failure mode of guessing 24-bit at a terminal
without it is not a duller palette but no colour at all.
Returns:
The depth to force, or ``None`` to leave prompt_toolkit's own default in place.
"""
from ...core.preferences import current as current_preferences
override = os.environ.get("MESHTERM_COLOR_DEPTH")
preferred = override if override is not None else current_preferences().color_depth
if preferred in _DEPTH_BY_NAME:
return _DEPTH_BY_NAME[preferred]
if preferred != "auto":
return None
# A 16-slot console is already right and has no truecolor to claim: the theme addresses
# its palette by index (see theme._VT_SLOTS), and promoting the depth would send RGB to
# a screen with sixteen colours to put it in.
if not get_platform().truecolor:
return None
if os.environ.get("COLORTERM", "").strip().lower() in _TRUECOLOR_COLORTERM:
return ColorDepth.DEPTH_24_BIT
# terminfo's own spelling for a direct-colour entry (``xterm-direct``, ``*-direct16m``).
if os.environ.get("TERM", "").endswith(("-direct", "-direct16m")):
return ColorDepth.DEPTH_24_BIT
return None
#: How many stacked dialog layers the layout can float over the background at once. A fixed
#: pool of centered-box floats (see :meth:`TuiSession._build_app`), sized well past the deepest
#: real nesting — a tool's list, an item's detail popup, and a confirm over that is only three.
@@ -431,6 +496,10 @@ class TuiSession:
# deliberately *not* on the screen stack: it hovers above every layer and is shown/
# hidden by busy_overlay, independent of whatever screens are pushed.
self._overlay: BusyOverlay | None = None
# The busy card currently pushed by :meth:`busy_dialog`, or None. Unlike the overlay
# this one *is* a screen — it has to be, to take the keys the hub underneath would
# otherwise bank — and this holds it so a nested wait rides it instead of stacking.
self._busy: BusyDialog | None = None
# What each drawn layer last composed — ``layer -> (text, carries a wide glyph)`` — so
# :meth:`_emit` can tell a frame that actually changed from one the 1 Hz refresh just
# re-rendered identically, and can ask whether anything currently on screen needs the
@@ -826,18 +895,27 @@ class TuiSession:
default: Any = None,
banner: Any | None = None,
footnote: str | None = None,
footer_hint: str = "↑↓ move · Enter select · Esc quit",
footer_hint: str = "↑↓ move · Enter select · Esc bye",
keys: Mapping[str, Any] | None = None,
key_hint: Callable[[Any], str] | None = None,
) -> Any:
"""Show a chromeless select splash (banner above a content-sized box).
Like :meth:`select`, but drawn without the header/footer status bars and centered
under ``banner`` — the startup device picker's presentation. Type-to-filter is off:
under ``banner`` — the startup device picker's presentation. Esc's verb here is
``bye``, the app's one send-off: this splash is the door, and the reader leaving it
has not started anything to quit out of. Type-to-filter is off:
the device list is short and fixed, so stray keys never narrow it. An optional
``footnote`` (e.g. a copyright notice) sits muted below the box. When any row opts
into removal (a :attr:`~meshterm.ui.tui.select.Choice.deletable` row), a "Del remove"
atom joins the footer — but only while the highlight is actually on such a row, so the
removal key advertises itself exactly where it acts (see
:attr:`~meshterm.ui.tui.select.SelectScreen.footer_hint`).
``keys`` hands the list bare-key shortcuts — which a splash can afford precisely
because it does not filter, so every letter is free (see :class:`SelectScreen`) —
and ``key_hint`` says what they are called on the row the highlight is standing on,
so they advertise themselves exactly where they act, as ``Del remove`` does.
"""
delete_hint = (
"Del remove" if any(isinstance(it, Choice) and it.deletable for it in items) else ""
@@ -849,10 +927,19 @@ class TuiSession:
footer_hint=footer_hint,
delete_hint=delete_hint,
filterable=False,
keys=keys,
key_hint=key_hint,
)
screen.chrome = False
screen.banner = banner
screen.footnote = footnote
# The splash's border is its only hint line and it is narrow (47 cells on the
# PicoCalc), so when the sentence outgrows the box it gives up the scroll atom
# first — ←→ are already named by the move atom in front of it — and then the
# *hide* key. Between the two shortcuts the one that survives is the way back:
# ⇧H only appears at all once something is hidden, which is exactly the state where
# a reader needs to be told how to undo it, and by then they have already found h.
screen.spare_hint_atoms = ("←→ scroll", "h hide")
result = await self.run_screen(screen)
return None if result is CANCEL else result
@@ -922,7 +1009,7 @@ class TuiSession:
backdrop_title,
backdrop_items,
default=backdrop_default,
footer_hint="↑↓ move · Enter select · Esc quit",
footer_hint="↑↓ move · Enter select · Esc bye",
delete_hint="Del remove",
filterable=False,
)
@@ -1323,6 +1410,91 @@ class TuiSession:
self._overlay = None
self.invalidate()
@asynccontextmanager
async def busy_dialog(
self,
message: str = "",
*,
title: str = "",
interval: float | None = None,
) -> AsyncIterator[BusyDialog]:
"""Float a modal busy card over the current screen for the duration of a block.
The in-stack counterpart to :meth:`busy_overlay`, and the one a screen wants when
*it* starts the slow work::
async with session.busy_dialog("saving Lakeside…", title="Channels"):
await device.set_channel(...)
Two things separate it from the overlay, and both are why the overlay could not do
this job. It is a real :class:`~meshterm.ui.tui.screen.BusyDialog` *pushed on the
stack*, so it draws over a hub instead of only in the gaps between screens — the
overlay paints on an empty stack alone (see :meth:`_overlay_visible`), which is
exactly never while a hub is visited. And being modal, it **owns the keyboard**: a
hub kept up with :meth:`stay` stays armed while the work runs, so without a modal
layer every key pressed during the wait still reaches it — letters landing in its
live filter (a list that comes back showing nothing), Esc resolving it to be handed
back the instant the work finishes (a screen that appears to close on its own a
beat later). Those presses now reach this card and stop there.
Nesting keeps the outermost card, so a batch of writes reports as one wait rather
than flashing a box per write; the inner block still runs. The caller may retitle
the card as it goes — ``busy.message = …`` — which is how a sequence says which
step it is on.
Unlike :meth:`busy_overlay` there is no fade-in. The fade is there so a fast
operation shows nothing at all, and that is a fine trade for a cosmetic card; this
one is also the key guard, and a guard that arrives late is a guard with a hole in
it.
Args:
message: The caption drawn beside the spinner.
title: An optional heading naming the feature doing the work.
interval: Seconds between spinner frames; defaults to the platform's cadence
(see :meth:`busy_startup`, which shares the hazard: the card animates *over*
a device read, so a rate the console can't sustain steals the loop from the
work it is reporting on).
Yields:
The live :class:`~meshterm.ui.tui.screen.BusyDialog`, so its caption can change.
"""
tick = spinner_interval() if interval is None else interval
if self._busy is not None: # a nested wait rides the card its caller already put up
yield self._busy
return
screen = BusyDialog(message, title=title)
# A lone floating screen on an empty stack is drawn *as* the background, framed and
# full-frame rather than as a box, so it gets the same blank backdrop every other
# dialog uses (see :meth:`_run_dialog_screen`).
backdrop = None
if not self._stack:
backdrop = ScrollScreen("", floating=False, footer_hint="")
self.push(backdrop)
self._busy = screen
self.push(screen)
async def animate() -> None:
while True:
await asyncio.sleep(tick)
screen.tick()
self.invalidate()
ticker = asyncio.ensure_future(animate())
try:
yield screen
finally:
ticker.cancel()
try:
await ticker
except asyncio.CancelledError:
pass
except Exception: # noqa: BLE001 - a spinner hiccup must never break a flow
pass
self._busy = None
self.pop(screen)
if backdrop is not None:
self.pop(backdrop)
# --- application lifecycle ------------------------------------------------
async def run(self, main: Any) -> None:
@@ -1445,6 +1617,9 @@ class TuiSession:
max_render_postpone_time=None,
input=self._input,
output=self._resolve_output(),
# None leaves the output's own default alone; see _color_depth for why the
# two prompt_toolkit outputs disagree and why only a raise is ever applied.
color_depth=_color_depth(),
)
if fastrender.enabled():
# Swap in the row-diff renderer for plain full-screen frames. Built with the
+23 -3
View File
@@ -1432,6 +1432,23 @@ _BATTERY_FLASH_PCT = 100 / _BATTERY_STEPS / 2
#: Frames in the charging sweep: empty → four fills → round again (bottom-to-full loop).
_CHARGE_FRAMES = 5
#: A full pack reads ``100``, with no ``%`` — the one charge whose sign is dropped, so the
#: reading is three cells wide exactly as ``99%`` is.
#:
#: Which matters because 99 and 100 are the two numbers a topped-off pack sits *between*:
#: the charger cuts out at full, the pack settles back to 99, the charger restarts, and the
#: companion reports the flip every poll for as long as it is plugged in (JP, 2026-09-09).
#: The gauge is pinned to the header's right edge and the pulse takes what is left, so a
#: number that grew a cell at the top of the range took that cell off the sparkline and
#: redrew the whole activity history at a new scale, twice a minute, on a device sitting
#: still. Every other width change in the range — 9 to 10 on the way down — is a boundary a
#: discharging pack crosses once and never comes back over, so it costs one repaint and
#: needs no answer.
#:
#: A bare ``100`` beside a full block reads as a percentage without being told; ``%`` is
#: doing its work at the readings that could be mistaken for something else.
_BATTERY_FULL = "100"
def _braille_fill(steps: int) -> str:
"""A single braille cell filled from the bottom by ``steps`` (0–8) half-rows.
@@ -1504,7 +1521,9 @@ def battery_cell(percent: int, *, charging: bool = False, frame: int = 0) -> Tex
A pack at 100% is drawn as **not charging** whichever way the flag reads: a full cell
resting full is the honest picture, and a topped-off charger left plugged in shouldn't
leave the gauge sweeping forever.
leave the gauge sweeping forever. It also drops its ``%`` (:data:`_BATTERY_FULL`), so
that full reading is the same three cells as the 99% it keeps flipping back to and the
header stops resizing under a pack on a charger.
Args:
percent: State of charge, 0–100 (clamped).
@@ -1513,7 +1532,8 @@ def battery_cell(percent: int, *, charging: bool = False, frame: int = 0) -> Tex
steadily-incrementing integer animates the two live states.
Returns:
A Rich :class:`Text`: the coloured block, a space, and ``NN%`` in muted text — the
A Rich :class:`Text`: the coloured block, a space, and ``NN%`` in muted text — a
full pack's bare ``100`` the one exception (:data:`_BATTERY_FULL`) — with the
block's colours on the glyph's span alone, so the ground stops at the cell.
"""
pct = max(0, min(100, int(percent)))
@@ -1530,5 +1550,5 @@ def battery_cell(percent: int, *, charging: bool = False, frame: int = 0) -> Tex
# muted percent onto the coloured ground alongside the cell.
out = Text()
out.append(_braille_fill(steps), style=color)
out.append(f" {pct}%", style="muted")
out.append(f" {_BATTERY_FULL if pct == 100 else f'{pct}%'}", style="muted")
return out
+23 -2
View File
@@ -77,6 +77,27 @@ def test_battery_cell_shows_the_true_percent() -> None:
assert battery_cell(42).plain == chr(0x2800 | 0xE4) + " 42%"
def test_battery_cell_drops_the_sign_at_full_so_the_header_stops_moving() -> None:
"""A full pack reads ``100`` — three cells, exactly as ``99%`` is.
Those two readings are the ones a topped-off pack sits between: the charger cuts out
at full, the pack settles to 99, the charger restarts, and the flip repeats every poll
for as long as it is plugged in. The gauge is pinned to the header's right edge and the
pulse takes what is left, so a fourth cell at the top of the range came off the
sparkline and rescaled the whole activity history, twice a minute, on a device sitting
still. Dropping the one sign that can be inferred costs nothing and settles it.
"""
assert battery_cell(100).plain.endswith(" 100") # no sign — a full block says it
assert battery_cell(99).cell_len == battery_cell(100).cell_len
# Every other reading keeps its sign; the charging sweep and the alarm never touch it.
assert battery_cell(99).plain.endswith(" 99%") and battery_cell(9).plain.endswith(" 9%")
assert {
battery_cell(pct, charging=True, frame=f).plain.endswith("%")
for pct in (5, 99)
for f in range(6)
} == {True}
def test_battery_cell_colours_by_band_while_the_dots_carry_the_detail() -> None:
"""Three broad bands over half / over a quarter / below, each a block of its own hue."""
assert _glyph_style(battery_cell(100)) == "batt.full" # light green on green
@@ -143,8 +164,8 @@ def test_battery_cell_animates_off_the_frame_counter_alone() -> None:
def test_battery_cell_at_full_rests_full_however_the_charging_flag_reads() -> None:
"""A topped-off pack draws as not charging, so a plugged-in charger stops the sweep."""
assert battery_cell(100, charging=True, frame=0).plain == "⣿ 100%"
assert {battery_cell(100, charging=True, frame=f).plain for f in range(6)} == {"⣿ 100%"}
assert battery_cell(100, charging=True, frame=0).plain == "⣿ 100"
assert {battery_cell(100, charging=True, frame=f).plain for f in range(6)} == {"⣿ 100"}
# One percent short is still filling, and still sweeps.
assert battery_cell(99, charging=True, frame=0).plain[0] == chr(0x2800)
+285 -3
View File
@@ -7,10 +7,13 @@ run against the :class:`MockDevice` simulator and a temporary database.
from __future__ import annotations
import re
from contextlib import asynccontextmanager
from hashlib import sha256
from pathlib import Path
from types import SimpleNamespace
import pytest
from rich.cells import cell_len
from rich.console import Console
from meshterm.context import AppContext
@@ -35,6 +38,7 @@ from meshterm.persistence.repository import Repository
from meshterm.services.chat_service import ChatService
from meshterm.tools.channels import ChannelsTool
from meshterm.ui.channels import (
_CREATE,
ChannelSlot,
_apply_order,
_next_free_slot,
@@ -369,6 +373,11 @@ class _ScriptedUi:
def show(self, *renderables) -> None: # noqa: ANN002
pass
@asynccontextmanager
async def busy_dialog(self, message: str = "", *, title: str = ""): # noqa: ANN201
"""No card without a screen stack — every mutation path goes through this."""
yield SimpleNamespace(message=message)
def note(self, markup: str) -> None:
pass
@@ -572,7 +581,7 @@ async def test_channel_rows_carry_stats_unread_and_lanes(ctx: AppContext) -> Non
ctx.chat._unread[slot.conversation.key] = 2 # as the service would after two arrivals
title, items = _menu_items(ctx, slots, 8, _LiveStats(ctx))
assert title == "Channels — 1/8 slots"
assert title == "Channels · 1/8 slots" # a status atom chains with ·, not an em dash
row = next(it for it in items if isinstance(it, Choice) and it.value == 0)
plain = row.label.plain # the title is a live callable; .label resolves it
assert "Ops" in plain
@@ -670,13 +679,47 @@ async def test_detail_summary_reads_slot_totals_and_unread(ctx: AppContext) -> N
await device.set_channel(3, "Ops", bytes(range(16)))
slot = next(s for s in await read_channel_slots(device) if s.idx == 3)
assert _detail_summary(ctx, slot, _LiveStats(ctx)) == "Slot 3 · no messages recorded yet"
# What the channel *is* leads — the openness and hash moved here out of a title that
# was 42 cells wide and left no room on the console for the bar that says Esc leaves.
assert _detail_summary(ctx, slot, _LiveStats(ctx)) == (
"private · hash be · slot 3 · no messages yet"
)
ctx.repo.record_chat_message(ChatMessage(text="hi", is_channel=True, channel_id=slot.identity))
ctx.chat._unread[slot.conversation.key] = 1
summary = _detail_summary(ctx, slot, _LiveStats(ctx))
# A fresh age reads as bare "now" (the app-wide format_ago grammar — never "now ago").
assert summary == "Slot 3 · 1 message · 1 unread · last message now"
assert summary == "private · hash be · slot 3 · 1 msg · 1 unread · last now"
async def test_the_detail_summary_sheds_atoms_rather_than_wrapping(ctx: AppContext) -> None:
"""One line, on both platforms: the traffic atoms go before the line does.
The identifying atoms are on the left and the describing ones on the right, so a line
too long for the console loses what the reader could already see in the row they opened
this screen from — never the channel's own identity.
"""
from meshterm.core.models import ChatMessage
from meshterm.platforms import PICOCALC, REGULAR, set_platform
from meshterm.ui.channels import _detail_summary, _LiveStats
device = await ctx.device()
await device.set_channel(0, "Lakeside emergency net", bytes(range(16)))
slot = next(s for s in await read_channel_slots(device) if s.idx == 0)
for _ in range(3):
ctx.repo.record_chat_message(
ChatMessage(text="hi", is_channel=True, channel_id=slot.identity)
)
ctx.chat._unread[slot.conversation.key] = 2
try:
for platform in (REGULAR, PICOCALC):
set_platform(platform)
line = _detail_summary(ctx, slot, _LiveStats(ctx))
assert cell_len(line) <= platform.readable_cols, (platform.name, line)
assert line.startswith("private · hash be · slot 0") # identity always survives
finally:
set_platform(REGULAR)
# -- muting channel notifications ---------------------------------------------
@@ -878,3 +921,242 @@ async def test_cli_clear_of_an_empty_slot_is_a_no_op(ctx: AppContext) -> None:
tool = ChannelsTool()
result = await tool.run(ctx, {"cli_action": "clear", "index": 7})
assert result.summary == {"index": 7, "cleared": False}
class _RecordingSession:
"""Records which surface :meth:`TuiUi.present` chose — the popup or the result window."""
def __init__(self) -> None:
self.shown: list[tuple[str, str]] = []
async def message_dialog(self, message, title: str = "") -> None: # noqa: ANN001
self.shown.append(("popup", title))
async def scroll(self, renderable, *, title: str = "", footer_hint: str = "") -> None: # noqa: ANN001
self.shown.append(("window", title))
class _PresentingUi(_ScriptedUi):
"""Scripted answers, but real note buffering — so what the visit *shows* can be asserted."""
def __init__(self, selects: list, texts: list, dialogs: list) -> None:
from meshterm.ui.surface import TuiUi
super().__init__(selects, texts, dialogs)
self.session_spy = _RecordingSession()
self.surface = TuiUi(self.session_spy) # no ``.session`` attribute: stays headless
def note(self, markup: str) -> None:
self.surface.note(markup)
def ack(self, markup: str) -> None:
self.surface.ack(markup)
async def test_a_busy_visit_says_nothing_on_the_way_out(ctx: AppContext) -> None:
"""A visit that changed three channels closes silently — no popup, no window.
The history this pins, in the order it happened: every action banked its own "✓ created
…" note, so the outcome grew a line per change and the third pushed it past the
acknowledgement popup's budget into the full-frame result window — the same visit
reporting itself two different ways depending on how much had been done in it. Folding
those into one summary line fixed the shape and left the real problem: an
acknowledgement for changes the reader had just watched land in the list in front of
them. So the line went too. The count survives in ``summary`` for the run log, which
nobody reads off the screen.
"""
ui = _PresentingUi(
selects=[_CREATE, _CREATE, _CREATE, None], # create three channels, then Esc
texts=["One", "Two", "Three"],
dialogs=[],
)
ctx.ui = ui
result = await ChannelsTool().run(ctx, {})
assert result.summary == {"changes": 3} # the run log still records what was done
assert result.message is None
# The menu's own tail: note the tool's message, then present what the run buffered.
if result.message:
ui.surface.note(result.message)
await ui.surface.present(title="Channels")
assert ui.session_spy.shown == [] # nothing shown, in either shape
class _FlakyDevice:
"""A device whose channel reads stop working partway through the probe.
``fail_from`` is the first slot index that raises, and ``error`` is what it raises —
the two endings the probe has to tell apart: a firmware refusing a slot it does not
have (a plain rejection) and a link that stopped answering (a timeout).
"""
def __init__(self, names: list[str], *, fail_from: int, error: BaseException) -> None:
self._names = names
self._fail_from = fail_from
self._error = error
self.written: list[tuple[int, str]] = []
async def get_channel(self, idx: int): # noqa: ANN201
if idx >= self._fail_from:
raise self._error
if idx < len(self._names):
return {"channel_name": self._names[idx], "channel_secret": bytes(range(16))}
return None
async def set_channel(self, idx: int, name: str, secret) -> None: # noqa: ANN001
self.written.append((idx, name))
async def test_a_probe_cut_short_by_a_failed_read_is_not_a_layout() -> None:
"""A timeout mid-probe reports incomplete; a refused slot reports a finished list.
They look identical in the returned list — a short one either way — which is what let
one timed-out read stand in for "this device has no channels".
"""
from meshterm.core.connection import DeviceCommandError
from meshterm.ui.channels import probe_channel_slots
dropped = _FlakyDevice(["Alpha", "Beta"], fail_from=1, error=DeviceCommandError("no reply"))
slots, complete = await probe_channel_slots(dropped)
assert [s.name for s in slots] == ["Alpha"] and complete is False
at_the_end = _FlakyDevice(["Alpha", "Beta"], fail_from=2, error=RuntimeError("out of range"))
slots, complete = await probe_channel_slots(at_the_end)
assert [s.name for s in slots] == ["Alpha", "Beta"] and complete is True
async def test_an_incomplete_probe_is_answered_but_never_cached(ctx: AppContext) -> None:
"""One bad read must not read as "no channels" for the rest of the session."""
from meshterm.core.connection import DeviceCommandError
device = await ctx.device()
await device.set_channel(0, "Alpha", bytes(range(16)))
calls = {"n": 0}
real = device.get_channel
async def flaky(idx: int): # noqa: ANN202
calls["n"] += 1
if calls["n"] == 1:
raise DeviceCommandError("timed out")
return await real(idx)
device.get_channel = flaky # type: ignore[method-assign]
assert await ctx.devstate.channel_slots() == [] # the failed read, answered honestly
# ...and not kept: the next ask re-probes and finds the channel that was there all along.
assert [s.name for s in await ctx.devstate.channel_slots()] == ["Alpha"]
async def test_a_slot_is_confirmed_empty_before_a_channel_is_written_over_it(
ctx: AppContext,
) -> None:
"""A slot missing from a short list must not be handed out as free.
The dangerous version of the bug above: a probe that stopped at slot 1 leaves slot 1
looking free, and every add flow writes to the slot it is given without asking.
"""
from meshterm.ui.channels import _pick_free_slot
device = await ctx.device()
await device.set_channel(0, "Alpha", bytes(range(16)))
await device.set_channel(1, "Beta", bytes(range(16, 32)))
ui = _ScriptedUi(selects=[], texts=[], dialogs=[])
ctx.ui = ui
truncated = [ChannelSlot(idx=0, name="Alpha", secret=bytes(range(16)))] # slot 1 unseen
assert await _pick_free_slot(ctx, device, truncated, 8) is None # refused, not slot 1
# With the list telling the truth, the next genuinely free slot is handed out.
full_list = list(await read_channel_slots(device))
assert await _pick_free_slot(ctx, device, full_list, 8) == 2
async def test_a_rename_keeps_the_page_it_renamed(ctx: AppContext) -> None:
"""Renaming a channel re-reads it in place; only a clear closes the page.
The channel is still there and this is still its page. It used to close on both, which
left the reader back in the list to find the row again — for no better reason than the
snapshot the page was holding having gone stale under the rename.
"""
from meshterm.ui.channels import _CHAT, _EDIT, _channel_detail, _LiveStats
device = await ctx.device()
await device.set_channel(0, "Ops", bytes(range(16)))
slot = next(s for s in await read_channel_slots(device) if s.idx == 0)
opened: list[str] = []
ui = _ScriptedUi(
# Rename, then open chat (proving the page is still up and knows the new name), Esc.
selects=[_EDIT, _CHAT, None],
texts=["Lakeside", ""], # the new name, and a blank key (derive it from the name)
dialogs=[],
)
ctx.ui = ui
import meshterm.ui.channels as channels_mod
async def fake_chat(_ctx, opened_slot): # noqa: ANN001, ANN202
opened.append(opened_slot.name)
original = channels_mod._open_chat
channels_mod._open_chat = fake_chat
try:
changes = await _channel_detail(ctx, device, slot, _LiveStats(ctx))
finally:
channels_mod._open_chat = original
assert opened == ["Lakeside"] # the page stayed, pointing at the renamed channel
assert changes == 1 # ...and still reports the rename when it finally closes
async def test_the_standard_public_channel_is_not_added_twice(ctx: AppContext) -> None:
"""A re-fired add is refused where it runs, not only where it is drawn.
The menu drops the row once a slot holds the channel, but the row is dispatched by a
sentinel: a press already on its way while the first write ran would add a second copy
of a channel that has exactly one well-known key.
"""
from meshterm.ui.channels import _add_default_public
device = await ctx.device()
ctx.ui = _ScriptedUi(selects=[], texts=[], dialogs=[])
slots: list[ChannelSlot] = []
assert await _add_default_public(ctx, device, slots, 8) == 1
slots = list(await read_channel_slots(device))
assert await _add_default_public(ctx, device, slots, 8) == 0 # refused, not duplicated
assert [s.name for s in await read_channel_slots(device)] == ["Public"]
async def test_a_reorder_that_breaks_partway_says_so(ctx: AppContext) -> None:
"""A relay has no transaction under it, so a link that drops mid-way is reported."""
from meshterm.ui.channels import _reorder_channels
device = await ctx.device()
await device.set_channel(0, "Alpha", bytes(range(16)))
await device.set_channel(1, "Beta", bytes(range(16, 32)))
slots = list(await read_channel_slots(device))
said: list[str] = []
class _Ui(_ScriptedUi):
def note(self, markup: str) -> None:
said.append(markup)
async def reorder(self, title: str, labels: list): # noqa: ANN201
return [1, 0] # swap them
ctx.ui = _Ui(selects=[], texts=[], dialogs=[])
real = device.set_channel
calls = {"n": 0}
async def flaky(idx: int, name: str, secret) -> None: # noqa: ANN001
calls["n"] += 1
if calls["n"] == 2:
raise RuntimeError("link dropped")
await real(idx, name, secret)
device.set_channel = flaky # type: ignore[method-assign]
assert await _reorder_channels(ctx, device, slots) == 0 # not counted as a clean reorder
assert said and "reorder stopped partway" in said[0]
+144 -17
View File
@@ -11,6 +11,7 @@ import re
from pathlib import Path
import pytest
from rich.cells import cell_len
from meshterm.core.channels import DEFAULT_PUBLIC_SECRET, derive_secret
from meshterm.core.config import Settings
@@ -27,7 +28,7 @@ from meshterm.core.preferences import Preferences
from meshterm.persistence.repository import Repository
from meshterm.services.chat_service import ChatService
from meshterm.services.event_hub import EventHub
from meshterm.tools.chat import _PREVIEW_WIDTH, _LiveLasts, _preview_text, _title
from meshterm.tools.chat import _lanes, _LiveLasts, _preview_text, _title
from meshterm.ui.chat import ChatScreen
from meshterm.ui.tui import fkeys
from meshterm.ui.tui.screen import CANCEL
@@ -441,11 +442,10 @@ def test_preview_colours_channel_sender_and_mentions() -> None:
assert any(s.style == "node.unknown" and s.start <= zed < s.end for s in preview.spans)
def test_preview_ellipsizes_long_text() -> None:
"""An over-long preview is clipped to the width budget with a trailing ellipsis."""
long = ChatMessage(text="x" * 100, is_channel=True, channel_idx=0)
out = _preview_text(long, _NO_KEYS).plain
assert len(out) == _PREVIEW_WIDTH and out.endswith("…")
def test_preview_keeps_the_whole_message() -> None:
"""The preview is built whole — the row's own cut is what the ←→ scroll walks past."""
long = ChatMessage(text="x" * 300, is_channel=True, channel_idx=0)
assert _preview_text(long, _NO_KEYS).plain == "x" * 300
def test_title_shows_badge_and_author_preview(repo: Repository) -> None:
@@ -453,7 +453,7 @@ def test_title_shows_badge_and_author_preview(repo: Repository) -> None:
conv = Conversation(label="General", is_channel=True, channel_idx=0, channel_id="c0")
ctx = _RowCtx(repo, unread={"chan:c0": 3})
last = ChatMessage(text="Bob: hi there", is_channel=True, channel_id="c0") # sender inline
title = _title(ctx, conv, {"chan:c0": last}, _NO_KEYS)
title = _title(ctx, conv, {"chan:c0": last}, _NO_KEYS, _lanes([conv]))
line = title.plain # a Text, since there is unread
assert line.startswith("🔒 General") # a private channel leads with its openness glyph
assert "● 3" in line
@@ -465,7 +465,7 @@ def test_title_reddens_only_the_unread_dot(repo: Repository) -> None:
from rich.text import Text
conv = Conversation(label="General", is_channel=True, channel_idx=0, channel_id="c0")
unread = _title(_RowCtx(repo, unread={"chan:c0": 2}), conv, {}, _NO_KEYS)
unread = _title(_RowCtx(repo, unread={"chan:c0": 2}), conv, {}, _NO_KEYS, _lanes([conv]))
assert isinstance(unread, Text)
dot = unread.plain.index("●")
reddened = [
@@ -473,7 +473,7 @@ def test_title_reddens_only_the_unread_dot(repo: Repository) -> None:
]
assert reddened and all(span.end - span.start == 1 for span in reddened) # just the glyph
read = _title(_RowCtx(repo, unread={}), conv, {}, _NO_KEYS)
read = _title(_RowCtx(repo, unread={}), conv, {}, _NO_KEYS, _lanes([conv]))
assert isinstance(read, Text) # always a Text now, so its spans can carry the row's colour
assert "●" not in read.plain # nothing unread -> no badge dot
assert not any(span.style == "err" for span in read.spans)
@@ -488,8 +488,9 @@ def test_title_preview_column_aligns_regardless_of_label_length(repo: Repository
)
m0 = ChatMessage(text="X: hello", is_channel=True, channel_id="c0")
m1 = ChatMessage(text="Y: hello", is_channel=True, channel_id="c1")
l0 = _title(ctx, short, {"chan:c0": m0}, _NO_KEYS).plain
l1 = _title(ctx, long, {"chan:c1": m1}, _NO_KEYS).plain
lanes = _lanes([short, long]) # one measurement for the list, as the picker does
l0 = _title(ctx, short, {"chan:c0": m0}, _NO_KEYS, lanes).plain
l1 = _title(ctx, long, {"chan:c1": m1}, _NO_KEYS, lanes).plain
assert l0.index("X: hello") == l1.index("Y: hello")
@@ -498,7 +499,7 @@ def test_title_leads_with_openness_glyph(repo: Repository) -> None:
ctx = _RowCtx(repo)
def head(conv):
return _title(ctx, conv, {}, _NO_KEYS).plain.split(" ", 1)[0]
return _title(ctx, conv, {}, _NO_KEYS, _lanes([conv])).plain.split(" ", 1)[0]
named = Conversation(
label="#general", is_channel=True, channel_id="c0", secret=derive_secret("#general")
@@ -533,7 +534,7 @@ def test_title_contact_dot_reflects_conversation_history(repo: Repository) -> No
)
# No history yet — a hollow ring in the standard companion pink.
fresh = _title(ctx, contact, {}, _NO_KEYS)
fresh = _title(ctx, contact, {}, _NO_KEYS, _lanes([contact]))
assert fresh.plain.startswith("○") and dot_is_pink(fresh)
# The name lane carries Alice's key-derived hue.
name_at = fresh.plain.index("Alice")
@@ -541,7 +542,7 @@ def test_title_contact_dot_reflects_conversation_history(repo: Repository) -> No
# Once we've exchanged messages the same pink dot fills in.
last = ChatMessage(text="hi", peer=contact.peer)
talked = _title(ctx, contact, {contact.key: last}, _NO_KEYS)
talked = _title(ctx, contact, {contact.key: last}, _NO_KEYS, _lanes([contact]))
assert talked.plain.startswith("●") and dot_is_pink(talked)
@@ -1791,9 +1792,135 @@ async def test_picker_pins_its_column_header_over_the_group_heading(
assert top[0].startswith("CONVERSATION") and top[0].endswith("LAST MESSAGE")
assert top[1] == "── 👤 Direct ──"
assert above is True
# Too narrow for the whole line, the trailing label shortens — never wraps.
assert header.text(50).endswith("LAST MSG")
assert "\n" not in header.text(40)
# 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 "\n" not in header.text(30)
async def test_picker_lane_is_sized_to_the_names_it_holds(repo: Repository) -> None:
"""The conversation lane fits the longest name, between its floor and its ceiling.
Every cell the lane holds past that longest name is padding taken out of the message
preview, which is the row's only unbounded content — so it is measured, not written down.
"""
from meshterm.tools.chat import _LABEL_MAX, _LABEL_MIN, _lanes
def lane(*names: str) -> int:
return _lanes([Conversation(label=n, is_channel=True, channel_id=n) for n in names]).label
assert lane("Bob", "Ann") == _LABEL_MIN # short names can't shrink it under the header word
assert lane("Bob", "YUL-Cartierville") == len("YUL-Cartierville")
assert lane("A Rather Long Contact Name Indeed") == _LABEL_MAX # nor grow it without end
async def test_picker_rows_scroll_the_message_under_pinned_lanes(repo: Repository) -> None:
"""A row hands ←→ the preview alone: the lanes in front of it are where it starts to slide.
Those lanes are the reader's place in the list, so the scroll begins exactly where the
message does — which is one column for every row, long name or short.
"""
from types import SimpleNamespace
from meshterm.tools.chat import ChatTool
from meshterm.ui.tui import Choice
contacts = [
Contact(name="Alice", public_key="d4" + "0" * 62, node_type=1),
Contact(name="A Rather Long Contact Name", public_key="60" + "0" * 62, node_type=1),
]
alice = Conversation(label="Alice", is_channel=False, contact=contacts[0])
repo.record_chat_message(ChatMessage(text="x" * 200, peer=alice.peer))
ctx = SimpleNamespace(devstate=_PickerDevstate(contacts), repo=repo, chat=_PickerChat())
rows = [it for it in await ChatTool()._picker_items(ctx) if isinstance(it, Choice)]
heads = {row.hscroll_from for row in rows}
assert len(heads) == 1 and heads.pop() > 0 # one head block for the whole list
talked = next(row for row in rows if row.value.label == "Alice")
line = talked.label.plain
# Measured in cells, not characters: a channel glyph is one character and two cells.
assert cell_len(line[: line.index("x")]) == talked.hscroll_from
# And the preview keeps the whole message: the cut is the row's, and ←→ walk past it.
assert talked.label.plain.endswith("x" * 200)
def test_picker_marker_lane_lines_the_two_groups_up(repo: Repository) -> None:
"""A channel glyph and a contact dot claim the same cells — on either platform.
That glyph is double-cell on regular and single-cell in the console font, so the dot is
padded to whatever it actually measures rather than to a written-down width: a
hard-coded pad left every channel row a cell adrift of every direct row on the PicoCalc.
"""
from meshterm.platforms import PICOCALC, REGULAR, set_platform
from meshterm.tools.chat import _lanes
channel = Conversation(label="Public", is_channel=True, channel_idx=0, channel_id="c0")
contact = Conversation(
label="Alice", is_channel=False, contact=Contact(name="Alice", public_key="d4" * 32)
)
ctx = _RowCtx(repo)
try:
for platform in (REGULAR, PICOCALC):
set_platform(platform)
lanes = _lanes([channel, contact])
rows = [_title(ctx, c, {}, _NO_KEYS, lanes).plain for c in (channel, contact)]
names = ("Public", "Alice")
at = [cell_len(r[: r.index(n)]) for r, n in zip(rows, names, strict=True)]
assert at[0] == at[1] == lanes.marker
finally:
set_platform(REGULAR)
async def test_picker_footer_fits_with_every_atom_showing(repo: Repository, monkeypatch) -> None:
"""Scroll, erase and the base atoms together stay inside the 72-cell footer budget.
The three of them coincide on an ordinary row — a thread with history long enough to
scroll — so the budget is the wording's constraint, not a corner case (it is what
``Del erase`` is: the row already names the conversation it would take the history of).
"""
import asyncio
from types import SimpleNamespace
from prompt_toolkit.input.defaults import create_pipe_input
from prompt_toolkit.output import DummyOutput
from meshterm.tools.chat import ChatTool
from meshterm.ui.surface import TuiUi
from meshterm.ui.tui.screen import CANCEL
from meshterm.ui.tui.session import TuiSession
ally = Contact(name="Ally", public_key="d4" + "0" * 62, node_type=1)
conv = Conversation(label="Ally", is_channel=False, contact=ally)
repo.record_chat_message(ChatMessage(text="y" * 200, peer=conv.peer))
ctx = SimpleNamespace(devstate=_PickerDevstate([ally]), repo=repo, chat=_PickerChat())
hints: list[str] = []
with create_pipe_input() as inp:
session = TuiSession(input=inp, output=DummyOutput())
ctx.ui = TuiUi(session)
async def main() -> None:
run = asyncio.ensure_future(ChatTool()._run_live(ctx))
loop = asyncio.get_running_loop()
deadline = loop.time() + 2
while not session._stack:
assert loop.time() < deadline, "the picker never opened"
await asyncio.sleep(0)
picker = session._stack[-1]
picker.render_body(72) # the overflow probe reads the last render width
picker.handle("down") # onto the one Direct row: deletable, and overflowing
picker.render_body(72)
hints.append(picker.footer_hint)
picker.resolve(CANCEL)
await asyncio.wait_for(run, timeout=2)
await asyncio.wait_for(session.run(main()), timeout=5)
hint = hints[0]
assert cell_len(hint) <= 72, f"{cell_len(hint)} cells: {hint!r}"
for atom in ("←→ scroll", "Enter open", "Del erase", "Esc back"):
assert atom in hint
async def test_picker_del_deletes_history_after_a_red_confirm(repo: Repository) -> None:
+4
View File
@@ -132,6 +132,10 @@ def test_bare_text_default_emoji_are_left_exactly_as_measured() -> None:
# A heading built around one costs its own cells and no more, so the row frames flush.
caption = cell_len(f"── {_ROAD} Longest distance ──")
assert caption == len("── ") + 1 + len(" Longest distance ──")
# The Trophy case's *visible* gap after the road is not this module's business and must
# not become it: the heading pads the mark out to the widest of the seven disciplines
# (records_screen.discipline_label), which costs a real cell the terminal advances over
# — where widening the glyph here would claim one it does not.
def test_flags_measure_two_cells_in_both_authorities() -> None:
+163
View File
@@ -22,18 +22,21 @@ starts reporting XPASS.
from __future__ import annotations
import asyncio
import logging
import tempfile
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from types import SimpleNamespace
import pytest
from rich.cells import cell_len
from rich.text import Text
from meshterm.core.advert_store import AdvertPolicy
from meshterm.core.channels import DEFAULT_PUBLIC_SECRET, derive_secret
from meshterm.core.courier_store import CourierStore
from meshterm.core.models import (
NODE_TYPE_REPEATER,
@@ -339,6 +342,163 @@ def _chat(cols: int, rows: int) -> Screen:
)
class _PickerChat:
"""The unread counter the picker rows read live."""
def __init__(self, unread: dict[str, int]) -> None:
self._unread = unread
def unread(self, key: str) -> int:
return self._unread.get(key, 0)
class _PickerDevstate:
"""The session-cached device reads the picker builds itself from."""
def __init__(self, contacts: list[Contact], slots: list) -> None:
self._contacts = contacts
self._slots = slots
async def channel_slots(self) -> list:
return list(self._slots)
async def contacts(self) -> list[Contact]:
return list(self._contacts)
class _PickerRepo:
"""The stored history behind each row's age, badge and last-message preview."""
def __init__(self, lasts: dict) -> None:
self._lasts = lasts
def last_chat_messages(self) -> dict:
return dict(self._lasts)
def node_names(self) -> dict:
return {}
def _chat_picker(cols: int, rows: int) -> Screen:
"""The conversation picker, built through the tool's own row funnel.
Its widest case: a name at the lane's ceiling, a three-digit-capable unread badge, and
a last message far longer than any terminal — which is the row ←→ scroll, so it has to
be cut rather than fitted (see :meth:`meshterm.tools.chat.ChatTool._picker_items`).
"""
from meshterm.tools.chat import ChatTool
contacts = [
Contact(name="Alice", public_key="aa" * 32, key_prefix="aa" * 6, node_type=1),
Contact(name="A Rather Long Contact Name", public_key="d4" * 32, key_prefix="d4" * 6),
Contact(name="Homestead", public_key="60" * 32, key_prefix="60" * 6, node_type=1),
]
now = datetime(2026, 7, 12, 14, 30, tzinfo=timezone.utc)
lasts = {
# The default public channel, as _channels_from_slots names it with no slots read.
"chan:slot:0": ChatMessage(
text="Alice: anyone up around the Plateau tonight? testing a new antenna",
is_channel=True,
channel_idx=0,
channel_id="slot:0",
created_at=now,
),
f"dm:{'aa' * 6}": ChatMessage(
text="on my way, should be there soon - the bridge is backed up again",
peer="aa" * 6,
created_at=now,
),
}
ctx = _PickerCtx(contacts, lasts)
items = asyncio.run(ChatTool()._picker_items(ctx))
return SelectScreen(
"Chat - pick a conversation",
items,
footer_hint="↑↓ move · type to filter · Enter open · Esc back",
delete_hint="Del erase",
)
def _channels_manager(cols: int, rows: int) -> Screen:
"""The channel manager at its widest: every lane populated, every action row drawn.
Built through the feature's own row funnel, so the header line, the glyph lane, the
unread badge, the counts, the ages and the sparkline are laid out by the code the app
runs. The cases that matter are here: a name at the lane's ceiling, a muted channel (its
mark folds to a different glyph on the console), a channel with no messages at all, and
a three-digit unread badge.
"""
from meshterm.ui.channels import _MANAGER_HINT, ChannelSlot, _LiveStats, _menu_items
slots = [
ChannelSlot(idx=0, name="Public", secret=DEFAULT_PUBLIC_SECRET),
ChannelSlot(idx=1, name="Lakeside emergency", secret=bytes(range(16))),
ChannelSlot(idx=2, name="#montreal", secret=derive_secret("#montreal")),
ChannelSlot(idx=3, name="Ops", secret=bytes(range(16, 32))),
]
ctx = _ChannelsCtx(muted={slots[3].identity})
title, items = _menu_items(ctx, slots, 8, _LiveStats(ctx))
return SelectScreen(title, items, footer_hint=_MANAGER_HINT)
def _channel_detail(cols: int, rows: int) -> Screen:
"""One channel's action page, its vital-signs line above the rows."""
from meshterm.ui.channels import ChannelSlot, _detail_items, _detail_summary, _LiveStats
slot = ChannelSlot(idx=2, name="Lakeside emergency", secret=bytes(range(16)))
ctx = _ChannelsCtx(muted=set())
stats = _LiveStats(ctx)
return SelectScreen(
f"Channel — {slot.name}",
_detail_items(ctx, slot),
prompt=_detail_summary(ctx, slot, stats),
footer_hint="↑↓ move · Enter select · Esc back",
)
class _ChannelsChat:
"""The chat service surface the channel rows read: unread counts and mute state."""
def __init__(self, muted: set[str]) -> None:
self._muted = muted
def unread(self, key: str) -> int:
"""A three-digit badge on one channel, so the lane is drawn at its widest."""
return 128 if key.endswith("slot:1") or "Lakeside" in key else 0
class _ChannelsCtx:
"""The minimal AppContext surface the channel rows and detail page read."""
def __init__(self, muted: set[str]) -> None:
self.chat = _ChannelsChat(muted)
self.repo = _ChannelsRepo()
self.preferences = Preferences()
self._muted = muted
@property
def mute_store(self): # noqa: ANN201 - a stand-in for the real store
"""A store answering only the one question a row asks it."""
return SimpleNamespace(is_muted=lambda identity: identity in self._muted)
class _ChannelsRepo:
"""Channel statistics with one busy channel, one quiet one, and one never used."""
def channel_stats(self, *a, **k): # noqa: ANN002, ANN003, ANN201
"""Return nothing: the rows then draw their empty marks, which is the tighter case."""
return {}
class _PickerCtx:
"""The minimal AppContext surface :meth:`ChatTool._picker_items` reads."""
def __init__(self, contacts: list[Contact], lasts: dict) -> None:
self.devstate = _PickerDevstate(contacts, [])
self.repo = _PickerRepo(lasts)
self.chat = _PickerChat({"chan:slot:0": 3, f"dm:{'aa' * 6}": 12})
def _livefeed(cols: int, rows: int) -> Screen:
return LiveFeedScreen(
session=_GallerySession(cols, rows),
@@ -701,6 +861,9 @@ _ENTRIES: list[_Entry] = [
_Entry("node_detail", _node_detail),
_Entry("map", _map),
_Entry("chat", _chat),
_Entry("chat_picker", _chat_picker),
_Entry("channels_manager", _channels_manager),
_Entry("channel_detail", _channel_detail),
_Entry("livefeed", _livefeed),
_Entry("walk", _walk),
_Entry("timemachine", _timemachine),
+92 -1
View File
@@ -20,7 +20,15 @@ from prompt_toolkit.output import DummyOutput
from meshterm.ui.tui.progress import ProgressScreen
from meshterm.ui.tui.prompt import ButtonDialog
from meshterm.ui.tui.screen import CANCEL, POP_ALL, BusyScreen, PopToMenu, Screen, ScrollScreen
from meshterm.ui.tui.screen import (
CANCEL,
POP_ALL,
BusyDialog,
BusyScreen,
PopToMenu,
Screen,
ScrollScreen,
)
from meshterm.ui.tui.select import Choice, SelectScreen
from meshterm.ui.tui.session import _CTRL_LETTER_CHORDS, _KEY_ACTIONS, TuiSession
@@ -585,6 +593,7 @@ def test_pop_all_is_refused_over_anything_modal() -> None:
ButtonDialog("Delete it?", [("Cancel", False), ("Delete", True)]),
ProgressScreen("Working"),
BusyScreen("checking…"),
BusyDialog("saving Lakeside…"),
):
session.push(layer)
assert session.request_pop_all() is False, f"{type(layer).__name__} must block ^W"
@@ -733,6 +742,10 @@ def test_only_navigational_layers_are_non_modal() -> None:
assert SelectScreen("list", [Choice("a", 1)]).modal is False
assert BusyScreen("checking…").floating is False
assert BusyScreen("checking…").modal is True
# ...and its twin is the same claim on the keyboard drawn as a box over what it
# interrupts, which is the pair's whole point.
assert BusyDialog("saving…").floating is True
assert BusyDialog("saving…").modal is True
assert Screen().modal is False
assert CANCEL is not POP_ALL
@@ -809,3 +822,81 @@ async def test_a_detached_flow_still_reports_a_real_failure() -> None:
task = session.run_detached(work())
with pytest.raises(RuntimeError):
await task
# --- work in flight ----------------------------------------------------------
async def test_a_key_pressed_during_a_slow_action_reaches_the_hub_that_is_still_armed() -> None:
"""The defect ``busy_dialog`` exists to close, stated as a test.
A hub kept up with ``stay`` is armed for the whole visit, so a key pressed while the
caller is off doing slow device work still lands on it. Esc resolves the round nobody
is waiting for yet, and it is handed back the instant the work finishes — the screen
closing on its own a beat after a press that appeared to do nothing.
"""
with create_pipe_input() as inp:
session = _session(inp)
screen = SelectScreen("hub", [Choice("alpha", 1)])
banked: list = []
async def main() -> None:
async with session.stay(screen) as visit:
inp.send_text(ESC) # pressed "while the write is running"
await asyncio.sleep(0.05) # let the input loop dispatch it
banked.append(await asyncio.wait_for(visit.result(), timeout=1))
await asyncio.wait_for(session.run(main()), timeout=5)
assert banked == [CANCEL] # the press was banked and comes straight back
async def test_the_busy_dialog_takes_the_keys_the_hub_would_have_banked() -> None:
"""With the card up, the same press dies on it: nothing is banked, nothing resolves.
This is what makes a slow channel write safe to sit through — the list underneath
cannot be filtered down to nothing, cannot be re-fired by an impatient second Enter,
and cannot be closed by an Esc that looks ignored until the work lands.
"""
with create_pipe_input() as inp:
session = _session(inp)
screen = SelectScreen("hub", [Choice("alpha", 1)])
timed_out = []
async def main() -> None:
async with session.stay(screen) as visit:
async with session.busy_dialog("saving…", title="Channels"):
inp.send_text(ESC + "zzz" + ENTER) # Esc, filter letters, and Enter
await asyncio.sleep(0.05)
assert session.top.__class__ is BusyDialog # the card owns the keyboard
try:
await asyncio.wait_for(visit.result(), timeout=0.2)
except asyncio.TimeoutError:
timed_out.append(True)
await asyncio.wait_for(session.run(main()), timeout=5)
assert timed_out == [True] # nothing was banked while the card was up
assert screen._filter == "" # and the letters never reached the list's filter
async def test_the_busy_dialog_is_one_card_however_deeply_it_nests() -> None:
"""A batch of writes reports as one wait, not a box flashing per write."""
with create_pipe_input() as inp:
session = _session(inp)
depths: list[int] = []
async def main() -> None:
session.push(ScrollScreen("hub", floating=False))
async with session.busy_dialog("reordering channels…") as outer:
depths.append(len(session._stack))
async with session.busy_dialog("moving Lakeside · 1/2") as inner:
assert inner is outer # the nested wait rides the card already up
depths.append(len(session._stack))
outer.message = "moving Dorval · 2/2" # the owner retitles it as it goes
depths.append(len(session._stack))
depths.append(len(session._stack))
await asyncio.wait_for(session.run(main()), timeout=5)
assert depths == [2, 2, 2, 1] # pushed once, popped once, hub still underneath
+24 -16
View File
@@ -112,18 +112,19 @@ def test_empty_path_reads_as_the_callers_word() -> None:
def test_chips_are_joined_by_one_interlocked_chevron() -> None:
"""A seam is one cell, not two.
"""A seam is one cell, not two — and the path's own end is not a seam at all.
The previous chip's point is laid *on* the next chip's fill, so the route reads as
a ribbon whose segments meet on a chevron. The closing edge keeps its lone taper —
nothing follows it to lay the point on.
a ribbon whose segments meet on a chevron. The point is spoken for: it is what says
the route continues, so a finished path never wears one. It ends square on the last
chip's own pad, and the only chevrons in the line are the joins.
"""
alice_fill = node_style("aa").split()[-1]
line = PathLine([PathHop("Alice", key="aa"), PathHop("you", you=True)], mode="powerline")
text = line.text()
assert text.plain == f" Alice {POWERLINE_SEP} you {POWERLINE_SEP}"
assert text.plain == f" Alice {POWERLINE_SEP} you "
seam_styles = [str(s.style) for s in text.spans if text.plain[s.start : s.end] == POWERLINE_SEP]
assert seam_styles == [f"{alice_fill} on {_YOU_BG}", _YOU_BG]
assert seam_styles == [f"{alice_fill} on {_YOU_BG}"]
def test_chips_keep_the_same_words_and_honour_style_overrides() -> None:
@@ -136,9 +137,9 @@ def test_chips_keep_the_same_words_and_honour_style_overrides() -> None:
chips = PathLine(hops, mode="powerline").text().plain
assert plain.replace(" → ", " ") == chips.replace(POWERLINE_SEP, "").replace(" ", " ").strip()
themed = PathLine([PathHop("X", style="brand")], mode="powerline").text()
assert str(themed.spans[-1].style) == "#5eead4" # the closing edge, brand-filled
assert pathline._fills(themed)[-1] == "#5eead4" # the chip runs to the very last cell
hexed = PathLine([PathHop("X", style="bold #123456")], mode="powerline").text()
assert str(hexed.spans[-1].style) == "#123456"
assert pathline._fills(hexed)[-1] == "#123456"
def test_auto_mode_follows_the_terminal_verdict(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -161,7 +162,7 @@ def test_the_cursor_is_a_hop_of_its_own_in_either_mode() -> None:
assert _styles(plain)[CURSOR_GLYPH] == "selected" # the reverse-video block
chips = PathLine(hops, mode="powerline").text()
assert chips.plain == f" a {POWERLINE_SEP} {CURSOR_GLYPH} {POWERLINE_SEP} b {POWERLINE_SEP}"
assert chips.plain == f" a {POWERLINE_SEP} {CURSOR_GLYPH} {POWERLINE_SEP} b "
slot = next(s for s in chips.spans if chips.plain[s.start : s.end] == CURSOR_GLYPH)
assert str(slot.style).endswith(f"on {_style_hex('cursor')}") # the cursor-white fill
@@ -500,10 +501,11 @@ def test_wrapped_carries_the_cursor_like_any_other_hop() -> None:
def test_wrapped_chip_lines_open_on_the_break_they_continue() -> None:
"""Chip wrapping never splits a chip; each line opens on the break it continues.
Every line ends on the pointed edge, and every line *but the first* opens on the
break's other half — the point notched out of its own fill in reverse video, so the
page shows through and not a trace of the line above bleeds down. The point always
faces the way the path flows.
Every line the path *outruns* ends on the pointed edge — the last one ends square,
the route having finished — and every line *but the first* opens on the break's
other half: the point notched out of its own fill in reverse video, so the page
shows through and not a trace of the line above bleeds down. The point always faces
the way the path flows.
"""
hops = [
PathHop(label, key=key)
@@ -518,7 +520,8 @@ def test_wrapped_chip_lines_open_on_the_break_they_continue() -> None:
# (it marks the run as a path line for the cursor fold and draws nothing).
carried = next(s for s in lines[1].spans if s.start == step and str(s.style) != PATH_INK)
assert str(carried.style) == f"{_style_hex(node_style('3d'))} reverse"
assert all(line.plain.endswith(POWERLINE_SEP) for line in lines)
assert lines[0].plain.endswith(POWERLINE_SEP) # …the path runs on past this line
assert lines[-1].plain.endswith(" ") # …and stops square on the last chip's pad
assert all(line.cell_len <= 20 for line in lines)
@@ -526,8 +529,10 @@ def test_rounded_caps_finish_a_path_only_where_the_font_has_them(monkeypatch) ->
"""Rounded caps finish a path only where the font actually has them.
A full Nerd Font rounds the path's two *outer* ends into a lozenge; a core-only
terminal squares the opening and points the close, never drawing tofu. Interior
breaks stay angled either way — a rounded end would read as the path stopping.
terminal squares *both* of them off, appending nothing and letting the outer chips'
own pads be the edges, never drawing tofu. Interior breaks stay angled either way —
a rounded end would read as the path stopping, and a pointed one at the finish would
read as the opposite, a route cut off mid-walk.
"""
hops = [
PathHop(label, key=key)
@@ -537,7 +542,10 @@ def test_rounded_caps_finish_a_path_only_where_the_font_has_them(monkeypatch) ->
monkeypatch.setattr(pathline, "powerline_full", lambda: False)
assert line.text().plain.startswith(" AAAA")
assert line.text().plain.endswith(POWERLINE_SEP)
assert line.text().plain.endswith("DDDD ")
# The point is still what a *wrapped* line ends on: there the route really does go on.
squared = line.wrapped(20, indent=2)
assert squared[0].plain.endswith(POWERLINE_SEP) and squared[-1].plain.endswith("DDDD ")
monkeypatch.setattr(pathline, "powerline_full", lambda: True)
assert line.text().plain.startswith(POWERLINE_ROUND_OPEN + " AAAA")
+17 -3
View File
@@ -14,6 +14,7 @@ from datetime import datetime, timezone
from pathlib import Path
import pytest
from rich.cells import cell_len
from rich.console import Console
from meshterm.context import AppContext
@@ -22,7 +23,13 @@ from meshterm.core.config import Settings
from meshterm.core.device_store import DeviceStore
from meshterm.persistence.repository import DiscoveredPath, Repository
from meshterm.services.records import CATEGORIES, CATEGORY_BY_ID
from meshterm.ui.records_screen import RecordDialog, WalkVertex, open_records
from meshterm.ui.records_screen import (
RecordDialog,
WalkVertex,
discipline_label,
discipline_lane,
open_records,
)
from meshterm.ui.surface import TuiUi
from meshterm.ui.tui import frame
from meshterm.ui.tui.prompt import TypedConfirmDialog
@@ -513,7 +520,14 @@ async def test_the_board_pins_its_discipline_heading_and_description(tui_ctx) ->
blocks = [
[_plain([line]).strip() for line in rows] for _idx, rows in browser._sticky_headers
]
assert [rows[0] for rows in blocks] == [f"── {c.icon} {c.title} ──" for c in CATEGORIES]
lane = discipline_lane()
assert [rows[0] for rows in blocks] == [
f"── {discipline_label(c, lane)} ──" for c in CATEGORIES
]
# And every one of them starts its title in the same column: the marks are not all
# the same width (🛣 and 🕸 are one cell where the rest are two), so the lane pads.
starts = {cell_len(discipline_label(c, lane)) - cell_len(c.title) for c in CATEGORIES}
assert len(starts) == 1
assert all(len(rows) >= 2 for rows in blocks) # each carries its description too
# Highlighting deep in the one populated board scrolls its heading off the top of a
# short viewport; the heading leads what pins, its description under it.
@@ -521,7 +535,7 @@ async def test_the_board_pins_its_discipline_heading_and_description(tui_ctx) ->
browser.handle("down")
visible, above, _below = frame._visible_slice(browser, browser.render_body(72), 10)
board = CATEGORY_BY_ID["grand_tour"]
assert _plain([visible[0]]).strip() == f"── {board.icon} {board.title} ──"
assert _plain([visible[0]]).strip() == f"── {discipline_label(board, lane)} ──"
assert _plain([visible[1]]).strip().startswith(board.description[:20])
assert above is True
browser.resolve(None) # Esc
+107
View File
@@ -0,0 +1,107 @@
"""Session colour tests: how many colours the terminal is actually sent.
prompt_toolkit decides this per *output class*, and its two classes disagree —
``Windows10_Output`` returns true colour outright, ``Vt100_Output`` returns 256 for every
``TERM`` but ``linux`` — so the same theme was drawn at 24 bits on Windows and quantized to
the 216-colour cube on macOS and Linux, silently. The resolver closes that gap, and these
tests pin the one property that makes it safe to run everywhere: it only ever *raises* the
verdict. A terminal that cannot be shown to do better returns ``None`` and keeps exactly the
depth prompt_toolkit gave it, because guessing 24-bit at a terminal without it costs not a
duller palette but the colour entirely.
"""
from __future__ import annotations
from prompt_toolkit.output.color_depth import ColorDepth
from meshterm.platforms import PICOCALC, REGULAR, set_platform
from meshterm.ui.tui.session import _color_depth
def _auto(monkeypatch) -> None:
"""Clear both overrides so the environment reading is what is under test."""
monkeypatch.delenv("MESHTERM_COLOR_DEPTH", raising=False)
monkeypatch.delenv("COLORTERM", raising=False)
monkeypatch.delenv("TERM", raising=False)
def test_an_unremarkable_terminal_keeps_prompt_toolkits_own_verdict(monkeypatch) -> None:
"""Nothing claimed → ``None``, so a host we have never heard of is never made worse."""
_auto(monkeypatch)
set_platform(REGULAR)
assert _color_depth() is None
def test_colorterm_truecolor_raises_the_depth(monkeypatch) -> None:
"""The convention every truecolor terminal follows — and the one prompt_toolkit ignores."""
_auto(monkeypatch)
monkeypatch.setenv("COLORTERM", "truecolor")
set_platform(REGULAR)
assert _color_depth() is ColorDepth.DEPTH_24_BIT
def test_colorterm_24bit_is_the_same_claim(monkeypatch) -> None:
"""``24bit`` is the other spelling in circulation; case is not part of the claim."""
_auto(monkeypatch)
monkeypatch.setenv("COLORTERM", "24BIT")
set_platform(REGULAR)
assert _color_depth() is ColorDepth.DEPTH_24_BIT
def test_a_direct_colour_terminfo_entry_raises_the_depth(monkeypatch) -> None:
"""``*-direct`` is terminfo's own spelling for a direct-colour terminal."""
_auto(monkeypatch)
monkeypatch.setenv("TERM", "xterm-direct")
set_platform(REGULAR)
assert _color_depth() is ColorDepth.DEPTH_24_BIT
def test_picocalc_is_never_promoted(monkeypatch) -> None:
"""A 16-slot console addresses its palette by index; RGB has nowhere to land there."""
_auto(monkeypatch)
monkeypatch.setenv("COLORTERM", "truecolor")
set_platform(PICOCALC)
assert _color_depth() is None
def test_env_override_names_a_depth_outright(monkeypatch) -> None:
"""``MESHTERM_COLOR_DEPTH`` wins over the reading, in either direction."""
_auto(monkeypatch)
monkeypatch.setenv("COLORTERM", "truecolor")
set_platform(REGULAR)
monkeypatch.setenv("MESHTERM_COLOR_DEPTH", "256")
assert _color_depth() is ColorDepth.DEPTH_8_BIT
monkeypatch.setenv("MESHTERM_COLOR_DEPTH", "16")
assert _color_depth() is ColorDepth.DEPTH_4_BIT
def test_the_preference_is_consulted_below_the_env_override(monkeypatch) -> None:
"""A stored preference names a depth the same way; ``auto`` falls through to the reading."""
from types import SimpleNamespace
_auto(monkeypatch)
set_platform(REGULAR)
monkeypatch.setattr(
"meshterm.core.preferences.current",
lambda: SimpleNamespace(color_depth="truecolor"),
)
assert _color_depth() is ColorDepth.DEPTH_24_BIT
monkeypatch.setattr(
"meshterm.core.preferences.current",
lambda: SimpleNamespace(color_depth="auto"),
)
assert _color_depth() is None
def test_an_unrecognised_preference_value_changes_nothing(monkeypatch) -> None:
"""A hand-edited YAML value we cannot read is not grounds for guessing."""
from types import SimpleNamespace
_auto(monkeypatch)
monkeypatch.setenv("COLORTERM", "truecolor")
set_platform(REGULAR)
monkeypatch.setattr(
"meshterm.core.preferences.current",
lambda: SimpleNamespace(color_depth="lots"),
)
assert _color_depth() is None
+298 -15
View File
@@ -1226,7 +1226,9 @@ def test_device_picker_builds_aligned_columns(tmp_path) -> None:
captured: dict = {}
class _Ui:
async def select_startup(self, title, items, *, default=None, banner=None, footnote=None):
async def select_startup( # noqa: ANN001, ANN201, ANN003
self, title, items, *, default=None, banner=None, footnote=None, **_kw
):
captured["items"] = items
captured["banner"] = banner
captured["footnote"] = footnote
@@ -1278,7 +1280,9 @@ def test_device_picker_names_and_sorts_known_devices(tmp_path) -> None:
captured: dict = {}
class _Ui:
async def select_startup(self, title, items, *, default=None, banner=None, footnote=None):
async def select_startup( # noqa: ANN001, ANN201, ANN003
self, title, items, *, default=None, banner=None, footnote=None, **_kw
):
captured["items"] = items
return None # skip past the smoke test
@@ -1315,7 +1319,9 @@ def test_device_picker_reinjects_remembered_tcp_device(tmp_path) -> None:
captured: dict = {}
class _Ui:
async def select_startup(self, title, items, *, default=None, banner=None, footnote=None):
async def select_startup( # noqa: ANN001, ANN201, ANN003
self, title, items, *, default=None, banner=None, footnote=None, **_kw
):
captured["items"] = items
return None # skip past the smoke test
@@ -1346,7 +1352,9 @@ def test_device_picker_lists_configured_tcp_profile(tmp_path) -> None:
captured: dict = {}
class _Ui:
async def select_startup(self, title, items, *, default=None, banner=None, footnote=None):
async def select_startup( # noqa: ANN001, ANN201, ANN003
self, title, items, *, default=None, banner=None, footnote=None, **_kw
):
captured["items"] = items
return None # skip past the smoke test
@@ -1379,7 +1387,9 @@ def test_device_picker_lists_configured_serial_profile(tmp_path) -> None:
captured: dict = {}
class _Ui:
async def select_startup(self, title, items, *, default=None, banner=None, footnote=None):
async def select_startup( # noqa: ANN001, ANN201, ANN003
self, title, items, *, default=None, banner=None, footnote=None, **_kw
):
captured["items"] = items
return None # skip past the smoke test
@@ -1412,7 +1422,9 @@ def test_device_picker_serial_profile_yields_to_scanned_port(tmp_path) -> None:
captured: dict = {}
class _Ui:
async def select_startup(self, title, items, *, default=None, banner=None, footnote=None):
async def select_startup( # noqa: ANN001, ANN201, ANN003
self, title, items, *, default=None, banner=None, footnote=None, **_kw
):
captured["items"] = items
return None
@@ -1442,7 +1454,9 @@ def test_device_picker_profile_yields_to_remembered_endpoint(tmp_path) -> None:
captured: dict = {}
class _Ui:
async def select_startup(self, title, items, *, default=None, banner=None, footnote=None):
async def select_startup( # noqa: ANN001, ANN201, ANN003
self, title, items, *, default=None, banner=None, footnote=None, **_kw
):
captured["items"] = items
return None
@@ -1467,7 +1481,9 @@ def test_device_picker_adds_network_device(tmp_path) -> None:
probed: dict = {}
class _Ui:
async def select_startup(self, title, items, *, default=None, banner=None, footnote=None):
async def select_startup( # noqa: ANN001, ANN201, ANN003
self, title, items, *, default=None, banner=None, footnote=None, **_kw
):
# Choose the "add a network device" action row.
return next(it.value for it in items if isinstance(it, Choice) and it.value is _ADD_TCP)
@@ -1517,7 +1533,9 @@ def test_device_picker_removes_network_device_on_delete(tmp_path) -> None:
def __init__(self) -> None:
self._passes = 0
async def select_startup(self, title, items, *, default=None, banner=None, footnote=None):
async def select_startup( # noqa: ANN001, ANN201, ANN003
self, title, items, *, default=None, banner=None, footnote=None, **_kw
):
names = [
it.label.plain
for it in items
@@ -1583,7 +1601,9 @@ def test_device_picker_keeps_network_device_when_removal_cancelled(tmp_path) ->
def __init__(self) -> None:
self._passes = 0
async def select_startup(self, title, items, *, default=None, banner=None, footnote=None):
async def select_startup( # noqa: ANN001, ANN201, ANN003
self, title, items, *, default=None, banner=None, footnote=None, **_kw
):
self._passes += 1
if self._passes == 1:
row = next(
@@ -1662,7 +1682,9 @@ class _PickerUi:
def __init__(self) -> None:
self.notes: list = []
async def select_startup(self, title, items, *, default=None, banner=None, footnote=None):
async def select_startup( # noqa: ANN001, ANN201, ANN003
self, title, items, *, default=None, banner=None, footnote=None, **_kw
):
return next(it.value for it in items if isinstance(it, Choice))
async def notify_startup(self, renderable, *, title="", banner=None, footnote=None):
@@ -1707,7 +1729,9 @@ def test_device_picker_leaves_the_copyright_to_the_wordmark(tmp_path) -> None:
footnotes: list = []
class _Ui:
async def select_startup(self, title, items, *, default=None, banner=None, footnote=None):
async def select_startup( # noqa: ANN001, ANN201, ANN003
self, title, items, *, default=None, banner=None, footnote=None, **_kw
):
footnotes.append(("select", footnote))
return next(it.value for it in items if isinstance(it, Choice))
@@ -1748,7 +1772,9 @@ def test_device_picker_prompts_and_retries_ble_pin(tmp_path) -> None:
errors: list = []
class _Ui:
async def select_startup(self, title, items, *, default=None, banner=None, footnote=None):
async def select_startup( # noqa: ANN001, ANN201, ANN003
self, title, items, *, default=None, banner=None, footnote=None, **_kw
):
return next(it.value for it in items if isinstance(it, Choice))
async def notify_startup(self, renderable, *, title="", banner=None, footnote=None):
@@ -1793,7 +1819,9 @@ def test_device_picker_pin_cancel_returns_to_list(tmp_path) -> None:
picks = iter([0, "quit"]) # pick the device once, then quit the re-opened list
class _Ui:
async def select_startup(self, title, items, *, default=None, banner=None, footnote=None):
async def select_startup( # noqa: ANN001, ANN201, ANN003
self, title, items, *, default=None, banner=None, footnote=None, **_kw
):
choices = [it for it in items if isinstance(it, Choice)]
step = next(picks)
if step == "quit":
@@ -1825,7 +1853,9 @@ def test_device_picker_quit_row_returns_none(tmp_path) -> None:
devices = [DiscoveredDevice(port="COM5", product="Wio SX1262")]
class _QuitUi:
async def select_startup(self, title, items, *, default=None, banner=None, footnote=None):
async def select_startup( # noqa: ANN001, ANN201, ANN003
self, title, items, *, default=None, banner=None, footnote=None, **_kw
):
# The last choice is the Quit row; picking it signals "exit".
quit_choice = [it for it in items if isinstance(it, Choice) and it.value is _QUIT]
assert quit_choice, "the picker offers a Quit row"
@@ -2958,3 +2988,256 @@ def test_a_button_dialogs_hint_never_names_the_verb_of_one_button() -> None:
assert "←→" in hint, (
f"{where}:{line} hides ←→, the key that moves the highlight Enter commits"
)
# --- hiding devices on the splash --------------------------------------------
def _hide_store(tmp_path): # noqa: ANN001, ANN202
"""A device registry on a scratch file."""
from meshterm.core.device_store import DeviceStore
return DeviceStore(tmp_path / "devices.json")
def test_hidden_devices_survive_the_session_that_hid_them(tmp_path) -> None: # noqa: ANN001
"""Hiding is written to the registry file, so a fresh store still knows about it."""
store = _hide_store(tmp_path)
assert store.hidden_ids() == set()
store.hide("usb-0001")
store.hide("usb-0002")
store.hide("usb-0001") # idempotent
assert _hide_store(tmp_path).hidden_ids() == {"usb-0001", "usb-0002"}
assert _hide_store(tmp_path).show_all() == 2
assert _hide_store(tmp_path).hidden_ids() == set()
assert _hide_store(tmp_path).show_all() == 0 # nothing left to bring back
def test_hiding_leaves_the_registry_and_its_default_alone(tmp_path) -> None: # noqa: ANN001
"""A hidden device is still remembered, still the default, still reachable by name.
Hiding is a *listing* choice on one screen. Everything that resolves a device without
the splash — ``--port``, a profile, the reconnect — must not be able to tell.
"""
from meshterm.core.discovery import serial_device
store = _hide_store(tmp_path)
device = serial_device("COM7", name="A Radio")
store.remember(device, node_name="Wardriver")
store.hide(device.stable_id)
fresh = _hide_store(tmp_path)
assert fresh.hidden_ids() == {device.stable_id}
assert fresh.is_known(device)
remembered = fresh.load()
assert remembered is not None and remembered.node_name == "Wardriver"
def test_connecting_to_a_hidden_device_shows_it_again(tmp_path) -> None: # noqa: ANN001
"""Confirming a device is the plainest statement that it belongs on the list."""
from meshterm.core.discovery import serial_device
store = _hide_store(tmp_path)
device = serial_device("COM7", name="A Radio")
store.hide(device.stable_id)
store.remember(device, node_name="Wardriver")
assert store.hidden_ids() == set()
async def test_the_splash_hides_the_highlighted_device_and_redraws(tmp_path) -> None: # noqa: ANN001
"""Pressing h drops the row and remembers it; the redrawn list is the feedback."""
from meshterm.core.discovery import serial_device
from meshterm.ui.device_picker import prompt_device
from meshterm.ui.tui import Choice, KeyRequest
probe = serial_device("COM3", name="A Debug Probe")
radio = serial_device("COM7", name="A Radio")
store = _hide_store(tmp_path)
drawn: list[list] = []
class _Ui:
"""Presses h on the probe, then picks whatever is left."""
def __init__(self) -> None:
self.round = 0
async def select_startup(self, title, items, **kw): # noqa: ANN001, ANN003, ANN201
drawn.append([it for it in items if isinstance(it, Choice)])
self.round += 1
if self.round == 1:
return KeyRequest(kw["keys"]["h"], probe)
return radio
async def busy_startup(self, message, coro, **kw): # noqa: ANN001, ANN003, ANN201
return await coro
async def verify(device, pin): # noqa: ANN001, ANN202
return {"name": "Wardriver"}
chosen = await prompt_device(_Ui(), [probe, radio], store, verify)
assert chosen is radio
assert store.hidden_ids() == {probe.stable_id}
# The first pass offered both devices; the second offered only the one left.
assert probe in [row.value for row in drawn[0]]
assert probe not in [row.value for row in drawn[1]]
def test_the_splash_says_so_when_it_is_empty_only_because_of_hiding() -> None:
"""Nothing-detected would be a lie, and with no rows there is no footer atom either."""
from meshterm.core.discovery import serial_device
from meshterm.ui.device_picker import _build_items
lines = [str(it.title) for it in _build_items([], None, {}, hidden=2)]
assert any("2 devices hidden" in line and "⇧H" in line for line in lines)
detected = [str(it.title) for it in _build_items([], None, {}, hidden=0)]
assert any("no companion devices detected" in line for line in detected)
# With something still listed the footer names ⇧H on every row, so the line would be a
# row of the box spent saying it twice.
radio = serial_device("COM7", name="A Radio")
rows = [str(it.title) for it in _build_items([radio], None, {}, hidden=2)]
assert not any("hidden" in line for line in rows)
def test_hiding_a_device_leaves_the_highlight_where_the_row_was() -> None:
"""The next device down, or the one above at the end — so a run of adapters clears in a run."""
from meshterm.core.discovery import serial_device
from meshterm.ui.device_picker import _after_hiding
first, middle, last = (serial_device(f"COM{n}", name=f"D{n}") for n in (1, 2, 3))
listed = [first, middle, last]
assert _after_hiding(listed, first) is middle
assert _after_hiding(listed, middle) is last
# Off the end there is nowhere further down, so the highlight steps up rather than
# rolling round to the top — nothing in the app rolls over.
assert _after_hiding(listed, last) is middle
assert _after_hiding([first], first) is None # an emptied list has nothing to land on
def test_the_splash_names_a_shortcut_only_where_it_would_act() -> None:
"""Named on a device row only, and ⇧H only while hidden — the Del remove rule."""
from meshterm.core.discovery import serial_device
from meshterm.ui.device_picker import _ADD_TCP, _QUIT, _shortcut_hint
radio = serial_device("COM7", name="A Radio")
nothing_hidden = _shortcut_hint(0)
assert nothing_hidden(radio) == "h hide" # no way back is offered; there is nothing back
assert nothing_hidden(_ADD_TCP) == "" # nothing to hide on the action rows
assert nothing_hidden(_QUIT) == ""
assert nothing_hidden(None) == "" # an empty list highlights nothing at all
with_hidden = _shortcut_hint(2)
assert with_hidden(radio) == "h hide · ⇧H show all"
# ⇧H is screen-wide, so it rides whatever row the reader happens to be standing on.
assert with_hidden(_QUIT) == "⇧H show all"
def test_a_hint_too_long_for_its_box_drops_atoms_rather_than_its_tail() -> None:
"""Cutting the line at the edge would take Esc, which is the one atom that must survive."""
from meshterm.ui.tui.frame import fit_hint
full = "↑↓ move · ←→ scroll · Enter select · h hide · ⇧H show all · Esc quit"
assert fit_hint(full, 100) == full # room for everything: untouched
# Dropped from the right, in front of Esc — never Esc itself.
assert fit_hint(full, 56) == "↑↓ move · ←→ scroll · Enter select · h hide · Esc quit"
assert fit_hint(full, 45) == "↑↓ move · ←→ scroll · Enter select · Esc quit"
assert fit_hint(full, 20).endswith("Esc quit")
# ...unless the caller names atoms it can spare first, in the order it can spare them:
# the scroll keys are already named by the move atom, and between the two hide keys the
# one to keep is the way back.
spared = fit_hint(full, 56, shed_first=("←→ scroll", "h hide"))
assert spared == "↑↓ move · Enter select · h hide · ⇧H show all · Esc quit"
assert (
fit_hint(full, 50, shed_first=("←→ scroll", "h hide"))
== "↑↓ move · Enter select · ⇧H show all · Esc quit"
)
def test_the_splash_scrolls_the_hardware_column_of_the_row_it_is_on() -> None:
"""←→ read a long firmware model to its end; the lanes in front of it stay put."""
from meshterm.core.device_store import RememberedDevice
from meshterm.core.discovery import serial_device
from meshterm.platforms import PICOCALC, REGULAR, set_platform
from meshterm.ui.device_picker import _build_items
from meshterm.ui.tui import Choice, SelectScreen
radio = serial_device("COM7", name="Wardriver")
registry = {
radio.stable_id: RememberedDevice(
stable_id=radio.stable_id,
port="COM7",
label="COM7",
last_connected="2026-09-01T00:00:00+00:00",
node_name="Wardriver",
hardware_model="Seeed Wio Tracker 1110 Development Kit",
)
}
try:
for platform, width in ((REGULAR, 66), (PICOCALC, 47)):
set_platform(platform)
items = _build_items([radio], registry[radio.stable_id], registry)
screen = SelectScreen("Select a companion device", items, filterable=False)
row = next(it for it in items if isinstance(it, Choice))
assert row.hscroll_from > 0 # the fixed lanes are pinned...
assert row.hscroll_from < width # ...and leave something to scroll into
before = _plain(screen.render_body(width)[1])
for _ in range(3):
screen.handle("right")
after = _plain(screen.render_body(width)[1])
assert before != after, platform.name
assert after.startswith(before[: row.hscroll_from - 4]), "the lanes moved"
finally:
set_platform(REGULAR)
def test_the_splash_hint_stays_inside_the_box_it_is_drawn_in() -> None:
"""The chromeless splash keeps its hint in its own border, on both platforms.
The border is the terminal less the gutter the box floats over — not the full readable
width — and on the PicoCalc that is 47 cells, which is what makes every atom here
conditional rather than merely tidy.
"""
from rich.cells import cell_len
from meshterm.core.discovery import serial_device
from meshterm.platforms import PICOCALC, REGULAR
from meshterm.ui.device_picker import _shortcut_hint
from meshterm.ui.tui.select import _splice_hint
base = "↑↓ move · Enter select · Esc bye" # the splash's own send-off
radio = serial_device("COM7", name="A Radio")
common = _splice_hint(base, _shortcut_hint(0)(radio))
for platform in (REGULAR, PICOCALC):
budget = platform.readable_cols - platform.dialog_margin - 2
assert cell_len(common) <= budget, (platform.name, common)
def test_a_shortcut_is_only_honoured_where_a_letter_is_free() -> None:
"""A filtering list spends its letters on the query, so it declares no shortcuts.
Otherwise a device named "Homestead" could not be typed on a list that had claimed h.
"""
from meshterm.ui.tui import Choice, KeyRequest
from meshterm.ui.tui.select import SelectScreen
token = object()
fixed = SelectScreen("fixed", [Choice("a row", 1)], filterable=False, keys={"h": token})
fixed.future = None
resolved: list = []
fixed.resolve = lambda value: resolved.append(value) # type: ignore[method-assign]
fixed.handle("text", "h")
assert resolved == [KeyRequest(token, 1)]
filtering = SelectScreen("filtering", [Choice("Homestead", 1)], keys={"h": token})
filtering.resolve = lambda value: resolved.append(value) # type: ignore[method-assign]
filtering.handle("text", "h")
assert len(resolved) == 1 # nothing new resolved
assert filtering._filter == "h" # the letter went to the query, where it belongs