Compare commits
No files matched your search
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 ·
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
Reference in new issue
Block a user