Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
873af31e21 | ||
|
|
16345ed6b3 | ||
|
|
86ddb87f54 | ||
|
|
5ecd5d003f | ||
|
|
edc140e30c | ||
|
|
6459ca5446 | ||
|
|
a94c14f000 | ||
|
|
db7e6ccc18 | ||
|
|
ababfaf993 | ||
|
|
6b6bb975f5 | ||
|
|
1fade6287b | ||
|
|
661227cd2d | ||
|
|
c481bb5191 | ||
|
|
9f65c75f01 | ||
|
|
a35d82c654 | ||
|
|
e8a654a15f | ||
|
|
c868977f1c | ||
|
|
4ab873e9f8 |
@@ -0,0 +1,108 @@
|
||||
# CI and releases (docs/milestones/R1.md).
|
||||
# A push: the host tests, with their coverage of lib/. On main, the README's badges
|
||||
# are published to the branch `badges`.
|
||||
# A pull request: the same, then the release firmware and the Debug Build.
|
||||
# A tag v*: all of it, then a Gitea release with the signed Update File.
|
||||
# Run by hand: the release of a tag that exists already (the ones from before CI).
|
||||
#
|
||||
# The job runs in a plain Python image, as scripts/ci.sh does on a developer's machine, with the
|
||||
# toolchains in a Docker volume the runner allows (container.valid_volumes: roro9stack-pio): that
|
||||
# volume is the cache. No JavaScript actions, so the image needs no Node: the checkout is git.
|
||||
name: CI
|
||||
on:
|
||||
push:
|
||||
branches: ['**', '!badges'] # badges holds what CI itself publishes
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: An existing tag to build and publish as a release
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu
|
||||
# A pull request from a fork would run someone else's code on our runner: not without us (Q154).
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
container:
|
||||
image: python:3.12-slim
|
||||
volumes:
|
||||
- roro9stack-pio:/pio
|
||||
env:
|
||||
PLATFORMIO_CORE_DIR: /pio
|
||||
RORO_NO_DOCKER: 1
|
||||
steps:
|
||||
- name: Tools
|
||||
run: |
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq --no-install-recommends git build-essential openssl >/dev/null
|
||||
pip install -q --no-cache-dir --root-user-action=ignore platformio gcovr
|
||||
pio --version; df -h /pio | tail -1; ls /pio | head
|
||||
|
||||
- name: Check out
|
||||
run: |
|
||||
find . -mindepth 1 -maxdepth 1 -exec rm -rf {} +
|
||||
git config --global --add safe.directory '*'
|
||||
git init -q .
|
||||
git remote add origin "${{ github.server_url }}/${{ github.repository }}.git"
|
||||
git fetch -q --tags origin '+refs/heads/*:refs/remotes/origin/*' '+refs/pull/*/head:refs/remotes/pull/*'
|
||||
git checkout -q --detach "${{ github.sha }}"
|
||||
git describe --tags --always
|
||||
|
||||
- name: Host tests, and their coverage of lib/
|
||||
if: github.event_name != 'workflow_dispatch'
|
||||
run: scripts/coverage.sh
|
||||
|
||||
- name: The release firmware and the Debug Build
|
||||
if: github.event_name == 'pull_request' || github.ref_type == 'tag'
|
||||
run: scripts/ci.sh builds
|
||||
|
||||
# The README's badges are files on a branch of their own, replaced at each push to main.
|
||||
- name: Publish the badges
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
rm -rf /tmp/badges && mkdir /tmp/badges
|
||||
cp .pio/coverage/coverage.svg .pio/coverage/summary.json /tmp/badges/
|
||||
scripts/coverage_badge.py --plain release "$(git describe --tags --abbrev=0)" /tmp/badges/release.svg
|
||||
cd /tmp/badges
|
||||
git init -q -b badges .
|
||||
git add .
|
||||
git -c user.name="roro9stack CI" -c user.email="ci@git.twis.la" commit -q -m "Coverage of ${{ github.ref_name }} at ${{ github.sha }}"
|
||||
git push -q --force "$(echo "${{ github.server_url }}" | sed "s#://#://ci:${GITEA_TOKEN}@#")/${{ github.repository }}.git" badges
|
||||
|
||||
- name: Which release
|
||||
id: release
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = workflow_dispatch ]; then
|
||||
echo "tag=${{ inputs.tag }}" >> "$GITHUB_OUTPUT"
|
||||
elif [ "${{ github.ref_type }}" = tag ]; then
|
||||
echo "tag=${{ github.ref_name }}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Build and sign the release
|
||||
if: steps.release.outputs.tag != ''
|
||||
env:
|
||||
OTA_SIGNING_KEY: ${{ secrets.OTA_SIGNING_KEY }}
|
||||
run: |
|
||||
# The sources of the tag in a clone of their own; the tools are this commit's.
|
||||
rm -rf /tmp/release-src dist
|
||||
git clone -q . /tmp/release-src
|
||||
git -C /tmp/release-src checkout -q --detach "refs/tags/${{ steps.release.outputs.tag }}"
|
||||
# The key exists as a file only while this step runs, in a container that goes with the job.
|
||||
umask 077
|
||||
export RORO_OTA_KEY="$(mktemp)"
|
||||
trap 'rm -f "$RORO_OTA_KEY"' EXIT
|
||||
printf '%s\n' "$OTA_SIGNING_KEY" > "$RORO_OTA_KEY"
|
||||
umask 022
|
||||
scripts/release_build.sh /tmp/release-src dist
|
||||
|
||||
- name: Publish the release
|
||||
if: steps.release.outputs.tag != ''
|
||||
env:
|
||||
GITEA_API: ${{ github.server_url }}/api/v1
|
||||
GITEA_REPO: ${{ github.repository }}
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: scripts/release_publish.py dist
|
||||
@@ -128,6 +128,10 @@ The Notification raised once per boot when the SD card passes 80% full. It point
|
||||
**Storage Clean-up**:
|
||||
The screen where the user deletes old Logs and Captures by category and age, with a preview of the space freed. Notes are never offered for deletion. It lives in the Storage App, under Maintenance.
|
||||
|
||||
**Note**:
|
||||
A plain text file in `/notes`, written on the device in the Notes App. Listed by its first line. Saved without being asked; never offered by Storage Clean-up.
|
||||
_Avoid_: memo, document
|
||||
|
||||
**Storage App**:
|
||||
The App that browses the SD card: folders and files, a clipboard for one item at a time (copy, cut, paste), rename, delete, new folder, and a viewer for each kind of file the firmware writes. The top-level folders, `/gemini/cache` and files being written are read-only.
|
||||
_Avoid_: file manager, Files, explorer
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# roro9stack
|
||||
|
||||
[](https://git.twis.la/twisla/roro9stack/actions?workflow=ci.yml) [](#build-and-test-local-ci) [](https://git.twis.la/twisla/roro9stack/releases/latest)
|
||||
|
||||
A multi-app firmware for the **M5Stack Cardputer ADV** with the **Cap LoRa-1262**. It's a Meshtastic-compatible mesh messenger, plus Wi-Fi tools, IRC, GNSS and more. Licensed GPL-3.0.
|
||||
|
||||
- Domain language: [CONTEXT.md](CONTEXT.md)
|
||||
@@ -18,8 +20,23 @@ scripts/ci.sh
|
||||
|
||||
This runs the host-side unit tests (`test/`, `native` environment), then builds the firmware. The output is `.pio/build/cardputer-adv/firmware.factory.bin`.
|
||||
|
||||
`scripts/coverage.sh` runs the same tests with coverage counters and writes a line-by-line report to `.pio/coverage/index.html`. The badge above is its figure for `main`: the share of the lines of `lib/` that the host tests run. `lib/` is the logic that compiles on a PC; `lib/SD` (the card's driver) and `src/` (the Apps, the Services, everything that needs the device) have no host tests and aren't in that figure.
|
||||
|
||||
The framework is rebuilt with the TLS settings in `platformio.ini` (`custom_sdkconfig`, ADR 0006), so the first build after a fresh checkout takes about 4 minutes; later builds take under a minute.
|
||||
|
||||
## CI and releases
|
||||
|
||||
Gitea Actions (`.gitea/workflows/ci.yml`, docs/milestones/R1.md) runs the host tests on every push, and on a pull request also builds the release firmware and the Debug Build: changes reach `main` through pull requests. Pushing a tag `v*` runs all of it and publishes a release on Gitea with:
|
||||
|
||||
- `roro9stack-<version>.ota`, the signed Update File;
|
||||
- `roro9stack-<version>-factory.bin`, the whole flash image for a first install over USB;
|
||||
- `roro9stack-<version>.elf.gz`, to decode crash reports from that build;
|
||||
- `SHA256SUMS`.
|
||||
|
||||
CI signs with the project's key, held as a repository secret (ADR 0008). Debug Builds are built but never published: each carries its builder's Debug Console token.
|
||||
|
||||
`scripts/ota_verify.py <file.ota>` checks an Update File on a PC the way a device does. `scripts/release_build.sh` and `scripts/release_publish.py` are what the workflow runs; they work the same by hand.
|
||||
|
||||
## Flash
|
||||
|
||||
1. Connect the Cardputer by USB-C.
|
||||
@@ -99,7 +116,7 @@ A copy runs in the background of the card (about 400 KB a second) in short turns
|
||||
|
||||
Enter on a file opens it by type; Tab switches to the same file as a hex dump or as text:
|
||||
|
||||
- **Text** (`.txt`, `.log`, `.gmi`, `.csv`, and anything that looks like text): only the screen's worth is read from the card, so a file of any size opens at once. Logs open at the end. Up and Down move a line, Left and Right a page, `t` and `b` go to the top and the end.
|
||||
- **Text** (`.txt`, `.log`, `.gmi`, `.csv`, and anything that looks like text): only the screen's worth is read from the card, so a file of any size opens at once. Logs open at the end. Up and Down move a line, Left and Right a page, `t` and `b` go to the top and the end, `e` edits it (up to 16 KB, see Notes).
|
||||
- **Captures** (`.pcap`): the packets as the LoRa Scanner lists them; Enter shows one with its Meshtastic header and bytes.
|
||||
- **Tracks** (`.gpx`): the number of points, the start, the duration and the distance.
|
||||
- **Update Files** (`.ota`): the version, and whether the file would install: it's checked as an install checks it (signature and contents) without writing anything. Enter then installs it.
|
||||
@@ -107,6 +124,16 @@ Enter on a file opens it by type; Tab switches to the same file as a hex dump or
|
||||
|
||||
At the top of the card the last row, **Maintenance** (also `m`), holds the card's usage, Storage Clean-up and "Erase SD card", behind a warning: those delete for good. It replaces Settings > Storage.
|
||||
|
||||
## Notes
|
||||
|
||||
The Notes App (docs/milestones/F1.md) keeps plain text notes in `/notes` on the SD card. The list shows each note's first line and its date, newest first; `s` switches to by file name. `n` starts a note, Enter opens one, `r` renames its file, `d` deletes it after asking.
|
||||
|
||||
In the editor, type. Enter starts a line, Del deletes backwards, Fn with the arrows moves the cursor through the wrapped text, Ctrl+A and Ctrl+E go to the start and the end of the line, Tab types two spaces, and the Compose Key gives accents as everywhere. **There's no save key:** the note is written five seconds after the last key, on Back, on leaving the App, when the screen turns off and before the device powers off. The top line says "typing" or "saved". Each save writes a temporary file and then puts it in the note's place, so a power cut costs a few seconds of typing and never the note; if a save was cut short, opening the note offers its copy back.
|
||||
|
||||
A new note has no file until something is typed; its file is then named after its first line (`shopping-list.txt`), or `note-<date>-<time>.txt`.
|
||||
|
||||
A note holds up to 16 KB while it's edited. A bigger text file opens read-only in the Storage App (editing any size is issue #47). The Storage App's text viewer has `e` to edit a file with the same editor, anywhere on the card, unless the file is read-only.
|
||||
|
||||
## Development aids
|
||||
|
||||
`scripts/serial_log.sh [seconds] [command…]` records the serial output, and can send commands to the firmware first. For example, `scripts/serial_log.sh 30 short sleep:12 burst` sets short screen timeouts, waits 12 s, then sends a burst of Toasts.
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends git build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN pip install --no-cache-dir platformio
|
||||
RUN pip install --no-cache-dir platformio gcovr
|
||||
|
||||
# Toolchains and libraries are cached in a named volume mounted here.
|
||||
RUN mkdir -p /pio && chmod 777 /pio
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# CI signs releases with the project's key
|
||||
|
||||
A tag `v*` is built, signed and published by Gitea Actions with nobody at a keyboard. The signing key of ADR 0003 is therefore held twice: in `~/.config/roro9stack/ota-key.pem` on the development machine, as before, and as the repository secret `OTA_SIGNING_KEY`, which the release step writes to a file for as long as it runs.
|
||||
|
||||
We chose this over signing by hand after CI has built (a command per release, the key in one place), and over a second key for CI that the firmware would also trust. A release that needs a manual step isn't made on the day it's ready, and issue #6, the device installing releases by itself, needs releases that are always there and always signed.
|
||||
|
||||
## What it costs
|
||||
|
||||
- **Whoever can run a workflow in this repository can sign firmware every device accepts.** That means: anyone who can push to it, the runner's host and whoever administers it, and the Gitea instance with its database, where the secret is stored. Before, it took the development machine.
|
||||
- The runner executes jobs **on its own host**, not in a container, as a user who can use Docker. A workflow is not confined.
|
||||
- Pull requests from forks must never run with this secret. Gitea doesn't pass secrets to them; the workflow also only runs on pushes and by hand.
|
||||
|
||||
## What limits it
|
||||
|
||||
- The release step checks the signed file against the public key in the sources it built (`scripts/ota_verify.py`): a wrong or replaced secret stops the release instead of publishing a file no device takes.
|
||||
- ADR 0003's way out stays: a firmware release can carry a new public key. If the secret is ever in doubt, make a new pair, ship it in a release signed with the old key, and replace the secret.
|
||||
- A device still only installs what it's told to (until #6), keeps a new image on Probation, and rolls back one that doesn't hold.
|
||||
+74
-1
@@ -1,6 +1,6 @@
|
||||
# F1 — Files and Notes
|
||||
|
||||
**Status:** in progress. The Storage App (issue #3) shipped as **v0.9.0** on 2026-10-06. Notes (#19) and the card as a USB drive (#1) come after.
|
||||
**Status:** in progress. The Storage App (issue #3) shipped as **v0.9.0** on 2026-10-06. Notes (#19) shipped as **v0.10.0** the same day. The card as a USB drive (#1) comes after.
|
||||
|
||||
**Goal:** get at what's on the SD card from the device itself: browse it, look inside the files the firmware writes, copy, move, rename and delete, and keep notes. A side milestone, like G1 and S1; Files and Notes were M3's original second half (Q30, Q89).
|
||||
|
||||
@@ -86,3 +86,76 @@ All in a scratch folder, `/f1test`, removed afterwards.
|
||||
**One slip during the checks:** a scripted key sequence ran in the wrong folder and renamed `/gemini/saved` to `saved2`, then copied it to the top of the card. Both were put right at once (renamed back, the copy deleted; 7 files, 53,798 bytes, as before).
|
||||
|
||||
**Found on the way:** a panic at Wi-Fi join, there since v0.7.0 (SNTP started twice, issue #46). Fixed in v0.9.0.
|
||||
|
||||
## Notes (issue #19)
|
||||
|
||||
Plain text notes on the SD card, written on the device. Q30 settled the base: `.txt` files in `/notes`, created, edited and deleted from the device, never offered by Storage Clean-up.
|
||||
|
||||
### Decisions (design round 2026-10-06)
|
||||
|
||||
| # | Decision |
|
||||
|---|---|
|
||||
| Q141 | A **Notes** App in the Launcher. One row per note: its first line as the title, then the date. Newest first; `s` switches to by name. `n` new, Enter opens, `d` deletes after a confirmation, `r` renames the file. |
|
||||
| Q142 | A new note's file name is never typed: it comes from the first line when the note is first saved (`shopping-list.txt`), or `note-20261006-0919.txt` if that line is empty. It doesn't change afterwards unless the note is renamed. |
|
||||
| Q143 | **Autosave, no "discard changes?" prompt:** five seconds after the last key, on leaving the note or the App, and when the screen turns off. A save writes a temporary file and renames it over the note, so a power cut loses the last few seconds at most. A temporary file left behind is offered back at the next open. |
|
||||
| Q144 | The whole note is in memory while it's edited, up to **16 KB**. A bigger text file opens read-only in the Storage App's viewer. The App refuses to open below the memory floors (Q86). **Editing files of any size must come in a later release: issue #47.** |
|
||||
| Q145 | The editor wraps at spaces, 38 columns by 8 rows, with a line for the name and the state. Enter is a new line, Del deletes backwards, Fn+arrows move (the Text Entry rule), Ctrl+A and Ctrl+E go to the start and the end of the line, Tab types two spaces, Back saves and returns. The Compose Key works as elsewhere. |
|
||||
| Q146 | The Storage App's text viewer gets `e`: edit this file with the same editor, for a text file up to 16 KB that isn't read-only. That lifts Q135 without Apps opening each other (#43 stays). |
|
||||
| Q147 | The list is flat: the files directly in `/notes`. Sub-folders are reached through the Storage App. |
|
||||
| Q148 | UTF-8, LF line ends; a file with CRLF is saved back with LF. Characters the font lacks are kept on save. |
|
||||
| Q149 | Left out, each with its issue: editing files of any size (#47), searching inside notes (#48), undo (#49), selecting and copying text (#50). |
|
||||
| Q150 | Ships as **v0.10.0** when built and checked on the device. |
|
||||
|
||||
### Done when
|
||||
|
||||
- A note can be started, typed with accents, left and found again in the list under its first line; renamed; deleted after a confirmation.
|
||||
- What's typed is on the card five seconds after the last key, and after Back, Home, or the screen turning off, without a prompt.
|
||||
- Pulling the power while typing loses a few seconds at most, and the note is never left empty or half-written.
|
||||
- The cursor moves by character and by line through wrapped text, and the screen follows it; a 16 KB note edits without lag.
|
||||
- A note at 16 KB refuses more text and says so; a bigger file opens read-only.
|
||||
- `e` in the Storage App's text viewer edits a file; a read-only one is refused with its reason.
|
||||
- Free heap stays above the floors with a 16 KB note open and IRC connected.
|
||||
|
||||
### Work breakdown
|
||||
|
||||
1. **Model** (host-tested): the text buffer with its cursor, wrapping and scrolling; file names from first lines.
|
||||
2. **The editor on the device:** loading, drawing, keys, autosave through a temporary file, recovery.
|
||||
3. **The Notes App:** the list with titles, new, rename, delete.
|
||||
4. **`e` in the Storage App.**
|
||||
5. **Checks on the device**, recorded here.
|
||||
|
||||
### As built
|
||||
|
||||
- **`NoteText`** (`lib/notes`, host-tested) is the text, its cursor and the screen around it. A line owns the space or the newline it ends with, so every byte is on exactly one line and the cursor has one place for each. **No index of lines is kept:** a note of newlines alone would need twice its own size for one. Where a line starts is worked out from the start of its paragraph.
|
||||
- **One buffer, 16 KB, for as long as the editor is open.** It's reserved when the note is opened, the file is read straight into it, and typing never makes it grow. On this device a failed allocation is an abort, and with IRC connected the largest free block is about 31 KB whatever the total says: the first version read the file into one string and copied it into another, and opening a full note with IRC connected restarted the device. The editor now also refuses to open without a free block of 24 KB.
|
||||
- **`NoteEditor`** (`src/apps/note_editor`) is shared by the Notes App and the Storage App's `e`. A save runs on the storage task while the main loop waits for it: no second copy of the note, and at 16 KB the wait is a fraction of a second at a moment when nobody has typed for five.
|
||||
- **A save** writes `<note>.tmp`, checks its size, deletes the note and renames the temporary file (FAT can't rename onto a file). A cut between the last two steps leaves only the `.tmp`: the Notes list puts such a file back under its name. A `.tmp` next to its note is an unfinished save: opening the note offers it.
|
||||
- **Titles** in the list are read from the card for the eight rows on screen, when the list moves.
|
||||
- **Before powering off**, the firmware now leaves the foreground App (`PowerService::beforePowerOff`), which makes the editor save.
|
||||
- Shift or Alt with Fn+Up and Fn+Down moves a page (not in Q145).
|
||||
|
||||
### Found on the way
|
||||
|
||||
- **The screen could go "off" for one tick after a key sent through the Debug Console**, and the next key was then swallowed as a wake-up: the `key` command stamps the power timer from `millis()`, the power tick compares with its pass's older time, and the unsigned difference read as 49 days idle. The same shape as #46. Fixed in `PowerPolicy::update` with a test. Keys from the keyboard were never affected. It explains remote keys "lost" in earlier sessions.
|
||||
- **`scripts/rdbg.py` held back piped lines** written while it was still connecting, until the next line came (a buffered `readline()` behind `select()`). Fixed.
|
||||
|
||||
### Checks on the device (2026-10-06, Debug Build of branch `notes`)
|
||||
|
||||
Test notes were made in `/notes` and removed afterwards; the folder is left, empty.
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| Host tests | 439 pass |
|
||||
| A first note | "No notes yet", `n`, typed three lines: the top line says "typing", then "saved" five seconds after the last key, under `shopping-list.txt`. 63 keys in a row all arrived |
|
||||
| Leaving | Back saves and returns to the list, which shows the note under its first line. Home in the middle of a new note saved it as `ideas.txt` |
|
||||
| The cursor | Down, Right, an insertion in the middle of a line; the screen scrolls through a note of about 230 lines |
|
||||
| A power cut | Typed, waited seven seconds, typed more and restarted the device at once (`reset`): the note has what was saved, whole, and not the last keys |
|
||||
| An unfinished save | A `.tmp` next to its note: "Unsaved copy... Keep the note / Use the copy"; using it brings its text back and saves it. A `.tmp` alone was put back under its name when the list opened |
|
||||
| 16 KB | A note of exactly 16,384 bytes opens and scrolls; one more character: "This note is full: 16 KB". A file of 16,398 bytes: "Too big to edit: 16 KB at most" |
|
||||
| Rename, delete, sort | `r` renamed `orphan.txt` to `orphan2.txt`; `d` asked, then deleted; `s` switched between newest first and by file name |
|
||||
| `e` in the Storage App | A note opened from the text viewer, edited, saved on Back; the listing shows its new size |
|
||||
| Memory | IRC connected, the full 16 KB note open: 55 KB free, largest block 31.7 KB (72 KB free before opening) |
|
||||
|
||||
**Not checked:** accents through the Compose Key and Ctrl+A / Ctrl+E (the remote `key` command can't send them; the model's tests cover both), the power button's save (it needs a hand on the device), a missing card, and how typing feels on the keyboard itself.
|
||||
|
||||
**One slip during the checks:** a key sequence sent right after a restart opened IRC instead of Notes, and the test letters went into IRC's input line. Nothing was sent: the line was cleared and the App left. IRC connected to Libera as it does when opened.
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# R1 — Releases
|
||||
|
||||
**Status:** in progress. CI and signed releases on Gitea (issue #5) are in place since 2026-10-06: every tag from v0.1.0 to v0.10.0 has its release. Next: updates from Gitea (#6), which waited for this, and the Issues App (#4).
|
||||
|
||||
**Goal:** a tag is a release, built the same way every time and published where a device can find it.
|
||||
|
||||
## CI and releases (issue #5)
|
||||
|
||||
Until now the tests, the builds, the signing and the flashing all happened on one machine, through `scripts/ci.sh` and `scripts/flash.sh`. Nothing was published.
|
||||
|
||||
### Decisions (design round 2026-10-06)
|
||||
|
||||
| # | Decision |
|
||||
|---|---|
|
||||
| Q151 | A push, to any branch: the host tests (with their coverage). A pull request: the same and both builds; changes reach `main` through pull requests. A tag `v*`: all of it, then a release. (First: everything on every push, which rebuilt the firmware far more often than anyone looked at it.) |
|
||||
| Q152 | **CI signs.** The signing key is the repository secret `OTA_SIGNING_KEY`; a tag push makes a complete, signed release with no manual step (ADR 0008). |
|
||||
| Q153 | The Debug Build is built in CI with a token of the runner's own, to prove it compiles, and **isn't published**: it would hand everyone its Debug Console token. |
|
||||
| Q154 | Pull requests from forks don't start a run. |
|
||||
| Q155 | A release carries `roro9stack-<version>.ota` (signed), `-factory.bin` for USB, `.elf.gz` to decode crashes, and `SHA256SUMS`. |
|
||||
| Q156 | Its text is the tag's message, what the files are, and the commits since the tag before. |
|
||||
| Q157 | No cache service to begin with: measure first. |
|
||||
| Q158 | Reproducible builds aren't needed for signing any more (Q152); not pursued here. |
|
||||
| Q159 | **The tags from before CI get their releases too**, v0.1.0 to v0.10.0, built from each tag's own sources by running the workflow by hand. |
|
||||
| Q160 | Actions is switched on for the repository. |
|
||||
| Q161 | **Changes reach `main` through pull requests, merged as "rebase, then a merge commit"**, the only style the repository allows: the branch's commits keep their messages, the merge commit marks the pull request, and what CI tested is what lands. No squash, no fast-forward. |
|
||||
|
||||
### As built
|
||||
|
||||
- **One workflow, `.gitea/workflows/ci.yml`, one job**, on the runner `runner0` (label `ubuntu`). The job asks for a `python:3.12-slim` container, installs git, a compiler, openssl and PlatformIO, and runs the same scripts as a developer's machine. No Docker inside the job.
|
||||
- **The cache is a Docker volume**, `roro9stack-pio`, mounted at `/pio`; the runner's `config.yaml` allows it under `container.valid_volumes`. A first run downloads about 1 GB and rebuilds the framework (17 minutes); with the volume filled, tests and both builds take about 6.
|
||||
- **No JavaScript actions**, so the image needs no Node and nothing is fetched from GitHub: the checkout is four git commands.
|
||||
- **`scripts/_docker.sh`** runs the command in place when `RORO_NO_DOCKER` is set (a CI job is already in a build container), and in the project's image otherwise. The Debug Build's token is made on the spot in CI and goes with the container.
|
||||
- **`scripts/release_build.sh <checkout> <out>`** builds a tag's own sources with today's tools, signs, verifies against the public key in those sources, and writes the files and the release's text. **`scripts/release_publish.py`** creates the Gitea release or completes it; run twice, it replaces what's there. Both run the same on a developer's machine.
|
||||
- **`scripts/ota_verify.py`** checks an Update File as a device does, on a PC.
|
||||
- **The job's own token** (`secrets.GITEA_TOKEN`) is enough to create a release and upload its files.
|
||||
- **Old tags.** v0.1.0 to v0.3.0 are from before the framework was rebuilt with our settings (ADR 0006) and can't link against a rebuilt one left in the cache: the release build puts the stock framework libraries back for them. v0.1.0 to v0.2.1 have no public key in their sources (Firmware Updates came with v0.3.0); their files are checked against today's.
|
||||
|
||||
### How it went
|
||||
|
||||
- **The runner's label took three tries.** Registered as `ubuntu://docker:ubuntu:resolute` and then as `ubuntu::docker://...`, Gitea took the whole string for the label's name; with the first, jobs ran on the runner's host itself. The first version of the workflow was written for that (plain shell, `docker run` for the build) and published v0.10.0 that way. `ubuntu:docker://docker.gitea.com/runner-images:ubuntu-latest` is the form that works.
|
||||
- **Gitea 1.27's API can't cancel a run that isn't finished**, only delete a finished one; switching Actions off and on for the repository doesn't either. Runs queued for a label that no longer exists stay queued until cancelled in the web UI.
|
||||
- **CI's image isn't byte-identical to a local build of the same tag** (same size, different bytes). Not pursued (Q158).
|
||||
@@ -0,0 +1,329 @@
|
||||
#include "note_text.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace roro::notes {
|
||||
|
||||
namespace {
|
||||
bool continuation(char c) { return (static_cast<uint8_t>(c) & 0xC0) == 0x80; }
|
||||
} // namespace
|
||||
|
||||
NoteText::NoteText(int cols, int rows) : cols_(std::max(1, cols)), rows_(std::max(1, rows)) { text_.reserve(kMaxBytes); }
|
||||
|
||||
NoteText::NoteText(int cols, int rows, std::string&& text) : cols_(std::max(1, cols)), rows_(std::max(1, rows)), text_(std::move(text)) {
|
||||
dropCarriageReturns();
|
||||
if (text_.size() > kMaxBytes) text_.resize(kMaxBytes); // the caller checks sizes: not reached
|
||||
text_.reserve(kMaxBytes);
|
||||
}
|
||||
|
||||
void NoteText::dropCarriageReturns() {
|
||||
size_t kept = 0;
|
||||
for (size_t i = 0; i < text_.size(); i++)
|
||||
if (!(text_[i] == '\r' && i + 1 < text_.size() && text_[i + 1] == '\n')) text_[kept++] = text_[i];
|
||||
text_.resize(kept);
|
||||
}
|
||||
|
||||
bool NoteText::setText(const std::string& text) {
|
||||
size_t kept = text.size();
|
||||
for (size_t i = 0; i + 1 < text.size(); i++)
|
||||
if (text[i] == '\r' && text[i + 1] == '\n') kept--;
|
||||
if (kept > kMaxBytes) return false;
|
||||
text_.assign(text);
|
||||
dropCarriageReturns();
|
||||
cursor_ = top_ = 0;
|
||||
goal_ = -1;
|
||||
revision_++;
|
||||
return true;
|
||||
}
|
||||
|
||||
size_t NoteText::nextLine(size_t start) const {
|
||||
size_t size = text_.size();
|
||||
int count = 0;
|
||||
size_t lastSpace = 0;
|
||||
bool space = false;
|
||||
for (size_t i = start; i < size; i++) {
|
||||
char c = text_[i];
|
||||
if (c == '\n') return i + 1;
|
||||
if (continuation(c)) continue;
|
||||
if (count == cols_) { // one character too many: wrap
|
||||
if (c == ' ') return i + 1; // the space stays at the end of this line
|
||||
if (space) return lastSpace + 1; // after the last space that fits
|
||||
return i; // a word longer than the screen is cut
|
||||
}
|
||||
count++;
|
||||
if (c == ' ') {
|
||||
lastSpace = i;
|
||||
space = true;
|
||||
}
|
||||
}
|
||||
return size;
|
||||
}
|
||||
|
||||
size_t NoteText::lineOf(size_t pos) const {
|
||||
size_t size = text_.size();
|
||||
pos = std::min(pos, size);
|
||||
size_t a = pos; // the start of the paragraph: after the newline before `pos`
|
||||
while (a > 0 && text_[a - 1] != '\n') a--;
|
||||
while (a < size) {
|
||||
size_t n = nextLine(a);
|
||||
if (n > pos) return a;
|
||||
// The end of the text is on the last line, unless that line ended with a newline: then
|
||||
// it's on an empty line of its own.
|
||||
if (n == size && pos == size && text_[size - 1] != '\n') return a;
|
||||
a = n;
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
bool NoteText::hasLineAfter(size_t start) const {
|
||||
size_t n = nextLine(start), size = text_.size();
|
||||
if (n < size) return true;
|
||||
return start < size && lineOf(size) != start; // the empty line after a final newline
|
||||
}
|
||||
|
||||
size_t NoteText::lastSpot(size_t start) const {
|
||||
size_t n = nextLine(start), size = text_.size();
|
||||
if (n == start) return start; // the empty line at the end
|
||||
if (n == size && lineOf(size) == start) return size;
|
||||
size_t p = n - 1; // before the newline, the space or the last character the line ends with
|
||||
while (p > start && continuation(text_[p])) p--;
|
||||
return p;
|
||||
}
|
||||
|
||||
int NoteText::columnOf(size_t start, size_t pos) const {
|
||||
int col = 0;
|
||||
for (size_t i = start; i < pos && i < text_.size(); i++)
|
||||
if (!continuation(text_[i])) col++;
|
||||
return col;
|
||||
}
|
||||
|
||||
size_t NoteText::atColumn(size_t start, int col) const {
|
||||
size_t last = lastSpot(start), p = start;
|
||||
while (p < last && col > 0) {
|
||||
p++;
|
||||
while (p < last && continuation(text_[p])) p++;
|
||||
col--;
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
void NoteText::moved(bool keepGoal) {
|
||||
if (!keepGoal) goal_ = -1;
|
||||
}
|
||||
|
||||
bool NoteText::insertText(const std::string& s) {
|
||||
if (text_.size() + s.size() > kMaxBytes) return false;
|
||||
text_.insert(cursor_, s);
|
||||
cursor_ += s.size();
|
||||
revision_++;
|
||||
moved();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool NoteText::insert(uint32_t cp) {
|
||||
std::string s;
|
||||
if (cp < 0x80) s += static_cast<char>(cp);
|
||||
else if (cp < 0x800) {
|
||||
s += static_cast<char>(0xC0 | (cp >> 6));
|
||||
s += static_cast<char>(0x80 | (cp & 0x3F));
|
||||
} else if (cp < 0x10000) {
|
||||
s += static_cast<char>(0xE0 | (cp >> 12));
|
||||
s += static_cast<char>(0x80 | ((cp >> 6) & 0x3F));
|
||||
s += static_cast<char>(0x80 | (cp & 0x3F));
|
||||
} else {
|
||||
s += static_cast<char>(0xF0 | (cp >> 18));
|
||||
s += static_cast<char>(0x80 | ((cp >> 12) & 0x3F));
|
||||
s += static_cast<char>(0x80 | ((cp >> 6) & 0x3F));
|
||||
s += static_cast<char>(0x80 | (cp & 0x3F));
|
||||
}
|
||||
return insertText(s);
|
||||
}
|
||||
|
||||
void NoteText::backspace() {
|
||||
if (cursor_ == 0) return;
|
||||
size_t from = cursor_ - 1;
|
||||
while (from > 0 && continuation(text_[from])) from--;
|
||||
text_.erase(from, cursor_ - from);
|
||||
cursor_ = from;
|
||||
revision_++;
|
||||
moved();
|
||||
}
|
||||
|
||||
void NoteText::left() {
|
||||
if (cursor_ == 0) return;
|
||||
cursor_--;
|
||||
while (cursor_ > 0 && continuation(text_[cursor_])) cursor_--;
|
||||
moved();
|
||||
}
|
||||
|
||||
void NoteText::right() {
|
||||
if (cursor_ >= text_.size()) return;
|
||||
cursor_++;
|
||||
while (cursor_ < text_.size() && continuation(text_[cursor_])) cursor_++;
|
||||
moved();
|
||||
}
|
||||
|
||||
void NoteText::up() {
|
||||
size_t line = lineOf(cursor_);
|
||||
if (goal_ < 0) goal_ = columnOf(line, cursor_);
|
||||
if (line == 0) return;
|
||||
cursor_ = atColumn(lineOf(line - 1), goal_);
|
||||
moved(true);
|
||||
}
|
||||
|
||||
void NoteText::down() {
|
||||
size_t line = lineOf(cursor_);
|
||||
if (goal_ < 0) goal_ = columnOf(line, cursor_);
|
||||
if (!hasLineAfter(line)) return;
|
||||
size_t next = nextLine(line);
|
||||
cursor_ = next >= text_.size() ? text_.size() : atColumn(next, goal_);
|
||||
moved(true);
|
||||
}
|
||||
|
||||
void NoteText::pageUp() {
|
||||
for (int i = 1; i < rows_; i++) up();
|
||||
}
|
||||
|
||||
void NoteText::pageDown() {
|
||||
for (int i = 1; i < rows_; i++) down();
|
||||
}
|
||||
|
||||
void NoteText::lineStart() {
|
||||
cursor_ = lineOf(cursor_);
|
||||
moved();
|
||||
}
|
||||
|
||||
void NoteText::lineEnd() {
|
||||
cursor_ = lastSpot(lineOf(cursor_));
|
||||
moved();
|
||||
}
|
||||
|
||||
void NoteText::toStart() {
|
||||
cursor_ = 0;
|
||||
moved();
|
||||
}
|
||||
|
||||
void NoteText::toEnd() {
|
||||
cursor_ = text_.size();
|
||||
moved();
|
||||
}
|
||||
|
||||
void NoteText::follow() {
|
||||
size_t line = lineOf(cursor_);
|
||||
top_ = lineOf(std::min(top_, text_.size())); // an edit above may have moved where lines start
|
||||
if (line < top_) {
|
||||
top_ = line;
|
||||
return;
|
||||
}
|
||||
size_t a = top_;
|
||||
for (int i = 0; i < rows_; i++) {
|
||||
if (a == line) return; // on screen
|
||||
if (!hasLineAfter(a)) return;
|
||||
a = nextLine(a);
|
||||
}
|
||||
// Below the screen: the cursor's line becomes the last row.
|
||||
top_ = line;
|
||||
for (int i = 1; i < rows_ && top_ > 0; i++) top_ = lineOf(top_ - 1);
|
||||
}
|
||||
|
||||
std::vector<std::string> NoteText::rows() {
|
||||
follow();
|
||||
std::vector<std::string> out;
|
||||
size_t a = top_, size = text_.size();
|
||||
for (int i = 0; i < rows_; i++) {
|
||||
size_t n = nextLine(a);
|
||||
std::string row = text_.substr(a, n - a);
|
||||
if (!row.empty() && row.back() == '\n') row.pop_back();
|
||||
for (char& c : row)
|
||||
if (c == '\t') c = ' ';
|
||||
out.push_back(std::move(row));
|
||||
if (!hasLineAfter(a)) break;
|
||||
a = n >= size ? size : n;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
int NoteText::cursorRow() {
|
||||
follow();
|
||||
size_t line = lineOf(cursor_), a = top_;
|
||||
for (int i = 0; i < rows_; i++) {
|
||||
if (a == line) return i;
|
||||
a = nextLine(a);
|
||||
}
|
||||
return rows_ - 1;
|
||||
}
|
||||
|
||||
int NoteText::cursorCol() { return columnOf(lineOf(cursor_), cursor_); }
|
||||
|
||||
// At most a screen's worth of it: a note that is one line of 16 KB must not be copied whole.
|
||||
std::string NoteText::firstLine() const { return text_.substr(0, std::min<size_t>(text_.find('\n'), 160)); }
|
||||
|
||||
namespace {
|
||||
// U+00C0 to U+00FF as the plain letters a file name gets; 0: left out.
|
||||
const char kPlain[64] = {
|
||||
'a', 'a', 'a', 'a', 'a', 'a', 0, 'c', 'e', 'e', 'e', 'e', 'i', 'i', 'i', 'i', // À..Ï
|
||||
0, 'n', 'o', 'o', 'o', 'o', 'o', 0, 'o', 'u', 'u', 'u', 'u', 'y', 0, 's', // Ð..ß
|
||||
'a', 'a', 'a', 'a', 'a', 'a', 0, 'c', 'e', 'e', 'e', 'e', 'i', 'i', 'i', 'i', // à..ï
|
||||
0, 'n', 'o', 'o', 'o', 'o', 'o', 0, 'o', 'u', 'u', 'u', 'u', 'y', 0, 'y'}; // ð..ÿ
|
||||
|
||||
// Drops a character the end of the string cuts in two.
|
||||
void dropPartial(std::string& s) {
|
||||
size_t k = s.size();
|
||||
while (k > 0 && continuation(s[k - 1]) && s.size() - k < 3) k--;
|
||||
if (k == 0) return;
|
||||
uint8_t lead = static_cast<uint8_t>(s[k - 1]);
|
||||
size_t want = lead >= 0xF0 ? 4 : lead >= 0xE0 ? 3 : lead >= 0xC0 ? 2 : 1;
|
||||
if (s.size() - (k - 1) < want) s.resize(k - 1);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
std::string nameFromFirstLine(const std::string& firstLine, const std::string& stamp) {
|
||||
std::string out;
|
||||
bool dash = false;
|
||||
for (size_t i = 0; i < firstLine.size() && out.size() < 32; i++) {
|
||||
uint8_t c = static_cast<uint8_t>(firstLine[i]);
|
||||
char letter = 0;
|
||||
if (c < 0x80) {
|
||||
if (c >= 'A' && c <= 'Z') letter = static_cast<char>(c + 32);
|
||||
else if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')) letter = static_cast<char>(c);
|
||||
} else if (c == 0xC3 && i + 1 < firstLine.size()) { // U+00C0..U+00FF
|
||||
letter = kPlain[static_cast<uint8_t>(firstLine[++i]) & 0x3F];
|
||||
} else {
|
||||
while (i + 1 < firstLine.size() && continuation(firstLine[i + 1])) i++; // anything else is left out
|
||||
}
|
||||
if (letter) {
|
||||
if (dash && !out.empty()) out += '-';
|
||||
dash = false;
|
||||
out += letter;
|
||||
} else {
|
||||
dash = true;
|
||||
}
|
||||
}
|
||||
return out.empty() ? "note-" + stamp : out;
|
||||
}
|
||||
|
||||
std::string titleFrom(const std::string& head, size_t maxChars) {
|
||||
for (size_t at = 0; at < head.size();) {
|
||||
size_t end = head.find('\n', at);
|
||||
bool cut = end == std::string::npos; // the line goes on past what was read
|
||||
if (cut) end = head.size();
|
||||
std::string line = head.substr(at, end - at);
|
||||
if (cut) dropPartial(line);
|
||||
while (!line.empty() && (line.back() == '\r' || line.back() == ' ' || line.back() == '\t')) line.pop_back();
|
||||
size_t first = line.find_first_not_of(" \t");
|
||||
if (first != std::string::npos) {
|
||||
line.erase(0, first);
|
||||
size_t bytes = 0;
|
||||
for (size_t chars = 0; bytes < line.size() && chars < maxChars; chars++) {
|
||||
bytes++;
|
||||
while (bytes < line.size() && continuation(line[bytes])) bytes++;
|
||||
}
|
||||
line.resize(bytes);
|
||||
return line;
|
||||
}
|
||||
at = end + 1;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
} // namespace roro::notes
|
||||
@@ -0,0 +1,81 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace roro::notes {
|
||||
|
||||
// The text of a note while it's edited (F1, Q144, Q145): UTF-8 held whole in memory, a cursor, and
|
||||
// the part of it on screen. Lines wrap at spaces, `cols` characters wide; a line owns the space or
|
||||
// the newline it ends with, so every byte of the text belongs to exactly one line. No index of
|
||||
// lines is kept (a note of newlines alone would need twice its size): where a line starts is
|
||||
// worked out from the start of its paragraph, which is never far.
|
||||
class NoteText {
|
||||
public:
|
||||
static constexpr size_t kMaxBytes = 16 * 1024;
|
||||
|
||||
// Room for a full note is reserved once, so typing never has to find a bigger block of memory:
|
||||
// on the device a failed allocation is the end. The second form takes over a string the
|
||||
// caller filled (and reserved): a note is never in memory twice.
|
||||
NoteText(int cols, int rows);
|
||||
NoteText(int cols, int rows, std::string&& text);
|
||||
|
||||
// Copied into the buffer already held. CRLF becomes LF (Q148). False, and nothing changes, if
|
||||
// it's over kMaxBytes.
|
||||
bool setText(const std::string& text);
|
||||
const std::string& text() const { return text_; }
|
||||
size_t cursor() const { return cursor_; }
|
||||
uint32_t revision() const { return revision_; } // changes with every edit: is it saved?
|
||||
|
||||
bool insert(uint32_t codePoint); // false: the note is full
|
||||
bool insertText(const std::string& s); // all of it or nothing
|
||||
void backspace();
|
||||
void left();
|
||||
void right();
|
||||
void up();
|
||||
void down();
|
||||
void pageUp();
|
||||
void pageDown();
|
||||
void lineStart();
|
||||
void lineEnd();
|
||||
void toStart();
|
||||
void toEnd();
|
||||
|
||||
// The screen, kept around the cursor: its rows (tabs as spaces, the ending newline left out),
|
||||
// and where the cursor is on it, in rows and characters.
|
||||
std::vector<std::string> rows();
|
||||
int cursorRow();
|
||||
int cursorCol();
|
||||
int percent() const { return text_.empty() ? 0 : static_cast<int>(top_ * 100 / text_.size()); }
|
||||
|
||||
std::string firstLine() const; // without its newline, for the title and the file's name
|
||||
|
||||
private:
|
||||
size_t nextLine(size_t start) const; // where the line after the one at `start` starts
|
||||
size_t lineOf(size_t pos) const; // the start of the line `pos` is on
|
||||
size_t lastSpot(size_t start) const; // the last place the cursor can be on that line
|
||||
size_t atColumn(size_t start, int col) const;
|
||||
int columnOf(size_t start, size_t pos) const;
|
||||
bool hasLineAfter(size_t start) const;
|
||||
void moved(bool keepGoal = false);
|
||||
void follow(); // scrolls so the cursor is on screen
|
||||
void dropCarriageReturns();
|
||||
|
||||
int cols_, rows_;
|
||||
std::string text_;
|
||||
size_t cursor_ = 0, top_ = 0;
|
||||
int goal_ = -1; // the column Up and Down aim for, across short lines
|
||||
uint32_t revision_ = 0;
|
||||
};
|
||||
|
||||
// The file a new note is saved as (Q142): from its first line, "Shopping list!" -> "shopping-list",
|
||||
// or "note-<stamp>" when that gives nothing. No extension, no folder.
|
||||
std::string nameFromFirstLine(const std::string& firstLine, const std::string& stamp);
|
||||
|
||||
// A row's title in the Notes list, from the first bytes of a file: its first line that isn't
|
||||
// blank, cut to `maxChars`; "" if there's none.
|
||||
std::string titleFrom(const std::string& head, size_t maxChars);
|
||||
|
||||
} // namespace roro::notes
|
||||
@@ -10,7 +10,10 @@ bool PowerPolicy::activity(uint32_t nowMs) {
|
||||
}
|
||||
|
||||
ScreenState PowerPolicy::update(uint32_t nowMs) {
|
||||
uint32_t idle = nowMs - lastActivityMs_;
|
||||
// Activity stamped from a clock read after this pass's nowMs (the Debug Console's `key`) is in
|
||||
// the future, not 49 days ago: unsigned, the screen went off for a tick and ate the next key.
|
||||
int32_t since = static_cast<int32_t>(nowMs - lastActivityMs_);
|
||||
uint32_t idle = since < 0 ? 0 : static_cast<uint32_t>(since);
|
||||
state_ = idle >= offMs_ ? ScreenState::Off : idle >= dimMs_ ? ScreenState::Dimmed : ScreenState::On;
|
||||
if (notifying_) {
|
||||
if (static_cast<int32_t>(nowMs - notifyUntilMs_) >= 0)
|
||||
|
||||
@@ -46,3 +46,14 @@ build_flags =
|
||||
platform = native
|
||||
lib_ldf_mode = deep+
|
||||
build_flags = -std=gnu++17
|
||||
|
||||
; The same tests, built to count which lines of lib/ they run (scripts/coverage.sh).
|
||||
[env:native-coverage]
|
||||
extends = env:native
|
||||
build_flags =
|
||||
${env:native.build_flags}
|
||||
--coverage
|
||||
-O0
|
||||
extra_scripts =
|
||||
${env.extra_scripts}
|
||||
pre:scripts/coverage_link.py
|
||||
|
||||
+7
-1
@@ -1,8 +1,10 @@
|
||||
# Shared helper: run a command inside the roro9stack build container.
|
||||
# With RORO_NO_DOCKER set, the caller is in such a container already (a CI job): the command runs
|
||||
# right here, in the checkout.
|
||||
IMAGE=roro9stack-build
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
docker build -q -t "$IMAGE" "$ROOT/docker" >/dev/null
|
||||
[ -n "${RORO_NO_DOCKER:-}" ] || docker build -q -t "$IMAGE" "$ROOT/docker" >/dev/null
|
||||
|
||||
# The Debug Console token (ADR 0004): made once, kept with the OTA key, never committed.
|
||||
DEBUG_TOKEN_FILE="$HOME/.config/roro9stack/debug-token"
|
||||
@@ -12,6 +14,10 @@ if [ ! -s "$DEBUG_TOKEN_FILE" ]; then
|
||||
fi
|
||||
|
||||
run_in_container() {
|
||||
if [ -n "${RORO_NO_DOCKER:-}" ]; then
|
||||
(cd "$ROOT" && RORO_DEBUG_TOKEN="$(cat "$DEBUG_TOKEN_FILE")" "$@")
|
||||
return
|
||||
fi
|
||||
docker run --rm \
|
||||
-u "$(id -u):$(id -g)" -e HOME=/tmp \
|
||||
-e RORO_DEBUG_TOKEN="$(cat "$DEBUG_TOKEN_FILE")" \
|
||||
|
||||
+8
-1
@@ -1,7 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
# Local CI: run host unit tests, then build the firmware.
|
||||
# Usage: scripts/ci.sh [tests|builds] one half only; both by default
|
||||
set -euo pipefail
|
||||
source "$(dirname "$0")/_docker.sh"
|
||||
DOCKER_EXTRA=()
|
||||
|
||||
run_in_container bash -c 'git config --global --add safe.directory /work && pio test -e native && pio run -e cardputer-adv -e cardputer-adv-debug'
|
||||
case "${1:-all}" in
|
||||
tests) STEPS='pio test -e native' ;;
|
||||
builds) STEPS='pio run -e cardputer-adv -e cardputer-adv-debug' ;;
|
||||
all) STEPS='pio test -e native && pio run -e cardputer-adv -e cardputer-adv-debug' ;;
|
||||
*) echo "Usage: scripts/ci.sh [tests|builds]" >&2; exit 1 ;;
|
||||
esac
|
||||
run_in_container bash -c 'git config --global --add safe.directory "$PWD" && '"$STEPS"
|
||||
|
||||
Executable
+21
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runs the host tests and says how much of lib/ they run: they're built with coverage counters.
|
||||
# Usage: scripts/coverage.sh [out folder, default .pio/coverage]
|
||||
# Out: summary.json (gcovr), coverage.svg (the README's badge), index.html (line by line).
|
||||
# What it measures: the lines of lib/ that compile on a PC. Not lib/SD (the card's driver) and not
|
||||
# src/ (the Apps, the Services, everything that needs the device): those have no host tests.
|
||||
set -euo pipefail
|
||||
source "$(dirname "$0")/_docker.sh"
|
||||
DOCKER_EXTRA=()
|
||||
OUT="${1:-.pio/coverage}"
|
||||
|
||||
run_in_container bash -c '
|
||||
set -eo pipefail # a failing test fails this script, tail or not
|
||||
git config --global --add safe.directory "$PWD"
|
||||
rm -rf .pio/build/native-coverage "'"$OUT"'"
|
||||
mkdir -p "'"$OUT"'"
|
||||
pio test -e native-coverage | tail -1
|
||||
gcovr -r . --filter "lib/" --object-directory .pio/build/native-coverage \
|
||||
--json-summary "'"$OUT"'/summary.json" --html-details "'"$OUT"'/index.html" --print-summary | tail -4
|
||||
python3 scripts/coverage_badge.py "'"$OUT"'/summary.json" "'"$OUT"'/coverage.svg"
|
||||
'
|
||||
Executable
+46
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Draws the README's coverage badge from gcovr's summary.
|
||||
|
||||
Usage: scripts/coverage_badge.py <summary.json> <out.svg>
|
||||
scripts/coverage_badge.py --plain <label> <value> <out.svg> any other badge, in blue
|
||||
Also prints one line, and appends a short table to $GITHUB_STEP_SUMMARY when CI sets it.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def badge(label, value, color, title):
|
||||
left, right = 6 * len(label) + 12, 7 * len(value) + 12
|
||||
return f'''<svg xmlns="http://www.w3.org/2000/svg" width="{left + right}" height="20" role="img" aria-label="{label}: {value}">
|
||||
<title>{title}</title>
|
||||
<linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient>
|
||||
<clipPath id="r"><rect width="{left + right}" height="20" rx="3" fill="#fff"/></clipPath>
|
||||
<g clip-path="url(#r)"><rect width="{left}" height="20" fill="#555"/><rect x="{left}" width="{right}" height="20" fill="{color}"/><rect width="{left + right}" height="20" fill="url(#s)"/></g>
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
||||
<text x="{left / 2}" y="14">{label}</text><text x="{left + right / 2}" y="14">{value}</text></g></svg>
|
||||
'''
|
||||
|
||||
|
||||
def main():
|
||||
if sys.argv[1] == "--plain": # any other badge: --plain <label> <value> <out.svg>
|
||||
label, value, out = sys.argv[2:5]
|
||||
open(out, "w").write(badge(label, value, "#007ec6", f"{label}: {value}"))
|
||||
return
|
||||
summary = json.load(open(sys.argv[1]))
|
||||
lines, branches = summary["line_percent"], summary["branch_percent"]
|
||||
label, value = "lib coverage", f"{lines:.0f}%"
|
||||
color = "#4c1" if lines >= 90 else "#a4a61d" if lines >= 75 else "#dfb317" if lines >= 60 else "#e05d44"
|
||||
svg = badge(label, value, color, f"{label}: {value} of the lines of lib/ are run by the host tests")
|
||||
open(sys.argv[2], "w").write(svg)
|
||||
print(f"coverage of lib/: {lines:.1f}% of {summary['line_total']} lines, {branches:.1f}% of branches, {len(summary['files'])} files")
|
||||
step = os.environ.get("GITHUB_STEP_SUMMARY")
|
||||
if step:
|
||||
worst = sorted((f["line_percent"], f["filename"]) for f in summary["files"] if f["line_total"] >= 10)[:5]
|
||||
with open(step, "a") as out:
|
||||
out.write(f"### Coverage of `lib/` by the host tests\n\n**{lines:.1f}%** of {summary['line_total']} lines, {branches:.1f}% of branches.\n\n")
|
||||
out.write("| Least covered | Lines |\n|---|---|\n" + "".join(f"| `{name}` | {pct:.0f}% |\n" for pct, name in worst))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,4 @@
|
||||
# The coverage build must also link with --coverage (build_flags only reaches the compiler).
|
||||
Import("env") # noqa: F821 (provided by PlatformIO)
|
||||
|
||||
env.Append(LINKFLAGS=["--coverage"]) # noqa: F821
|
||||
Executable
+49
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Checks an Update File (.ota) the way the device does, on a PC: header, signature, image hash.
|
||||
|
||||
Usage: scripts/ota_verify.py <file.ota> [public key, default keys/ota-public.pem]
|
||||
Exits 0 and prints the version if a device would accept the file.
|
||||
"""
|
||||
import hashlib
|
||||
import os
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
HEADER_SIZE = 160
|
||||
SIGNED_BYTES = 80
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
sys.exit(__doc__)
|
||||
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
key = sys.argv[2] if len(sys.argv) > 2 else os.path.join(root, "keys", "ota-public.pem")
|
||||
data = open(sys.argv[1], "rb").read()
|
||||
if len(data) < HEADER_SIZE or data[:8] != b"RORO-OTA":
|
||||
sys.exit("not an update file")
|
||||
fmt, header_size, image_size = struct.unpack("<HHI", data[8:16])
|
||||
if fmt != 1 or header_size != HEADER_SIZE:
|
||||
sys.exit("unsupported update format")
|
||||
image = data[HEADER_SIZE:]
|
||||
if len(image) != image_size:
|
||||
sys.exit(f"the header announces {image_size} bytes of image, the file has {len(image)}")
|
||||
if hashlib.sha256(image).digest() != data[16:48]:
|
||||
sys.exit("image corrupted (hash mismatch)")
|
||||
version = data[48:80].split(b"\0")[0].decode()
|
||||
(sig_len,) = struct.unpack("<H", data[80:82])
|
||||
with tempfile.NamedTemporaryFile() as signed, tempfile.NamedTemporaryFile() as sig:
|
||||
signed.write(data[:SIGNED_BYTES])
|
||||
signed.flush()
|
||||
sig.write(data[82:82 + sig_len])
|
||||
sig.flush()
|
||||
ok = subprocess.run(["openssl", "dgst", "-sha256", "-verify", key, "-signature", sig.name, signed.name],
|
||||
capture_output=True).returncode == 0
|
||||
if not ok:
|
||||
sys.exit("bad signature (wrong key)")
|
||||
print(f"{sys.argv[1]}: {version}, {image_size} bytes, signature good")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+8
-3
@@ -224,10 +224,15 @@ def interactive(sock):
|
||||
sys.stdout.write(data.decode(errors="replace"))
|
||||
sys.stdout.flush()
|
||||
if sys.stdin in ready:
|
||||
line = sys.stdin.readline()
|
||||
if not line:
|
||||
# Straight from the descriptor: readline() takes every waiting line into Python's own
|
||||
# buffer and hands over one, and select() then sees nothing more to read. Piped input
|
||||
# written while we were still connecting got stuck until the next line came.
|
||||
data = os.read(sys.stdin.fileno(), 4096)
|
||||
if not data:
|
||||
return
|
||||
sock.sendall(line.encode())
|
||||
sock.setblocking(True)
|
||||
sock.sendall(data)
|
||||
sock.setblocking(False)
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
Executable
+62
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env bash
|
||||
# Builds what a release publishes, from a checkout of the repository at a tag (docs/milestones/R1.md).
|
||||
# Usage: scripts/release_build.sh <checkout> <out folder>
|
||||
# <checkout> a clone with its tags, at the commit to release: its own sources are built, with
|
||||
# this copy's build image and signing tools, so an old tag can be released today.
|
||||
# The signing key is read from $RORO_OTA_KEY (a file), as scripts/make_ota.py does.
|
||||
# Out: roro9stack-<version>.ota (signed, checked), -factory.bin (USB), .elf.gz (to decode crashes),
|
||||
# SHA256SUMS, and notes.md for the release's text.
|
||||
set -euo pipefail
|
||||
SRC="$(cd "$1" && pwd)"
|
||||
mkdir -p "$2"
|
||||
OUT="$(cd "$2" && pwd)"
|
||||
TOOLS="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
VERSION="$(git -C "$SRC" describe --tags --always --dirty)"
|
||||
case "$VERSION" in
|
||||
*-dirty) echo "release: $SRC has uncommitted changes ($VERSION)" >&2; exit 1 ;;
|
||||
esac
|
||||
git -C "$SRC" describe --tags --exact-match >/dev/null 2>&1 || { echo "release: $VERSION is not a tag" >&2; exit 1; }
|
||||
|
||||
source "$TOOLS/_docker.sh"
|
||||
ROOT="$SRC" # _docker.sh mounts $ROOT as /work: the checkout to build, not necessarily this copy
|
||||
DOCKER_EXTRA=()
|
||||
# A tag from before the framework was rebuilt with our settings (ADR 0006) can't link against a
|
||||
# rebuilt one left in the toolchain cache: it gets the framework's libraries as they come.
|
||||
if ! grep -q custom_sdkconfig "$SRC/platformio.ini"; then
|
||||
run_in_container bash -c 'rm -rf "${PLATFORMIO_CORE_DIR:-/pio}/packages/framework-arduinoespressif32-libs"'
|
||||
fi
|
||||
run_in_container bash -c 'git config --global --add safe.directory "$PWD" && pio run -e cardputer-adv'
|
||||
|
||||
BUILD="$SRC/.pio/build/cardputer-adv"
|
||||
NAME="roro9stack-$VERSION"
|
||||
"$TOOLS/make_ota.py" "$BUILD/firmware.bin" "$VERSION" "$OUT/$NAME.ota"
|
||||
# A wrong key must stop the release here, not on a device: checked against the public key the
|
||||
# sources being built carry (the tags from before Firmware Updates have none: today's, then).
|
||||
PUBLIC="$SRC/keys/ota-public.pem"
|
||||
[ -e "$PUBLIC" ] || PUBLIC="$TOOLS/../keys/ota-public.pem"
|
||||
"$TOOLS/ota_verify.py" "$OUT/$NAME.ota" "$PUBLIC"
|
||||
cp "$BUILD/firmware.factory.bin" "$OUT/$NAME-factory.bin"
|
||||
gzip -9 -c "$BUILD/firmware.elf" > "$OUT/$NAME.elf.gz"
|
||||
(cd "$OUT" && sha256sum "$NAME.ota" "$NAME-factory.bin" "$NAME.elf.gz" > SHA256SUMS)
|
||||
|
||||
# The release's text: what the tag says, then what went in since the tag before.
|
||||
PREVIOUS="$(git -C "$SRC" describe --tags --abbrev=0 "$VERSION^" 2>/dev/null || true)"
|
||||
{
|
||||
git -C "$SRC" tag -l --format='%(contents)' "$VERSION" | sed -e '/^-----BEGIN PGP/,$d'
|
||||
echo
|
||||
echo "## Files"
|
||||
echo
|
||||
echo "- \`$NAME.ota\`: the signed Update File. Copy it to \`/updates\` on the SD card and install it from Settings > Firmware or the Storage App, or push it over Wi-Fi with \`scripts/ota_push.py\`."
|
||||
echo "- \`$NAME-factory.bin\`: the whole flash image, for a first install over USB at offset 0."
|
||||
echo "- \`$NAME.elf.gz\`: the symbols, to decode a crash report from this build."
|
||||
echo "- \`SHA256SUMS\`: checksums of the three."
|
||||
if [ -n "$PREVIOUS" ]; then
|
||||
echo
|
||||
echo "## Changes since $PREVIOUS"
|
||||
echo
|
||||
git -C "$SRC" log --no-merges --format='- %s' "$PREVIOUS..$VERSION"
|
||||
fi
|
||||
} > "$OUT/notes.md"
|
||||
echo "$VERSION" > "$OUT/version"
|
||||
ls -l "$OUT"
|
||||
Executable
+65
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Creates or completes a Gitea release from what scripts/release_build.sh made.
|
||||
|
||||
Usage: scripts/release_publish.py <folder>
|
||||
Environment: GITEA_API (https://host/api/v1), GITEA_REPO (owner/name), GITEA_TOKEN.
|
||||
Run again for the same version, it replaces the files and the text instead of failing.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
API = os.environ.get("GITEA_API", "").rstrip("/")
|
||||
REPO = os.environ.get("GITEA_REPO", "")
|
||||
TOKEN = os.environ.get("GITEA_TOKEN", "")
|
||||
|
||||
|
||||
def call(method, path, body=None, raw=None, content_type="application/json"):
|
||||
data = raw if raw is not None else (json.dumps(body).encode() if body is not None else None)
|
||||
request = urllib.request.Request(f"{API}/repos/{REPO}{path}", data=data, method=method)
|
||||
request.add_header("Authorization", f"token {TOKEN}")
|
||||
if data is not None:
|
||||
request.add_header("Content-Type", content_type)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=300) as response:
|
||||
text = response.read()
|
||||
return response.status, json.loads(text) if text else None
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, e.read().decode(errors="replace")[:300]
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 2 or not (API and REPO and TOKEN):
|
||||
sys.exit(__doc__)
|
||||
folder = sys.argv[1]
|
||||
version = open(os.path.join(folder, "version")).read().strip()
|
||||
notes = open(os.path.join(folder, "notes.md")).read()
|
||||
files = sorted(f for f in os.listdir(folder) if f not in ("version", "notes.md"))
|
||||
|
||||
status, release = call("GET", f"/releases/tags/{urllib.parse.quote(version)}")
|
||||
fields = {"tag_name": version, "name": f"roro9stack {version}", "body": notes, "draft": False, "prerelease": False}
|
||||
if status == 200:
|
||||
status, release = call("PATCH", f"/releases/{release['id']}", fields)
|
||||
else:
|
||||
status, release = call("POST", "/releases", fields)
|
||||
if status not in (200, 201):
|
||||
sys.exit(f"release: Gitea answered {status}: {release}")
|
||||
|
||||
for asset in release.get("assets") or []: # a second run replaces what the first uploaded
|
||||
if asset["name"] in files:
|
||||
call("DELETE", f"/releases/{release['id']}/assets/{asset['id']}")
|
||||
for name in files:
|
||||
with open(os.path.join(folder, name), "rb") as f:
|
||||
status, answer = call("POST", f"/releases/{release['id']}/assets?name={urllib.parse.quote(name)}", raw=f.read(),
|
||||
content_type="application/octet-stream")
|
||||
if status != 201:
|
||||
sys.exit(f"release: uploading {name}: Gitea answered {status}: {answer}")
|
||||
print(f"uploaded {name} ({answer['size']} bytes)")
|
||||
print(f"published {release['html_url']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -204,6 +204,7 @@ void FileViewer::close() {
|
||||
file_.reset();
|
||||
pager_.reset();
|
||||
confirm_.reset();
|
||||
message_.clear();
|
||||
std::vector<std::string>().swap(shown_);
|
||||
std::vector<std::string>().swap(details_);
|
||||
packets_.setCount(0);
|
||||
@@ -216,6 +217,11 @@ void FileViewer::show(Mode mode) {
|
||||
rowsFrom_ = -1;
|
||||
}
|
||||
|
||||
void FileViewer::say(const std::string& text) {
|
||||
message_ = text;
|
||||
messageMs_ = millis();
|
||||
}
|
||||
|
||||
std::string FileViewer::title() const { return files::fitName(files::baseName(path_), 24); }
|
||||
|
||||
void FileViewer::scroll(int lines) {
|
||||
@@ -314,6 +320,10 @@ bool FileViewer::onKey(const KeyEvent& e) {
|
||||
}
|
||||
|
||||
bool FileViewer::update(uint32_t) {
|
||||
if (!message_.empty() && millis() - messageMs_ >= 4000) {
|
||||
message_.clear();
|
||||
return true;
|
||||
}
|
||||
if (!scan_) return false;
|
||||
if (scan_->done && !scanShown_) {
|
||||
scanShown_ = true;
|
||||
@@ -387,6 +397,7 @@ void FileViewer::draw(Canvas& c) {
|
||||
}
|
||||
right = formatBytes(size_);
|
||||
keys = std::string("Tab: ") + (mode_ != base_ ? "back" : mode_ == Mode::Text ? "hex" : "text") + " t: top b: end";
|
||||
if (mode_ == Mode::Text) keys += " e: edit";
|
||||
break;
|
||||
}
|
||||
case Mode::Gpx: {
|
||||
@@ -452,7 +463,8 @@ void FileViewer::draw(Canvas& c) {
|
||||
c.setTextDatum(top_right);
|
||||
c.drawString(right.c_str(), area.w - 3, area.y + 1);
|
||||
c.setTextDatum(top_left);
|
||||
c.drawString(keys.c_str(), 4, area.y + area.h - 9);
|
||||
if (!message_.empty()) c.setTextColor(theme::kWarning);
|
||||
c.drawString(message_.empty() ? keys.c_str() : message_.c_str(), 4, area.y + area.h - 9);
|
||||
}
|
||||
|
||||
} // namespace roro
|
||||
|
||||
@@ -30,6 +30,12 @@ class FileViewer {
|
||||
bool update(uint32_t nowMs); // true: draw again
|
||||
void draw(Canvas& c);
|
||||
|
||||
// For `e` in the Storage App: is this a text small enough to edit (Q146)? And a line to say why not.
|
||||
bool showingText() const { return mode_ == Mode::Text; }
|
||||
const std::string& path() const { return path_; }
|
||||
uint32_t size() const { return size_; }
|
||||
void say(const std::string& text);
|
||||
|
||||
private:
|
||||
enum class Mode { Text, Hex, Gpx, Pcap, Packet, Ota };
|
||||
static constexpr int kRows = 8;
|
||||
@@ -62,6 +68,8 @@ class FileViewer {
|
||||
std::vector<std::string> details_;
|
||||
int detailsTop_ = 0;
|
||||
std::unique_ptr<DialogModel> confirm_;
|
||||
std::string message_;
|
||||
uint32_t messageMs_ = 0;
|
||||
};
|
||||
|
||||
} // namespace roro
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
#include "note_editor.h"
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <SD.h>
|
||||
|
||||
#include <ctime>
|
||||
|
||||
#include "cleanup_plan.h"
|
||||
#include "file_list.h"
|
||||
#include "file_names.h"
|
||||
#include "ui/fonts.h"
|
||||
#include "ui/theme.h"
|
||||
#include "ui/widgets.h"
|
||||
|
||||
namespace roro {
|
||||
|
||||
using notes::NoteText;
|
||||
|
||||
namespace {
|
||||
constexpr uint32_t kMessageMs = 4000;
|
||||
|
||||
// One block the size of a full note, and something left: without it, nothing is opened. Free
|
||||
// memory in total isn't the measure: with IRC connected the largest free block is about 31 KB.
|
||||
constexpr size_t kRoomWanted = NoteText::kMaxBytes + 8 * 1024;
|
||||
const char* const kNoRoom = "Not enough memory to edit: close IRC or a Gemini page";
|
||||
|
||||
// On the storage task. Reads a file of up to `limit` bytes into a string that has that capacity
|
||||
// already; false if it can't be read whole.
|
||||
bool readWhole(const std::string& path, std::string& into, size_t limit) {
|
||||
File f = SD.open(path.c_str(), FILE_READ);
|
||||
if (!f) return false;
|
||||
size_t size = f.size();
|
||||
if (size > limit) {
|
||||
f.close();
|
||||
return false;
|
||||
}
|
||||
into.resize(size);
|
||||
size_t got = size ? f.read(reinterpret_cast<uint8_t*>(&into[0]), size) : 0;
|
||||
f.close();
|
||||
return got == size;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void NoteEditor::say(const std::string& text) {
|
||||
message_ = text;
|
||||
messageMs_ = millis();
|
||||
redraw_ = true;
|
||||
}
|
||||
|
||||
std::string NoteEditor::open(const std::string& path) {
|
||||
close();
|
||||
if (ESP.getMaxAllocHeap() < kRoomWanted) return kNoRoom;
|
||||
std::string body, left, why;
|
||||
body.reserve(NoteText::kMaxBytes); // the note's own buffer from here on: read into, then handed over
|
||||
bool ran = storage_.runAndWait([&]() {
|
||||
File f = SD.open(path.c_str(), FILE_READ);
|
||||
if (!f) {
|
||||
why = "The card refused to open it";
|
||||
return;
|
||||
}
|
||||
size_t size = f.size();
|
||||
f.close();
|
||||
if (size > NoteText::kMaxBytes) why = "Too big to edit: 16 KB at most";
|
||||
else if (!readWhole(path, body, NoteText::kMaxBytes)) why = "The card refused to read it";
|
||||
if (!why.empty()) return;
|
||||
// A save that never finished: its temporary file is offered back (Q143), if there's the
|
||||
// memory to look at it now. If not, it stays for the next time.
|
||||
std::string tmp = path + ".tmp";
|
||||
File t = SD.open(tmp.c_str(), FILE_READ);
|
||||
if (!t) return;
|
||||
size_t tmpSize = t.size();
|
||||
t.close();
|
||||
if (tmpSize > 0 && tmpSize <= NoteText::kMaxBytes && ESP.getMaxAllocHeap() < tmpSize + 8 * 1024) return;
|
||||
left.reserve(tmpSize <= NoteText::kMaxBytes ? tmpSize : 0);
|
||||
if (!readWhole(tmp, left, NoteText::kMaxBytes) || left == body || left.empty()) {
|
||||
std::string().swap(left);
|
||||
SD.remove(tmp.c_str());
|
||||
}
|
||||
});
|
||||
if (!ran) return "No SD card";
|
||||
if (!why.empty()) return why;
|
||||
text_.reset(new NoteText(kCols, kRows, std::move(body)));
|
||||
path_ = path;
|
||||
folder_ = files::parentOf(path);
|
||||
savedRevision_ = text_->revision();
|
||||
problem_.clear();
|
||||
message_.clear();
|
||||
lastKeyMs_ = millis();
|
||||
if (!left.empty()) {
|
||||
recovered_ = std::move(left);
|
||||
ask_ = Ask::Recover;
|
||||
dialog_.reset(new DialogModel({"Keep the note", "Use the copy"}));
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
std::string NoteEditor::openNew(const std::string& folder) {
|
||||
close();
|
||||
if (ESP.getMaxAllocHeap() < kRoomWanted) return kNoRoom;
|
||||
text_.reset(new NoteText(kCols, kRows));
|
||||
path_.clear();
|
||||
folder_ = folder;
|
||||
savedRevision_ = text_->revision();
|
||||
problem_.clear();
|
||||
message_.clear();
|
||||
lastKeyMs_ = millis();
|
||||
return "";
|
||||
}
|
||||
|
||||
void NoteEditor::close() {
|
||||
if (dirty()) save();
|
||||
text_.reset();
|
||||
dialog_.reset();
|
||||
ask_ = Ask::None;
|
||||
std::string().swap(recovered_);
|
||||
}
|
||||
|
||||
// On the main loop, waiting for the storage task: no second copy of the note is made, and at
|
||||
// 16 KB the wait is a fraction of a second, when nobody has typed for five.
|
||||
bool NoteEditor::save() {
|
||||
if (!text_) return true;
|
||||
lastTryMs_ = millis();
|
||||
const std::string& body = text_->text();
|
||||
if (path_.empty() && body.empty()) { // a new note nothing was typed in: no file
|
||||
savedRevision_ = text_->revision();
|
||||
return true;
|
||||
}
|
||||
std::string path = path_, why;
|
||||
if (path.empty()) {
|
||||
char stamp[20] = "new";
|
||||
int64_t now = clock_.utcNow();
|
||||
if (now >= 0) {
|
||||
time_t t = static_cast<time_t>(now);
|
||||
struct tm local;
|
||||
localtime_r(&t, &local);
|
||||
std::snprintf(stamp, sizeof stamp, "%04d%02d%02d-%02d%02d", local.tm_year + 1900, local.tm_mon + 1, local.tm_mday, local.tm_hour,
|
||||
local.tm_min);
|
||||
}
|
||||
path = files::joinPath(folder_, notes::nameFromFirstLine(text_->firstLine(), stamp) + ".txt");
|
||||
}
|
||||
bool fresh = path_.empty();
|
||||
bool ran = storage_.runAndWait([&]() {
|
||||
if (!SD.exists(folder_.c_str()) && !SD.mkdir(folder_.c_str())) {
|
||||
why = "the card refused to make " + folder_;
|
||||
return;
|
||||
}
|
||||
if (fresh) { // a name nothing has yet: "list (2).txt"
|
||||
std::string name = files::baseName(path);
|
||||
for (int n = 2; n < 100 && SD.exists(path.c_str()); n++) path = files::joinPath(folder_, files::copyName(name, n));
|
||||
}
|
||||
std::string tmp = path + ".tmp";
|
||||
File f = SD.open(tmp.c_str(), FILE_WRITE);
|
||||
if (!f) {
|
||||
why = "the card refused to open a file";
|
||||
return;
|
||||
}
|
||||
size_t wrote = body.empty() ? 0 : f.write(reinterpret_cast<const uint8_t*>(body.data()), body.size());
|
||||
f.close();
|
||||
File check = SD.open(tmp.c_str(), FILE_READ);
|
||||
bool whole = wrote == body.size() && check && check.size() == body.size();
|
||||
if (check) check.close();
|
||||
if (!whole) {
|
||||
SD.remove(tmp.c_str());
|
||||
why = "the card refused a write";
|
||||
return;
|
||||
}
|
||||
// FAT can't rename onto a file. Between these two lines only the temporary file exists:
|
||||
// the Notes list puts such a file back under its name.
|
||||
if (SD.exists(path.c_str())) SD.remove(path.c_str());
|
||||
if (!SD.rename(tmp.c_str(), path.c_str())) why = "the card refused to rename the file";
|
||||
});
|
||||
if (!ran) why = "no SD card";
|
||||
if (!why.empty()) {
|
||||
if (problem_ != why) say("Not saved: " + why);
|
||||
problem_ = why;
|
||||
return false;
|
||||
}
|
||||
path_ = path;
|
||||
problem_.clear();
|
||||
savedRevision_ = text_->revision();
|
||||
redraw_ = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool NoteEditor::onKey(const KeyEvent& e) {
|
||||
if (!text_) return false;
|
||||
redraw_ = true;
|
||||
if (dialog_) {
|
||||
dialog_->onKey(e);
|
||||
int result = dialog_->result();
|
||||
if (result == DialogModel::kPending) return true;
|
||||
Ask asked = ask_;
|
||||
ask_ = Ask::None;
|
||||
dialog_.reset();
|
||||
if (asked == Ask::Recover) {
|
||||
if (result == 1) {
|
||||
if (text_->setText(recovered_)) say("The unsaved copy is back"); // different from the card: saved in five seconds
|
||||
else say("That copy is too big to edit");
|
||||
} else {
|
||||
std::string tmp = path_ + ".tmp";
|
||||
storage_.runJob([tmp]() { SD.remove(tmp.c_str()); });
|
||||
}
|
||||
std::string().swap(recovered_);
|
||||
return true;
|
||||
}
|
||||
if (asked == Ask::LeaveUnsaved && result == 1) {
|
||||
savedRevision_ = text_->revision(); // given up on
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
lastKeyMs_ = millis();
|
||||
bool page = e.shift || e.alt;
|
||||
switch (e.key) {
|
||||
case Key::Char: {
|
||||
uint32_t lower = e.ch >= 'A' && e.ch <= 'Z' ? e.ch + 32 : e.ch;
|
||||
if (e.ctrl) {
|
||||
if (lower == 'a') text_->lineStart();
|
||||
else if (lower == 'e') text_->lineEnd();
|
||||
break;
|
||||
}
|
||||
if (!text_->insert(e.ch)) say("This note is full: 16 KB");
|
||||
break;
|
||||
}
|
||||
case Key::Select:
|
||||
if (!text_->insert('\n')) say("This note is full: 16 KB");
|
||||
break;
|
||||
case Key::Tab:
|
||||
if (!text_->insertText(" ")) say("This note is full: 16 KB");
|
||||
break;
|
||||
case Key::Delete: text_->backspace(); break;
|
||||
case Key::Left: text_->left(); break;
|
||||
case Key::Right: text_->right(); break;
|
||||
case Key::Up: page ? text_->pageUp() : text_->up(); break;
|
||||
case Key::Down: page ? text_->pageDown() : text_->down(); break;
|
||||
case Key::Back:
|
||||
if (!dirty() || save()) return false;
|
||||
ask_ = Ask::LeaveUnsaved;
|
||||
dialog_.reset(new DialogModel({"Stay", "Leave"}));
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool NoteEditor::update(uint32_t) {
|
||||
if (!text_) return false;
|
||||
uint32_t now = millis();
|
||||
// A save that failed is tried again every five seconds, not at every pass.
|
||||
if (dirty() && !dialog_ && now - lastTryMs_ >= kSaveAfterMs &&
|
||||
(now - lastKeyMs_ >= kSaveAfterMs || power_.screen() == ScreenState::Off))
|
||||
save();
|
||||
if (!message_.empty() && now - messageMs_ >= kMessageMs) {
|
||||
message_.clear();
|
||||
redraw_ = true;
|
||||
}
|
||||
bool r = redraw_;
|
||||
redraw_ = false;
|
||||
return r;
|
||||
}
|
||||
|
||||
void NoteEditor::draw(Canvas& c) {
|
||||
if (!text_) return;
|
||||
const auto& area = theme::kContent;
|
||||
c.setTextDatum(top_left);
|
||||
|
||||
// The name, and whether the card has what's on screen.
|
||||
c.setFont(&fonts::small);
|
||||
c.setTextColor(theme::kMuted);
|
||||
std::string name = path_.empty() ? "New note" : files::fitName(files::baseName(path_), 26);
|
||||
c.drawString(name.c_str(), 4, area.y + 1);
|
||||
std::string state = formatBytes(text_->text().size()) + (dirty() ? (problem_.empty() ? ", typing" : ", NOT SAVED") : ", saved");
|
||||
if (path_.empty() && !dirty()) state = "empty";
|
||||
c.setTextDatum(top_right);
|
||||
c.setTextColor(dirty() && !problem_.empty() ? theme::kWarning : theme::kMuted);
|
||||
c.drawString(state.c_str(), area.w - 3, area.y + 1);
|
||||
c.setTextDatum(top_left);
|
||||
|
||||
int top = area.y + 10;
|
||||
auto rows = text_->rows();
|
||||
c.setFont(&fonts::body);
|
||||
c.setTextColor(theme::kText);
|
||||
for (size_t i = 0; i < rows.size(); i++) c.drawString(rows[i].c_str(), 4, top + 1 + static_cast<int>(i) * theme::kLineHeight);
|
||||
c.fillRect(3 + text_->cursorCol() * 6, top + text_->cursorRow() * theme::kLineHeight, 1, theme::kLineHeight, theme::kAccent);
|
||||
if (text_->text().size() > static_cast<size_t>(kCols * kRows)) { // more than a screen: where we are in it
|
||||
int h = kRows * theme::kLineHeight, barH = 12;
|
||||
c.fillRect(area.w - 2, top + (h - barH) * text_->percent() / 100, 2, barH, theme::kMuted);
|
||||
}
|
||||
|
||||
c.setFont(&fonts::small);
|
||||
bool showMessage = !message_.empty() && millis() - messageMs_ < kMessageMs;
|
||||
c.setTextColor(showMessage ? theme::kWarning : theme::kMuted);
|
||||
c.drawString(showMessage ? message_.c_str() : "Fn+arrows: move Ctrl+A/E: line Back: done", 4, area.y + area.h - 9);
|
||||
|
||||
if (dialog_ && ask_ == Ask::Recover)
|
||||
widgets::dialog(c, "Unsaved copy", "A save of this note was cut short. Its copy has " + formatBytes(recovered_.size()) + ", the note " +
|
||||
formatBytes(text_->text().size()) + ".",
|
||||
*dialog_);
|
||||
else if (dialog_)
|
||||
widgets::dialog(c, "Not saved", "The note couldn't be saved: " + problem_ + ". Leave and lose what was typed?", *dialog_);
|
||||
}
|
||||
|
||||
} // namespace roro
|
||||
@@ -0,0 +1,60 @@
|
||||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "dialog_model.h"
|
||||
#include "key_event.h"
|
||||
#include "note_text.h"
|
||||
#include "services/clock_service.h"
|
||||
#include "services/power_service.h"
|
||||
#include "services/storage_service.h"
|
||||
#include "ui/canvas.h"
|
||||
|
||||
namespace roro {
|
||||
|
||||
// Edits one text file of up to 16 KB (F1, Q143-Q145): the Notes App's editor, and the Storage
|
||||
// App's for `e`. It saves by itself: five seconds after the last key, when the screen turns off,
|
||||
// and on close. A save writes `<file>.tmp`, then puts it in the note's place, so the note on the
|
||||
// card is always a whole one.
|
||||
class NoteEditor {
|
||||
public:
|
||||
static constexpr int kCols = 38, kRows = 8;
|
||||
static constexpr uint32_t kSaveAfterMs = 5000;
|
||||
|
||||
NoteEditor(StorageService& storage, ClockService& clock, PowerService& power)
|
||||
: storage_(storage), clock_(clock), power_(power) {}
|
||||
|
||||
std::string open(const std::string& path); // "" or why it can't be edited
|
||||
std::string openNew(const std::string& folder); // the same; no file until there's something to save (Q142)
|
||||
void close(); // saves what isn't yet
|
||||
bool isOpen() const { return static_cast<bool>(text_); }
|
||||
const std::string& path() const { return path_; } // "" for a new note nothing was typed in
|
||||
|
||||
bool onKey(const KeyEvent& e); // false: done, and saved
|
||||
bool update(uint32_t nowMs); // true: draw again
|
||||
void draw(Canvas& c);
|
||||
|
||||
private:
|
||||
enum class Ask { None, Recover, LeaveUnsaved };
|
||||
|
||||
bool dirty() const { return text_ && text_->revision() != savedRevision_; }
|
||||
bool save(); // true if the card has it now (or there was nothing to save)
|
||||
void say(const std::string& text);
|
||||
|
||||
StorageService& storage_;
|
||||
ClockService& clock_;
|
||||
PowerService& power_;
|
||||
std::unique_ptr<notes::NoteText> text_;
|
||||
std::string path_, folder_;
|
||||
uint32_t savedRevision_ = 0, lastKeyMs_ = 0, lastTryMs_ = 0;
|
||||
std::string recovered_; // what a temporary file left behind holds, until the user has chosen
|
||||
Ask ask_ = Ask::None;
|
||||
std::unique_ptr<DialogModel> dialog_;
|
||||
std::string message_, problem_;
|
||||
uint32_t messageMs_ = 0;
|
||||
bool redraw_ = false;
|
||||
};
|
||||
|
||||
} // namespace roro
|
||||
@@ -0,0 +1,346 @@
|
||||
#include "notes_app.h"
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <SD.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
|
||||
#include "file_names.h"
|
||||
#include "ui/canvas.h"
|
||||
#include "ui/fonts.h"
|
||||
#include "ui/widgets.h"
|
||||
|
||||
namespace roro {
|
||||
|
||||
using files::baseName;
|
||||
using files::joinPath;
|
||||
|
||||
namespace {
|
||||
constexpr uint32_t kMessageMs = 4000;
|
||||
constexpr size_t kTitleChars = 26;
|
||||
|
||||
bool endsWith(const std::string& s, const char* tail) {
|
||||
size_t n = std::strlen(tail);
|
||||
return s.size() >= n && s.compare(s.size() - n, n, tail) == 0;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void NotesApp::onEnter() {
|
||||
view_ = ESP.getFreeHeap() < kStartFloor ? View::NoMemory : View::List;
|
||||
dialog_.reset();
|
||||
message_.clear();
|
||||
cardPresent_ = storage_.state().present;
|
||||
mended_ = false;
|
||||
if (view_ == View::List) reload();
|
||||
requestRedraw();
|
||||
}
|
||||
|
||||
void NotesApp::onExit() {
|
||||
editor_.close(); // saves
|
||||
if (wait_ == Wait::List) ops_.cancel();
|
||||
wait_ = Wait::None;
|
||||
wantList_ = loaded_ = false;
|
||||
list_ = files::FileList();
|
||||
std::vector<uint16_t>().swap(notes_);
|
||||
std::vector<std::string>().swap(titles_);
|
||||
titlesFrom_ = -1;
|
||||
rows_.setCount(0);
|
||||
}
|
||||
|
||||
void NotesApp::say(const std::string& text) {
|
||||
message_ = text;
|
||||
messageMs_ = millis();
|
||||
requestRedraw();
|
||||
}
|
||||
|
||||
void NotesApp::reload(const std::string& select) {
|
||||
selectAfter_ = select;
|
||||
wantList_ = true;
|
||||
}
|
||||
|
||||
std::string NotesApp::selectedPath() const {
|
||||
int i = rows_.selected();
|
||||
if (!loaded_ || i < 0 || i >= static_cast<int>(notes_.size())) return "";
|
||||
return joinPath(kFolder, list_.name(notes_[i]));
|
||||
}
|
||||
|
||||
void NotesApp::leaveEditor() {
|
||||
std::string path = editor_.path();
|
||||
editor_.close();
|
||||
view_ = View::List;
|
||||
reload(path.empty() ? "" : baseName(path));
|
||||
}
|
||||
|
||||
void NotesApp::update(uint32_t nowMs) {
|
||||
bool present = storage_.state().present;
|
||||
if (present != cardPresent_) {
|
||||
cardPresent_ = present;
|
||||
if (view_ == View::Name) view_ = View::List;
|
||||
loaded_ = false;
|
||||
wantList_ = present && view_ == View::List;
|
||||
requestRedraw();
|
||||
}
|
||||
if (view_ == View::Edit) {
|
||||
if (editor_.update(nowMs)) requestRedraw();
|
||||
return;
|
||||
}
|
||||
if (wantList_ && present && wait_ == Wait::None && !ops_.busy() && ops_.list(kFolder).empty()) {
|
||||
wantList_ = false;
|
||||
wait_ = Wait::List;
|
||||
}
|
||||
FileOps::Status s;
|
||||
if (wait_ != Wait::None && ops_.finished(s)) {
|
||||
onFinished(s);
|
||||
requestRedraw();
|
||||
}
|
||||
if (!message_.empty() && millis() - messageMs_ >= kMessageMs) {
|
||||
message_.clear();
|
||||
requestRedraw();
|
||||
}
|
||||
}
|
||||
|
||||
void NotesApp::onFinished(const FileOps::Status& s) {
|
||||
Wait was = wait_;
|
||||
wait_ = Wait::None;
|
||||
if (was == Wait::Work) {
|
||||
if (!s.error.empty()) say(s.error);
|
||||
else say(s.op == FileOps::Op::Delete ? "Deleted" : "Renamed");
|
||||
return reload(selectAfter_);
|
||||
}
|
||||
list_.clear();
|
||||
notes_.clear();
|
||||
if (s.error.empty()) { // no /notes yet is an empty list, not an error
|
||||
ops_.takeListing(list_);
|
||||
list_.sort(sort_);
|
||||
}
|
||||
// A save cut between its two last steps left "x.txt.tmp" and no "x.txt": the whole note, under
|
||||
// the wrong name. Put back once, then listed again.
|
||||
std::vector<std::string> orphans;
|
||||
for (size_t i = 0; i < list_.count(); i++) {
|
||||
std::string name = list_.name(i);
|
||||
if (list_.folder(i)) continue;
|
||||
if (endsWith(name, ".tmp")) {
|
||||
if (list_.find(name.substr(0, name.size() - 4)) < 0) orphans.push_back(name);
|
||||
continue;
|
||||
}
|
||||
notes_.push_back(static_cast<uint16_t>(i));
|
||||
}
|
||||
if (!orphans.empty() && !mended_) {
|
||||
mended_ = true;
|
||||
storage_.runAndWait([&]() {
|
||||
for (auto& name : orphans) {
|
||||
std::string from = joinPath(kFolder, name);
|
||||
SD.rename(from.c_str(), from.substr(0, from.size() - 4).c_str());
|
||||
}
|
||||
});
|
||||
return reload(selectAfter_);
|
||||
}
|
||||
loaded_ = true;
|
||||
titlesFrom_ = -1;
|
||||
rows_.setCount(static_cast<int>(notes_.size()));
|
||||
int at = -1;
|
||||
for (size_t i = 0; i < notes_.size() && !selectAfter_.empty(); i++)
|
||||
if (selectAfter_ == list_.name(notes_[i])) at = static_cast<int>(i);
|
||||
rows_.select(at >= 0 ? at : selectIndex_);
|
||||
selectAfter_.clear();
|
||||
selectIndex_ = 0;
|
||||
}
|
||||
|
||||
// The first line of each note on screen, read when the list moves: eight short reads.
|
||||
void NotesApp::readTitles() {
|
||||
if (titlesFrom_ == rows_.firstVisible()) return;
|
||||
titlesFrom_ = rows_.firstVisible();
|
||||
titles_.clear();
|
||||
std::vector<std::string> paths;
|
||||
for (int i = titlesFrom_; i < titlesFrom_ + kRows && i < static_cast<int>(notes_.size()); i++) paths.push_back(joinPath(kFolder, list_.name(notes_[i])));
|
||||
titles_.resize(paths.size());
|
||||
storage_.runAndWait([&]() {
|
||||
for (size_t i = 0; i < paths.size(); i++) {
|
||||
File f = SD.open(paths[i].c_str(), FILE_READ);
|
||||
if (!f) continue;
|
||||
char head[120];
|
||||
int n = f.read(reinterpret_cast<uint8_t*>(head), sizeof head);
|
||||
f.close();
|
||||
if (n > 0) titles_[i] = notes::titleFrom(std::string(head, n), kTitleChars);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
std::string NotesApp::titleOf(int row) {
|
||||
size_t i = static_cast<size_t>(row - titlesFrom_);
|
||||
if (i < titles_.size() && !titles_[i].empty()) return titles_[i];
|
||||
return files::fitName(list_.name(notes_[row]), kTitleChars); // an empty note, or not a text
|
||||
}
|
||||
|
||||
bool NotesApp::onKey(const KeyEvent& e) {
|
||||
requestRedraw();
|
||||
if (view_ == View::NoMemory) return false;
|
||||
if (view_ == View::Edit) {
|
||||
if (!editor_.onKey(e)) leaveEditor();
|
||||
return true;
|
||||
}
|
||||
if (dialog_) {
|
||||
dialog_->onKey(e);
|
||||
int result = dialog_->result();
|
||||
if (result == DialogModel::kPending) return true;
|
||||
dialog_.reset();
|
||||
if (result == 1) {
|
||||
selectIndex_ = rows_.selected();
|
||||
selectAfter_.clear();
|
||||
std::string why = ops_.remove(target_, false);
|
||||
if (why.empty()) wait_ = Wait::Work;
|
||||
else say(why);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (wantList_ || wait_ != Wait::None) return true; // a moment
|
||||
if (view_ == View::Name) return onNameKey(e);
|
||||
return onListKey(e);
|
||||
}
|
||||
|
||||
bool NotesApp::onNameKey(const KeyEvent& e) {
|
||||
switch (e.key) {
|
||||
case Key::Char: name_.insert(e.ch); break;
|
||||
case Key::Delete: name_.backspace(); break;
|
||||
case Key::Left: name_.left(); break;
|
||||
case Key::Right: name_.right(); break;
|
||||
case Key::Back: view_ = View::List; break;
|
||||
case Key::Select: {
|
||||
std::string name = name_.text() + ".txt";
|
||||
std::string why = files::checkName(name);
|
||||
if (why.empty() && name != baseName(target_)) {
|
||||
why = ops_.move(target_, false, joinPath(kFolder, name));
|
||||
if (why.empty()) {
|
||||
wait_ = Wait::Work;
|
||||
selectAfter_ = name;
|
||||
}
|
||||
}
|
||||
if (why.empty()) view_ = View::List;
|
||||
else say(why);
|
||||
break;
|
||||
}
|
||||
default: break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool NotesApp::onListKey(const KeyEvent& e) {
|
||||
if (e.key == Key::Back) return false;
|
||||
if (!cardPresent_ || !loaded_) return true;
|
||||
uint32_t ch = e.key == Key::Char ? (e.ch >= 'A' && e.ch <= 'Z' ? e.ch + 32 : e.ch) : 0;
|
||||
std::string path = selectedPath();
|
||||
switch (e.key) {
|
||||
case Key::Up: rows_.up(); return true;
|
||||
case Key::Down: rows_.down(); return true;
|
||||
case Key::Left: rows_.pageUp(); return true;
|
||||
case Key::Right: rows_.pageDown(); return true;
|
||||
case Key::Select: {
|
||||
if (path.empty()) return true;
|
||||
std::string why = editor_.open(path);
|
||||
if (why.empty()) view_ = View::Edit;
|
||||
else say(why);
|
||||
return true;
|
||||
}
|
||||
case Key::Delete: ch = 'd'; break;
|
||||
default: break;
|
||||
}
|
||||
if (ch == 'n') {
|
||||
std::string why = editor_.openNew(kFolder);
|
||||
if (why.empty()) view_ = View::Edit;
|
||||
else say(why);
|
||||
} else if (ch == 's') {
|
||||
sort_ = sort_ == files::FileSort::Date ? files::FileSort::Name : files::FileSort::Date;
|
||||
std::string keep = path.empty() ? "" : baseName(path);
|
||||
list_.sort(sort_);
|
||||
notes_.clear();
|
||||
for (size_t i = 0; i < list_.count(); i++)
|
||||
if (!list_.folder(i) && !endsWith(list_.name(i), ".tmp")) notes_.push_back(static_cast<uint16_t>(i));
|
||||
for (size_t i = 0; i < notes_.size(); i++)
|
||||
if (keep == list_.name(notes_[i])) rows_.select(static_cast<int>(i));
|
||||
titlesFrom_ = -1;
|
||||
say(sort_ == files::FileSort::Date ? "Newest first" : "By file name");
|
||||
} else if (path.empty()) {
|
||||
// nothing selected: the keys below need a note
|
||||
} else if (ch == 'd') {
|
||||
target_ = path;
|
||||
question_ = "Delete " + files::fitName(baseName(path), 22) + ", \"" + titleOf(rows_.selected()).substr(0, 20) + "\"? It can't be undone.";
|
||||
dialog_.reset(new DialogModel({"Cancel", "Delete"}));
|
||||
} else if (ch == 'r') {
|
||||
target_ = path;
|
||||
std::string name = baseName(path);
|
||||
size_t dot = name.rfind('.');
|
||||
name_.setText(dot == std::string::npos || dot == 0 ? name : name.substr(0, dot));
|
||||
view_ = View::Name;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void NotesApp::draw(Canvas& c) {
|
||||
lastDrawMs_ = millis();
|
||||
const auto& area = theme::kContent;
|
||||
c.setTextDatum(top_left);
|
||||
if (view_ == View::NoMemory) {
|
||||
c.setFont(&fonts::bold);
|
||||
c.setTextColor(theme::kWarning);
|
||||
c.drawString("Not enough memory", 4, area.y + 4);
|
||||
c.setFont(&fonts::body);
|
||||
c.setTextColor(theme::kMuted);
|
||||
c.drawString("Close what's running (IRC, a page", 4, area.y + 22);
|
||||
c.drawString("in Gemini) and open Notes again.", 4, area.y + 22 + theme::kLineHeight);
|
||||
return;
|
||||
}
|
||||
if (view_ == View::Edit) return editor_.draw(c);
|
||||
|
||||
c.setFont(&fonts::small);
|
||||
c.setTextColor(theme::kMuted);
|
||||
c.drawString(kFolder, 4, area.y + 1);
|
||||
if (loaded_) {
|
||||
std::string count = std::to_string(notes_.size()) + (notes_.size() == 1 ? " note" : " notes");
|
||||
if (list_.more() > 0) count = "the first " + std::to_string(notes_.size()) + " notes";
|
||||
c.setTextDatum(top_right);
|
||||
c.drawString(count.c_str(), area.w - 3, area.y + 1);
|
||||
c.setTextDatum(top_left);
|
||||
}
|
||||
|
||||
theme::Rect rows{area.x, area.y + 10, area.w, kRows * theme::kLineHeight};
|
||||
c.setFont(&fonts::body);
|
||||
if (!storage_.state().present) {
|
||||
c.setTextColor(theme::kWarning);
|
||||
c.drawString("No SD card", 4, rows.y + 4);
|
||||
c.setTextColor(theme::kMuted);
|
||||
c.drawString("Notes are kept on the card.", 4, rows.y + 4 + theme::kLineHeight);
|
||||
} else if (!loaded_) {
|
||||
// a moment
|
||||
} else if (notes_.empty()) {
|
||||
c.setTextColor(theme::kMuted);
|
||||
c.drawString("No notes yet.", 4, rows.y + 4);
|
||||
c.drawString("n starts one.", 4, rows.y + 4 + theme::kLineHeight);
|
||||
} else {
|
||||
readTitles();
|
||||
widgets::list(
|
||||
c, rows_, rows, [&](int i) { return titleOf(i); },
|
||||
[&](int i) {
|
||||
std::string stamp = files::formatStamp(list_.modified(notes_[i]));
|
||||
return stamp.size() >= 10 ? stamp.substr(0, 10) : stamp;
|
||||
});
|
||||
}
|
||||
|
||||
c.setFont(&fonts::small);
|
||||
bool showMessage = !message_.empty() && millis() - messageMs_ < kMessageMs;
|
||||
c.setTextColor(showMessage ? theme::kWarning : theme::kMuted);
|
||||
c.drawString(showMessage ? message_.c_str() : "n: new Enter: open r: name d: del s: sort", 4, area.y + area.h - 9);
|
||||
|
||||
if (view_ == View::Name) {
|
||||
theme::Rect box{16, 44, theme::kWidth - 32, 48};
|
||||
c.fillRect(box.x, box.y, box.w, box.h, theme::kBackground);
|
||||
c.drawRect(box.x, box.y, box.w, box.h, theme::kAccent);
|
||||
c.setFont(&fonts::bold);
|
||||
c.setTextColor(theme::kText);
|
||||
c.drawString("File name (.txt)", box.x + 6, box.y + 4);
|
||||
widgets::lineEditor(c, name_, {box.x + 6, box.y + 22, box.w - 12, theme::kLineHeight + 4});
|
||||
}
|
||||
if (dialog_) widgets::dialog(c, "Delete", question_, *dialog_);
|
||||
}
|
||||
|
||||
} // namespace roro
|
||||
@@ -0,0 +1,71 @@
|
||||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "app.h"
|
||||
#include "apps/note_editor.h"
|
||||
#include "dialog_model.h"
|
||||
#include "file_list.h"
|
||||
#include "line_editor.h"
|
||||
#include "list_model.h"
|
||||
#include "services/file_ops.h"
|
||||
#include "ui/theme.h"
|
||||
|
||||
namespace roro {
|
||||
|
||||
// Notes (docs/milestones/F1.md, Q141-Q150): plain text files in /notes, listed by their first line,
|
||||
// newest first. n starts one, Enter opens it in the editor, which saves by itself; r renames the
|
||||
// file, d deletes it after asking, s sorts by name or by date.
|
||||
class NotesApp : public App {
|
||||
public:
|
||||
NotesApp(FileOps& ops, StorageService& storage, ClockService& clock, PowerService& power)
|
||||
: ops_(ops), storage_(storage), editor_(storage, clock, power) {}
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
bool onKey(const KeyEvent& e) override;
|
||||
bool textEntryActive() const override { return view_ == View::Edit || view_ == View::Name; }
|
||||
void update(uint32_t nowMs) override;
|
||||
void draw(Canvas& c) override;
|
||||
|
||||
private:
|
||||
enum class View { List, Edit, Name, NoMemory };
|
||||
enum class Wait { None, List, Work };
|
||||
static constexpr int kRows = 8;
|
||||
static constexpr size_t kStartFloor = 55 * 1024; // Q86, Q144
|
||||
static constexpr const char* kFolder = "/notes";
|
||||
|
||||
void reload(const std::string& select = "");
|
||||
void onFinished(const FileOps::Status& s);
|
||||
bool onListKey(const KeyEvent& e);
|
||||
bool onNameKey(const KeyEvent& e);
|
||||
void leaveEditor();
|
||||
std::string selectedPath() const; // "" in an empty list
|
||||
std::string titleOf(int row);
|
||||
void readTitles();
|
||||
void say(const std::string& text);
|
||||
|
||||
FileOps& ops_;
|
||||
StorageService& storage_;
|
||||
NoteEditor editor_;
|
||||
|
||||
View view_ = View::List;
|
||||
files::FileList list_;
|
||||
std::vector<uint16_t> notes_; // the entries of list_ that are notes
|
||||
files::FileSort sort_ = files::FileSort::Date;
|
||||
ListModel rows_{kRows};
|
||||
bool wantList_ = false, loaded_ = false, cardPresent_ = false, mended_ = false;
|
||||
std::string selectAfter_;
|
||||
int selectIndex_ = 0;
|
||||
Wait wait_ = Wait::None;
|
||||
std::vector<std::string> titles_; // of the rows on screen
|
||||
int titlesFrom_ = -1;
|
||||
std::unique_ptr<DialogModel> dialog_;
|
||||
std::string target_, question_;
|
||||
LineEditor name_{40};
|
||||
std::string message_;
|
||||
uint32_t messageMs_ = 0, lastDrawMs_ = 0;
|
||||
};
|
||||
|
||||
} // namespace roro
|
||||
@@ -51,6 +51,7 @@ void StorageApp::onExit() {
|
||||
ops_.cancel();
|
||||
wait_ = Wait::None;
|
||||
}
|
||||
noteEditor_.close(); // saves
|
||||
viewer_.close();
|
||||
wantList_ = false;
|
||||
loaded_ = false;
|
||||
@@ -105,7 +106,8 @@ void StorageApp::update(uint32_t nowMs) {
|
||||
wantList_ = present;
|
||||
clip_ = Item();
|
||||
if (view_ == View::Viewer) viewer_.close();
|
||||
if (view_ == View::Details || view_ == View::Name || view_ == View::Viewer) view_ = View::Browse;
|
||||
if (view_ == View::Editor) noteEditor_.close();
|
||||
if (view_ == View::Details || view_ == View::Name || view_ == View::Viewer || view_ == View::Editor) view_ = View::Browse;
|
||||
requestRedraw();
|
||||
}
|
||||
if (wantList_ && present && wait_ == Wait::None && !ops_.busy() && ops_.list(want_).empty()) {
|
||||
@@ -114,6 +116,7 @@ void StorageApp::update(uint32_t nowMs) {
|
||||
waitSinceMs_ = nowMs;
|
||||
}
|
||||
if (view_ == View::Viewer && viewer_.update(nowMs)) requestRedraw();
|
||||
if (view_ == View::Editor && noteEditor_.update(nowMs)) requestRedraw();
|
||||
FileOps::Status s;
|
||||
if (wait_ != Wait::None && ops_.finished(s)) {
|
||||
onFinished(s);
|
||||
@@ -280,6 +283,32 @@ bool StorageApp::onKey(const KeyEvent& e) {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (view_ == View::Editor) {
|
||||
if (!noteEditor_.onKey(e)) {
|
||||
std::string name = baseName(noteEditor_.path());
|
||||
noteEditor_.close();
|
||||
view_ = View::Browse;
|
||||
open(cwd_, name); // its size and date have changed
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (view_ == View::Viewer && viewer_.showingText() && e.key == Key::Char && (e.ch == 'e' || e.ch == 'E')) {
|
||||
// Edit it (Q146), if the rules and its size allow.
|
||||
std::string path = viewer_.path(), why = ops_.whyReadOnly(path, false);
|
||||
if (why.empty() && viewer_.size() > notes::NoteText::kMaxBytes) why = "Too big to edit: 16 KB at most";
|
||||
if (why.empty()) {
|
||||
viewer_.close();
|
||||
why = noteEditor_.open(path);
|
||||
if (why.empty()) view_ = View::Editor;
|
||||
else {
|
||||
view_ = View::Browse;
|
||||
say(why);
|
||||
}
|
||||
} else {
|
||||
viewer_.say(why);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (view_ == View::Viewer) {
|
||||
if (!viewer_.onKey(e)) {
|
||||
viewer_.close();
|
||||
@@ -453,6 +482,7 @@ void StorageApp::draw(Canvas& c) {
|
||||
return;
|
||||
case View::Maintenance: maintenance_.draw(c); return;
|
||||
case View::Viewer: viewer_.draw(c); return;
|
||||
case View::Editor: noteEditor_.draw(c); return;
|
||||
case View::Details: {
|
||||
std::vector<std::string> lines;
|
||||
auto measure = widgets::bodyMeasure(c);
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include "app.h"
|
||||
#include "apps/file_viewer.h"
|
||||
#include "apps/maintenance_page.h"
|
||||
#include "apps/note_editor.h"
|
||||
#include "dialog_model.h"
|
||||
#include "event_bus.h"
|
||||
#include "file_list.h"
|
||||
@@ -25,17 +26,17 @@ namespace roro {
|
||||
// usage, Storage Clean-up and erasing the card, behind a warning (Q128).
|
||||
class StorageApp : public App {
|
||||
public:
|
||||
StorageApp(FileOps& ops, StorageService& storage, ClockService& clock, UpdateService& update, EventBus& bus)
|
||||
: ops_(ops), storage_(storage), bus_(bus), maintenance_(storage, clock, bus), viewer_(storage, update) {}
|
||||
StorageApp(FileOps& ops, StorageService& storage, ClockService& clock, UpdateService& update, PowerService& power, EventBus& bus)
|
||||
: ops_(ops), storage_(storage), bus_(bus), maintenance_(storage, clock, bus), viewer_(storage, update), noteEditor_(storage, clock, power) {}
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
bool onKey(const KeyEvent& e) override;
|
||||
bool textEntryActive() const override { return view_ == View::Name; }
|
||||
bool textEntryActive() const override { return view_ == View::Name || view_ == View::Editor; }
|
||||
void update(uint32_t nowMs) override;
|
||||
void draw(Canvas& c) override;
|
||||
|
||||
private:
|
||||
enum class View { Browse, Details, Name, Viewer, Maintenance, NoMemory };
|
||||
enum class View { Browse, Details, Name, Viewer, Editor, Maintenance, NoMemory };
|
||||
enum class Ask { None, Delete, Replace, Maintenance };
|
||||
enum class Wait { None, List, CountToDelete, CountForDetails, Work }; // what the running operation is for
|
||||
static constexpr int kRows = 8;
|
||||
@@ -67,6 +68,7 @@ class StorageApp : public App {
|
||||
EventBus& bus_;
|
||||
MaintenancePage maintenance_;
|
||||
FileViewer viewer_;
|
||||
NoteEditor noteEditor_; // `e` in the text viewer (Q146)
|
||||
|
||||
View view_ = View::Browse;
|
||||
std::string cwd_ = "/", want_ = "/"; // the folder on screen; the one being read
|
||||
|
||||
+5
-1
@@ -13,6 +13,7 @@
|
||||
#include "apps/irc_app.h"
|
||||
#include "apps/launcher_app.h"
|
||||
#include "apps/lora_scanner_app.h"
|
||||
#include "apps/notes_app.h"
|
||||
#include "apps/settings_app.h"
|
||||
#include "apps/storage_app.h"
|
||||
#include "apps/system_app.h"
|
||||
@@ -210,7 +211,10 @@ void setup() {
|
||||
apps->registerApp({"gnss", "GNSS", false, new GnssApp(*gnssService, settings)});
|
||||
apps->registerApp({"gemini", "Gemini", false, new GeminiApp(*geminiService)});
|
||||
apps->registerApp({"lora", "LoRa Scanner", false, new LoraScannerApp(*radioService, *loraCapture, settings, *clockService)});
|
||||
apps->registerApp({"storage", "Storage", false, new StorageApp(*fileOps, *storageService, *clockService, *update, bus)});
|
||||
apps->registerApp({"storage", "Storage", false, new StorageApp(*fileOps, *storageService, *clockService, *update, *power, bus)});
|
||||
apps->registerApp({"notes", "Notes", false, new NotesApp(*fileOps, *storageService, *clockService, *power)});
|
||||
// Leaving the foreground App makes it save: a note being typed, when the device is powered off.
|
||||
power->beforePowerOff = []() { apps->home(); };
|
||||
apps->registerApp({"system", "System", false,
|
||||
new SystemApp(*wifi, *battery, *storageService, *radioService, *gnssService, nvs)});
|
||||
apps->registerApp({"settings", "Settings", false,
|
||||
|
||||
@@ -42,6 +42,7 @@ void PowerService::applyScreen(ScreenState state) {
|
||||
}
|
||||
|
||||
void PowerService::powerOff() {
|
||||
if (beforePowerOff) beforePowerOff();
|
||||
auto& display = M5Cardputer.Display;
|
||||
display.wakeup();
|
||||
display.setBrightness(settings_.getInt(Setting::Brightness) * 255 / 100);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
|
||||
#include "power_policy.h"
|
||||
#include "service.h"
|
||||
#include "settings.h"
|
||||
@@ -24,6 +26,9 @@ class PowerService : public Service {
|
||||
|
||||
ScreenState screen() const { return applied_; }
|
||||
|
||||
// Run before the device powers off: the last chance to put on the card what's only in memory.
|
||||
std::function<void()> beforePowerOff;
|
||||
|
||||
private:
|
||||
void applyScreen(ScreenState state);
|
||||
void powerOff();
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
#include <unity.h>
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "note_text.h"
|
||||
|
||||
using namespace roro::notes;
|
||||
|
||||
void setUp() {}
|
||||
void tearDown() {}
|
||||
|
||||
static void type(NoteText& t, const std::string& s) {
|
||||
for (char c : s) TEST_ASSERT_TRUE(t.insert(static_cast<uint8_t>(c)));
|
||||
}
|
||||
|
||||
void test_typing_and_rows() {
|
||||
NoteText t(10, 4);
|
||||
TEST_ASSERT_EQUAL_size_t(1, t.rows().size()); // an empty note has one empty row
|
||||
type(t, "Hello\nworld");
|
||||
auto rows = t.rows();
|
||||
TEST_ASSERT_EQUAL_size_t(2, rows.size());
|
||||
TEST_ASSERT_EQUAL_STRING("Hello", rows[0].c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("world", rows[1].c_str());
|
||||
TEST_ASSERT_EQUAL_INT(1, t.cursorRow());
|
||||
TEST_ASSERT_EQUAL_INT(5, t.cursorCol());
|
||||
t.insert('\n');
|
||||
TEST_ASSERT_EQUAL_size_t(3, t.rows().size()); // the empty line the cursor is on
|
||||
TEST_ASSERT_EQUAL_INT(2, t.cursorRow());
|
||||
TEST_ASSERT_EQUAL_INT(0, t.cursorCol());
|
||||
t.backspace();
|
||||
t.backspace();
|
||||
TEST_ASSERT_EQUAL_STRING("Hello\nworl", t.text().c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("Hello", t.firstLine().c_str());
|
||||
}
|
||||
|
||||
void test_wrapping_at_spaces() {
|
||||
NoteText t(10, 6);
|
||||
type(t, "one two three four five");
|
||||
auto rows = t.rows();
|
||||
TEST_ASSERT_EQUAL_size_t(3, rows.size());
|
||||
TEST_ASSERT_EQUAL_STRING("one two ", rows[0].c_str()); // a line owns the space it ends with
|
||||
TEST_ASSERT_EQUAL_STRING("three four ", rows[1].c_str()); // ten characters and that space
|
||||
TEST_ASSERT_EQUAL_STRING("five", rows[2].c_str());
|
||||
TEST_ASSERT_EQUAL_INT(2, t.cursorRow());
|
||||
TEST_ASSERT_EQUAL_INT(4, t.cursorCol());
|
||||
// Every byte is on exactly one row.
|
||||
std::string all;
|
||||
for (auto& r : rows) all += r;
|
||||
TEST_ASSERT_EQUAL_STRING(t.text().c_str(), all.c_str());
|
||||
}
|
||||
|
||||
void test_a_long_word_is_cut() {
|
||||
NoteText t(5, 6);
|
||||
type(t, "abcdefghijkl");
|
||||
auto rows = t.rows();
|
||||
TEST_ASSERT_EQUAL_size_t(3, rows.size());
|
||||
TEST_ASSERT_EQUAL_STRING("abcde", rows[0].c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("fghij", rows[1].c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("kl", rows[2].c_str());
|
||||
}
|
||||
|
||||
void test_left_right_over_accents() {
|
||||
NoteText t(10, 4);
|
||||
t.insert(0xE9); // é
|
||||
t.insert('t');
|
||||
t.insert(0xE9);
|
||||
TEST_ASSERT_EQUAL_STRING("\xC3\xA9t\xC3\xA9", t.text().c_str());
|
||||
TEST_ASSERT_EQUAL_INT(3, t.cursorCol());
|
||||
t.left();
|
||||
TEST_ASSERT_EQUAL_size_t(3, t.cursor());
|
||||
t.left();
|
||||
t.left();
|
||||
t.left(); // already at the start
|
||||
TEST_ASSERT_EQUAL_size_t(0, t.cursor());
|
||||
t.right();
|
||||
TEST_ASSERT_EQUAL_size_t(2, t.cursor());
|
||||
t.backspace();
|
||||
TEST_ASSERT_EQUAL_STRING("t\xC3\xA9", t.text().c_str());
|
||||
t.insert(0x20AC); // €, three bytes
|
||||
TEST_ASSERT_EQUAL_size_t(3, t.cursor());
|
||||
t.backspace();
|
||||
TEST_ASSERT_EQUAL_STRING("t\xC3\xA9", t.text().c_str());
|
||||
}
|
||||
|
||||
void test_up_and_down_keep_their_column() {
|
||||
NoteText t(20, 6);
|
||||
TEST_ASSERT_TRUE(t.setText("a long first line\nab\n\nanother long line"));
|
||||
t.lineEnd();
|
||||
TEST_ASSERT_EQUAL_INT(17, t.cursorCol());
|
||||
t.down(); // a short line: as far right as it goes
|
||||
TEST_ASSERT_EQUAL_INT(1, t.cursorRow());
|
||||
TEST_ASSERT_EQUAL_INT(2, t.cursorCol());
|
||||
t.down(); // an empty one
|
||||
TEST_ASSERT_EQUAL_INT(0, t.cursorCol());
|
||||
t.down(); // and back out to the column it came from
|
||||
TEST_ASSERT_EQUAL_INT(3, t.cursorRow());
|
||||
TEST_ASSERT_EQUAL_INT(17, t.cursorCol());
|
||||
t.down(); // the last line: stays
|
||||
TEST_ASSERT_EQUAL_INT(3, t.cursorRow());
|
||||
t.up();
|
||||
t.up();
|
||||
t.up();
|
||||
TEST_ASSERT_EQUAL_INT(0, t.cursorRow());
|
||||
TEST_ASSERT_EQUAL_INT(17, t.cursorCol());
|
||||
t.up(); // the first line: stays
|
||||
TEST_ASSERT_EQUAL_INT(0, t.cursorRow());
|
||||
t.left(); // moving sideways forgets the column
|
||||
t.down();
|
||||
TEST_ASSERT_EQUAL_INT(2, t.cursorCol());
|
||||
t.down();
|
||||
t.down();
|
||||
TEST_ASSERT_EQUAL_INT(16, t.cursorCol());
|
||||
}
|
||||
|
||||
void test_up_and_down_through_wrapped_lines() {
|
||||
NoteText t(10, 6);
|
||||
TEST_ASSERT_TRUE(t.setText("one two three four five\nend"));
|
||||
// "one two " / "three four " / "five" / "end"
|
||||
t.toEnd();
|
||||
TEST_ASSERT_EQUAL_INT(3, t.cursorRow());
|
||||
t.up();
|
||||
TEST_ASSERT_EQUAL_INT(2, t.cursorRow());
|
||||
TEST_ASSERT_EQUAL_INT(3, t.cursorCol());
|
||||
t.up();
|
||||
TEST_ASSERT_EQUAL_INT(1, t.cursorRow());
|
||||
t.lineEnd(); // on the space the line ends with, not past it
|
||||
TEST_ASSERT_EQUAL_INT(1, t.cursorRow());
|
||||
TEST_ASSERT_EQUAL_INT(10, t.cursorCol());
|
||||
t.right(); // one more is the start of the next line
|
||||
TEST_ASSERT_EQUAL_INT(2, t.cursorRow());
|
||||
TEST_ASSERT_EQUAL_INT(0, t.cursorCol());
|
||||
t.lineStart();
|
||||
t.left();
|
||||
TEST_ASSERT_EQUAL_INT(1, t.cursorRow());
|
||||
t.lineStart();
|
||||
TEST_ASSERT_EQUAL_INT(0, t.cursorCol());
|
||||
TEST_ASSERT_EQUAL_size_t(8, t.cursor());
|
||||
}
|
||||
|
||||
void test_a_final_newline_gives_a_last_empty_line() {
|
||||
NoteText t(10, 6);
|
||||
TEST_ASSERT_TRUE(t.setText("ab\n"));
|
||||
TEST_ASSERT_EQUAL_size_t(2, t.rows().size());
|
||||
t.down();
|
||||
TEST_ASSERT_EQUAL_size_t(3, t.cursor());
|
||||
TEST_ASSERT_EQUAL_INT(1, t.cursorRow());
|
||||
t.down();
|
||||
TEST_ASSERT_EQUAL_size_t(3, t.cursor());
|
||||
t.up();
|
||||
TEST_ASSERT_EQUAL_size_t(0, t.cursor());
|
||||
t.lineEnd();
|
||||
TEST_ASSERT_EQUAL_size_t(2, t.cursor());
|
||||
}
|
||||
|
||||
void test_the_screen_follows_the_cursor() {
|
||||
NoteText t(10, 3);
|
||||
std::string text;
|
||||
for (int i = 0; i < 20; i++) text += "line " + std::to_string(i) + "\n";
|
||||
TEST_ASSERT_TRUE(t.setText(text));
|
||||
TEST_ASSERT_EQUAL_STRING("line 0", t.rows()[0].c_str());
|
||||
for (int i = 0; i < 5; i++) t.down();
|
||||
auto rows = t.rows();
|
||||
TEST_ASSERT_EQUAL_STRING("line 3", rows[0].c_str()); // scrolled a line at a time
|
||||
TEST_ASSERT_EQUAL_STRING("line 5", rows[2].c_str());
|
||||
TEST_ASSERT_EQUAL_INT(2, t.cursorRow());
|
||||
t.up();
|
||||
t.up();
|
||||
t.up();
|
||||
TEST_ASSERT_EQUAL_STRING("line 2", t.rows()[0].c_str());
|
||||
TEST_ASSERT_EQUAL_INT(0, t.cursorRow());
|
||||
t.toEnd();
|
||||
rows = t.rows();
|
||||
TEST_ASSERT_EQUAL_size_t(3, rows.size());
|
||||
TEST_ASSERT_EQUAL_STRING("line 18", rows[0].c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("", rows[2].c_str());
|
||||
TEST_ASSERT_EQUAL_INT(2, t.cursorRow());
|
||||
t.toStart();
|
||||
TEST_ASSERT_EQUAL_STRING("line 0", t.rows()[0].c_str());
|
||||
t.pageDown();
|
||||
TEST_ASSERT_EQUAL_size_t(14, t.cursor()); // two lines down
|
||||
t.pageUp();
|
||||
TEST_ASSERT_EQUAL_size_t(0, t.cursor());
|
||||
TEST_ASSERT_TRUE(t.percent() == 0);
|
||||
}
|
||||
|
||||
// Deleting above the screen must not leave the screen starting in the middle of a line.
|
||||
void test_edits_keep_the_screen_on_line_starts() {
|
||||
NoteText t(10, 3);
|
||||
TEST_ASSERT_TRUE(t.setText("aaaa bbbb cccc dddd eeee ffff gggg hhhh iiii jjjj"));
|
||||
t.toEnd();
|
||||
t.rows();
|
||||
for (int i = 0; i < 30; i++) {
|
||||
t.backspace();
|
||||
auto rows = t.rows();
|
||||
TEST_ASSERT_TRUE(rows.size() <= 3);
|
||||
int row = t.cursorRow();
|
||||
TEST_ASSERT_TRUE(row >= 0 && row < static_cast<int>(rows.size()));
|
||||
std::string all;
|
||||
for (auto& r : rows) all += r;
|
||||
TEST_ASSERT_TRUE(t.text().size() >= all.size());
|
||||
TEST_ASSERT_EQUAL_STRING(t.text().substr(t.text().size() - all.size()).c_str(), all.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
void test_full_and_too_big() {
|
||||
NoteText t(38, 8);
|
||||
TEST_ASSERT_FALSE(t.setText(std::string(NoteText::kMaxBytes + 1, 'x')));
|
||||
TEST_ASSERT_TRUE(t.setText(std::string(NoteText::kMaxBytes - 1, 'x')));
|
||||
uint32_t before = t.revision();
|
||||
t.toEnd();
|
||||
TEST_ASSERT_FALSE(t.insert(0xE9)); // two bytes: one too many
|
||||
TEST_ASSERT_FALSE(t.insertText(" "));
|
||||
TEST_ASSERT_EQUAL_UINT32(before, t.revision());
|
||||
TEST_ASSERT_TRUE(t.insert('y'));
|
||||
TEST_ASSERT_FALSE(t.insert('z'));
|
||||
TEST_ASSERT_TRUE(t.revision() != before);
|
||||
// One paragraph of 16 KB: moving through it still works.
|
||||
t.up();
|
||||
t.up();
|
||||
t.lineStart();
|
||||
TEST_ASSERT_EQUAL_size_t((NoteText::kMaxBytes / 38 - 2) * 38, t.cursor()); // two rows of 38 above the last
|
||||
}
|
||||
|
||||
void test_crlf_becomes_lf() {
|
||||
NoteText t(38, 8);
|
||||
TEST_ASSERT_TRUE(t.setText("a\r\nb\r\n\rc"));
|
||||
TEST_ASSERT_EQUAL_STRING("a\nb\n\rc", t.text().c_str());
|
||||
}
|
||||
|
||||
// The device hands over the string it read the file into: no second copy, and never a bigger
|
||||
// buffer afterwards, whatever is typed.
|
||||
void test_adopting_a_buffer_never_reallocates() {
|
||||
std::string body;
|
||||
body.reserve(NoteText::kMaxBytes);
|
||||
body = "one\r\ntwo\r\n";
|
||||
const char* buffer = body.data();
|
||||
NoteText t(38, 8, std::move(body));
|
||||
TEST_ASSERT_EQUAL_STRING("one\ntwo\n", t.text().c_str());
|
||||
TEST_ASSERT_EQUAL_PTR(buffer, t.text().data());
|
||||
t.toEnd();
|
||||
while (t.insert('x')) {}
|
||||
TEST_ASSERT_EQUAL_size_t(NoteText::kMaxBytes, t.text().size());
|
||||
TEST_ASSERT_EQUAL_PTR(buffer, t.text().data());
|
||||
TEST_ASSERT_TRUE(t.setText("replaced"));
|
||||
TEST_ASSERT_EQUAL_PTR(buffer, t.text().data());
|
||||
NoteText fresh(38, 8);
|
||||
const char* own = fresh.text().data();
|
||||
while (fresh.insert('y')) {}
|
||||
TEST_ASSERT_EQUAL_PTR(own, fresh.text().data());
|
||||
}
|
||||
|
||||
void test_names() {
|
||||
TEST_ASSERT_EQUAL_STRING("shopping-list", nameFromFirstLine("Shopping list!", "x").c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("ete-a-la-plage-2026", nameFromFirstLine(" \xC3\x89t\xC3\xA9 \xC3\xA0 la plage (2026)", "x").c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("note-20261006-0919", nameFromFirstLine("", "20261006-0919").c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("note-20261006-0919", nameFromFirstLine("\xE2\x82\xAC ?!", "20261006-0919").c_str());
|
||||
TEST_ASSERT_TRUE(nameFromFirstLine(std::string(100, 'a'), "x").size() <= 33);
|
||||
TEST_ASSERT_EQUAL_STRING("strase", nameFromFirstLine("Stra\xC3\x9F" "e", "x").c_str());
|
||||
}
|
||||
|
||||
void test_titles() {
|
||||
TEST_ASSERT_EQUAL_STRING("Shopping list", titleFrom("\n \r\n Shopping list \r\nmilk\n", 24).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("abcde", titleFrom("abcdefgh", 5).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("\xC3\xA9t", titleFrom("\xC3\xA9t\xC3", 24).c_str()); // a character cut by the end of what was read
|
||||
TEST_ASSERT_EQUAL_STRING("\xC3\xA9", titleFrom("\xC3\xA9t\xC3\xA9", 1).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("", titleFrom(" \n\n", 24).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("", titleFrom("", 24).c_str());
|
||||
}
|
||||
|
||||
int main() {
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_typing_and_rows);
|
||||
RUN_TEST(test_wrapping_at_spaces);
|
||||
RUN_TEST(test_a_long_word_is_cut);
|
||||
RUN_TEST(test_left_right_over_accents);
|
||||
RUN_TEST(test_up_and_down_keep_their_column);
|
||||
RUN_TEST(test_up_and_down_through_wrapped_lines);
|
||||
RUN_TEST(test_a_final_newline_gives_a_last_empty_line);
|
||||
RUN_TEST(test_the_screen_follows_the_cursor);
|
||||
RUN_TEST(test_edits_keep_the_screen_on_line_starts);
|
||||
RUN_TEST(test_full_and_too_big);
|
||||
RUN_TEST(test_crlf_becomes_lf);
|
||||
RUN_TEST(test_adopting_a_buffer_never_reallocates);
|
||||
RUN_TEST(test_names);
|
||||
RUN_TEST(test_titles);
|
||||
return UNITY_END();
|
||||
}
|
||||
@@ -31,6 +31,15 @@ void test_key_on_off_screen_only_wakes() {
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(ScreenState::On), static_cast<int>(p.update(70001)));
|
||||
}
|
||||
|
||||
// A key stamped a few milliseconds after the time the same pass updates with must not read as
|
||||
// 49 days of idleness: the screen stays on, and the next key acts.
|
||||
void test_activity_stamped_after_the_pass_time_keeps_the_screen_on() {
|
||||
PowerPolicy p(30000, 60000);
|
||||
TEST_ASSERT_FALSE(p.activity(10003));
|
||||
TEST_ASSERT_TRUE(p.update(10000) == ScreenState::On);
|
||||
TEST_ASSERT_FALSE(p.activity(10050));
|
||||
}
|
||||
|
||||
void test_new_timeouts_apply_immediately() {
|
||||
PowerPolicy p(30000, 60000);
|
||||
p.activity(0);
|
||||
@@ -91,6 +100,7 @@ int main() {
|
||||
RUN_TEST(test_screen_dims_then_turns_off_after_inactivity);
|
||||
RUN_TEST(test_key_on_dimmed_screen_wakes_and_still_acts);
|
||||
RUN_TEST(test_key_on_off_screen_only_wakes);
|
||||
RUN_TEST(test_activity_stamped_after_the_pass_time_keeps_the_screen_on);
|
||||
RUN_TEST(test_new_timeouts_apply_immediately);
|
||||
RUN_TEST(test_notification_lights_an_off_screen_dimmed_until_it_ends);
|
||||
RUN_TEST(test_notification_does_not_count_as_activity);
|
||||
|
||||
Reference in New Issue
Block a user