From 7fe8b3d22a4e49b0f5bb3b3d3a12f46f74495b93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Martin?= Date: Wed, 7 Oct 2026 01:29:51 +0200 Subject: [PATCH] Help: Fn+h lists the keys of the screen you're on, and no screen names its keys any more (#69) Fn+h on any screen, text fields included, and ? outside Text Entry, open a panel over the content area: the screen's own keys, then the ones that work everywhere. Every App declares its keys for the state it is in (pages, viewers, dialogs and text fields answer for themselves); the App manager opens the panel and takes every key while it is open. About 30 hint lines are gone, from every App. What stays on a screen is state. The first-start Setup keeps its hints and teaches the key; a device set up before gets one Toast, once. The guide and the FAQ open with it. `key help` over the consoles. 476 host tests (8 new). Checked on the device with key help and screenshots: the Launcher, all nine Apps and several of their states. 8.5 KB of flash and 40 bytes of static RAM. Decisions Q196 to Q203 in docs/milestones/U1.md. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT --- CONTEXT.md | 4 + README.md | 6 +- docs/milestones/U1.md | 40 ++++++++++ lib/core/src/app.h | 9 +++ lib/core/src/app_manager.cpp | 16 ++++ lib/core/src/app_manager.h | 4 + lib/core/src/key_event.h | 1 + lib/core/src/key_help.h | 92 ++++++++++++++++++++++ lib/input/src/key_mapper.cpp | 14 ++++ lib/input/src/key_mapper.h | 2 +- lib/services/src/settings.cpp | 1 + lib/services/src/settings.h | 1 + site/content/dev/debug/commands.md | 4 +- site/content/dev/debug/drive-the-ui.md | 4 +- site/content/dev/milestones/u1.md | 48 +++++++++++ site/content/faq.md | 4 + site/content/guide/_index.md | 2 +- site/content/guide/basics.md | 13 ++- site/content/guide/storage.md | 2 +- site/tools/gen_dev_docs.py | 2 +- src/apps/debug_console_page.cpp | 9 ++- src/apps/debug_console_page.h | 3 + src/apps/demo_app.cpp | 11 ++- src/apps/demo_app.h | 1 + src/apps/file_viewer.cpp | 33 ++++++-- src/apps/file_viewer.h | 2 + src/apps/firmware_page.cpp | 32 ++++++-- src/apps/firmware_page.h | 3 + src/apps/gemini_app.cpp | 34 +++++++- src/apps/gemini_app.h | 2 + src/apps/gnss_app.cpp | 17 ++-- src/apps/gnss_app.h | 1 + src/apps/irc_app.cpp | 33 +++++++- src/apps/irc_app.h | 2 + src/apps/launcher_app.h | 1 + src/apps/lora_scanner_app.cpp | 33 +++++++- src/apps/lora_scanner_app.h | 2 + src/apps/maintenance_page.cpp | 5 ++ src/apps/maintenance_page.h | 2 + src/apps/note_editor.cpp | 14 +++- src/apps/note_editor.h | 2 + src/apps/notes_app.cpp | 20 ++++- src/apps/notes_app.h | 2 + src/apps/settings_app.cpp | 31 +++++++- src/apps/settings_app.h | 2 + src/apps/setup_app.cpp | 25 +++++- src/apps/setup_app.h | 2 + src/apps/storage_app.cpp | 54 ++++++++++--- src/apps/storage_app.h | 2 + src/apps/system_app.cpp | 29 +++++-- src/apps/system_app.h | 2 + src/apps/wifi_settings_page.cpp | 44 +++++++++-- src/apps/wifi_settings_page.h | 3 + src/apps/wifi_tools_app.cpp | 29 ++++++- src/apps/wifi_tools_app.h | 2 + src/main.cpp | 11 ++- src/services/gemini_service.cpp | 3 +- src/ui/screen.cpp | 1 + src/ui/widgets.cpp | 36 +++++++++ src/ui/widgets.h | 4 + test/test_app_manager/test_app_manager.cpp | 84 ++++++++++++++++++++ test/test_key_mapper/test_key_mapper.cpp | 39 +++++++++ 62 files changed, 855 insertions(+), 81 deletions(-) create mode 100644 docs/milestones/U1.md create mode 100644 lib/core/src/key_help.h create mode 100644 site/content/dev/milestones/u1.md diff --git a/CONTEXT.md b/CONTEXT.md index 10d088c..b420548 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -106,6 +106,10 @@ The regulatory band plan the device transmits under (here EU868). It sets the al **Duty Cycle Budget**: The share of airtime the Region allows this device to transmit. When it's used up, outgoing traffic waits. +**Help panel**: +The list of the keys that work on the screen you are on, opened with Fn+h anywhere (or `?` outside Text Entry). Each App answers for its current state; no screen names keys any other way, except the first-start Setup. +_Avoid_: hints, cheat sheet, shortcuts bar + **Text Entry**: When an App is editing text. During Text Entry, `;` `.` `,` `/` type their characters and Fn makes them arrows. Otherwise they are arrows on their own. _Avoid_: edit mode, insert mode diff --git a/README.md b/README.md index 1914a1d..17a389a 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,10 @@ A multi-app firmware for the **M5Stack Cardputer ADV** with the **Cap LoRa-1262* - Decisions: [docs/adr/](docs/adr/) - Milestones: [docs/milestones/](docs/milestones/) +## On the device: one key + +**Fn+h, on any screen, lists the keys that work there** (`?` does the same outside a text field). No screen names its keys itself (docs/milestones/U1.md): each App declares them for the state it is in, and the help panel shows them, followed by the ones that work everywhere. + ## Requirements Only **Docker** is needed. PlatformIO and the ESP32 toolchain run inside a container, and are cached in the `roro9stack-pio` Docker volume. The first build downloads about 1 GB and takes a few minutes. @@ -159,7 +163,7 @@ A note holds up to 16 KB while it's edited. A bigger text file opens read-only i | Command | Effect | |---|---| | `burst` | Publishes 5 Notifications at once | -| `key up\|down\|left\|right\|select\|back\|home`, or `key ` | Injects a key press | +| `key up\|down\|left\|right\|select\|back\|home\|del\|tab\|space\|help`, or `key ` | Injects a key press (`help` is Fn+h: the keys of the screen that is showing) | | `sound on` / `sound off` | Toggles the Sound setting (beep + LED) | | `short` / `normal` | Screen timeouts 5 s / 10 s, or 30 s / 60 s | | `wifi add ` | Adds a Saved Network (so credentials stay out of the repo) | diff --git a/docs/milestones/U1.md b/docs/milestones/U1.md new file mode 100644 index 0000000..1172e48 --- /dev/null +++ b/docs/milestones/U1.md @@ -0,0 +1,40 @@ +# U1 — Look and feel + +**Status:** in progress. The help key (issue #69) is built and checked on the device, in a pull request. Screen recording (#17) and the rest of the milestone are not started. + +**Goal:** the interface is consistent and uncrowded: the same thing is done the same way on every screen, and the 135 pixels of height go to content. + +## The help key (issue #69) + +Every screen used to say something about its keys, differently: a footer of abbreviations in one place (`c x v:paste r:name d:del n:new i:info s:sort`), a line under a text field in another (`Enter: save \`: cancel`), `Tab: sky` in a corner, and nothing at all in several. About 30 such strings, each costing a line of a small screen, and none of them complete. + +### Decisions (design round 2026-10-07) + +| # | Decision | +|---|---| +| Q196 | **Fn+h, on every screen,** text fields included (Fn is held, so nothing is typed). **`?` too, outside Text Entry.** | +| Q197 | It opens **a panel over the content area**, titled with where you are: the screen's own keys, then an "Everywhere" group (Back, Home, the arrows, the help key). The arrows scroll it; any other key closes it and is not passed on. | +| Q198 | **Each App answers "what are your keys right now?"** for the state it is in; pages, viewers, dialogs and text fields answer for themselves, with shared lists for dialogs, lists and text entry. The lists are constants; the panel's rows exist only while it is open. | +| Q199 | **Every hint that names a key goes,** text fields included. What stays is state: `REC 12 points`, `LOG 42`, `sort:signal`, `typing`/`saved`, what is waiting to be pasted, the Sweep's floor. Messages were reworded where they named a key ("v pastes a copy of…" is "Copied …: paste it where you like"). | +| Q200 | **The first-start Setup keeps its hints,** and is the one place that does: someone in their first minute doesn't know the help key yet. It tells them about it on its first and last screens. | +| Q201 | **Loud everywhere else:** the user guide opens with it, the FAQ has it first, and a device set up before this firmware gets one Toast, once: "Fn+h: the keys of any screen". | +| Q202 | `key help` over the consoles. Generating the website's key tables from the same lists is a follow-up, not this issue. | +| Q203 | The key and the panel first, host-tested; then one App at a time, declaring its keys and losing its hints in the same step; then every screen looked at on the device. | + +### As built + +- **`Key::Help`** from the key mapper: Fn+h in both modes, `?` only outside Text Entry (`lib/input`, 3 tests). +- **`App::help()` and `App::helpTitle()`** (`lib/core/src/app.h`), `KeyHelp` rows and `HelpModel` (`key_help.h`). The **App manager** opens the panel, appends the "Everywhere" group, and while it is open takes every key: nothing reaches the App, Home included. It closes when the App changes (5 tests). +- **Every App declares its keys by state:** the Launcher, IRC (chat, settings, a field), Wi-Fi Tools (4 views), GNSS, Gemini (page, saved page, address, answer, dialogs), the LoRa Scanner (4 views), Storage (browse, details, a name, the viewer's 6 modes, the editor, Maintenance, busy), Notes (list, editor, a file name), System (5 views), Settings (menu, text, choice, and the Wi-Fi, Firmware and Debug Console pages with their own states), Setup and the widget demo. +- **The hints are gone** from all of them. The footers that remain say state only. +- **It costs** 8.5 KB of flash and 40 bytes of static RAM. + +### Checks + +| Check | Result | +|---|---| +| Host tests | 476 pass (468 before) | +| On the device, `key help` and a screenshot | The Launcher and all nine Apps, and these states: Storage scrolled, System's tasks, a Settings text field, Wi-Fi Tools' networks. The panel is titled with the scope, lists the right keys, scrolls, and closes on Tab | +| The one-time Toast | `notification: Fn+h: the keys of any screen` on the first start after the update | + +**Not checked:** the real Fn+h and `?` on the keyboard (the mapper is host-tested; the device was driven with `key help`); the Setup screens, which only a device that was never set up shows, so their new text has not been seen on a screen; and the states that need something to happen first (a dialog, a copy in progress, a Gemini prompt, a packet's details): their lists were read against the key handling, not looked at. diff --git a/lib/core/src/app.h b/lib/core/src/app.h index 8ebef71..571c2ca 100644 --- a/lib/core/src/app.h +++ b/lib/core/src/app.h @@ -2,7 +2,10 @@ #include +#include + #include "key_event.h" +#include "key_help.h" namespace roro { @@ -25,6 +28,12 @@ class App { // True while the App is editing text: the arrow keys then type ; . , / and need Fn to move. virtual bool textEntryActive() const { return false; } + // The keys that work right now, for the help panel (Fn+h): the App's own, in the state it's + // in. Back, Home and the arrows are added for it. No screen names keys any other way. + virtual void help(std::vector& out) const { (void)out; } + // What the panel is titled with, when the App's name isn't enough ("Notes: editor"). + virtual const char* helpTitle() const { return nullptr; } + // Called every main-loop pass while in the foreground (e.g. to refresh live values). virtual void update(uint32_t nowMs) { (void)nowMs; } diff --git a/lib/core/src/app_manager.cpp b/lib/core/src/app_manager.cpp index a0b2254..8832d1d 100644 --- a/lib/core/src/app_manager.cpp +++ b/lib/core/src/app_manager.cpp @@ -52,6 +52,21 @@ void AppManager::endModal() { } void AppManager::handleKey(const KeyEvent& event) { + if (help_.isOpen()) { // scrolls or closes; nothing reaches the App, Home included + help_.onKey(event); + redraw_ = true; + return; + } + if (event.key == Key::Help) { + std::vector rows; + foreground_->help(rows); + help::everywhere(rows); + const char* scope = foreground_->helpTitle(); + const char* app = foregroundTitle(); + help_.open(scope ? scope : app ? app : "Launcher", std::move(rows)); + redraw_ = true; + return; + } if (modal_) { foreground_->onKey(event); return; @@ -77,6 +92,7 @@ bool AppManager::takeRedraw() { } void AppManager::switchTo(App& app) { + help_.close(); // an App opened from elsewhere (a Notification, a command): its keys, not the last one's if (&app == foreground_) return; foreground_->onExit(); foreground_ = &app; diff --git a/lib/core/src/app_manager.h b/lib/core/src/app_manager.h index 317fe55..eebeb29 100644 --- a/lib/core/src/app_manager.h +++ b/lib/core/src/app_manager.h @@ -37,6 +37,9 @@ class AppManager { App& foreground() const { return *foreground_; } const char* foregroundTitle() const; // nullptr for the Launcher + // The help panel (Fn+h): open, it takes every key, and the App sees none of them. + const HelpModel& help() const { return help_; } + // True once after the screen needs redrawing (App switch, or the App asked for it). bool takeRedraw(); @@ -47,6 +50,7 @@ class AppManager { App& launcher_; App* foreground_; std::vector apps_; + HelpModel help_; bool redraw_ = true; bool modal_ = false; }; diff --git a/lib/core/src/key_event.h b/lib/core/src/key_event.h index a6b69f6..222d251 100644 --- a/lib/core/src/key_event.h +++ b/lib/core/src/key_event.h @@ -16,6 +16,7 @@ enum class Key : uint8_t { Home, Tab, Delete, + Help, // Fn+h anywhere, or ? outside Text Entry: the keys of this screen (issue #69) }; struct KeyEvent { diff --git a/lib/core/src/key_help.h b/lib/core/src/key_help.h new file mode 100644 index 0000000..6c8ef6a --- /dev/null +++ b/lib/core/src/key_help.h @@ -0,0 +1,92 @@ +#pragma once + +#include +#include +#include + +#include "key_event.h" + +// The help panel (issue #69, docs/milestones/U1.md): Fn+h on any screen lists the keys that work +// there. Every App says what its keys are in the state it's in; nothing on a screen names keys. +namespace roro { + +// One line of the panel: a key (or keys) and what it does. A line with no action is a heading. +struct KeyHelp { + const char* keys; + const char* action; +}; + +namespace help { + +// Lists that several screens share. +inline void list(std::vector& out, const char* enter = "open") { + out.push_back({"; .", "up, down"}); + out.push_back({"Enter", enter}); +} +inline void dialog(std::vector& out) { + out.push_back({", /", "the other answer"}); + out.push_back({"Enter", "choose it"}); + out.push_back({"`", "cancel"}); +} +inline void textEntry(std::vector& out, const char* enter = "save") { + out.push_back({"Enter", enter}); + out.push_back({"`", "cancel"}); + out.push_back({"Del", "delete backwards"}); + out.push_back({"Fn , /", "move the cursor"}); + out.push_back({"opt ' e", "an accent: \xC3\xA9"}); +} +// What works on every screen: the end of every panel. +inline void everywhere(std::vector& out) { + out.push_back({"Everywhere", nullptr}); + out.push_back({"`", "back"}); + out.push_back({"Fn `", "home, the Launcher"}); + out.push_back({"; . , /", "arrows (Fn+ while typing)"}); + out.push_back({"Fn h ?", "these keys (? not typing)"}); +} + +} // namespace help + +// The panel itself: what it lists, and how far it's scrolled. The keys it takes while open are the +// arrows; any other key closes it, and none reaches the App. +class HelpModel { + public: + explicit HelpModel(int visibleRows = 8) : visible_(visibleRows) {} + + void open(const std::string& title, std::vector rows) { + title_ = title; + rows_ = std::move(rows); + top_ = 0; + open_ = true; + } + void close() { + open_ = false; + rows_.clear(); + rows_.shrink_to_fit(); // nothing is kept while it's closed + } + bool isOpen() const { return open_; } + + void onKey(const KeyEvent& e) { + int last = std::max(0, static_cast(rows_.size()) - visible_); + switch (e.key) { + case Key::Up: top_ = std::max(0, top_ - 1); break; + case Key::Down: top_ = std::min(last, top_ + 1); break; + case Key::Left: top_ = std::max(0, top_ - visible_); break; + case Key::Right: top_ = std::min(last, top_ + visible_); break; + default: close(); break; + } + } + + const std::string& title() const { return title_; } + const std::vector& rows() const { return rows_; } + int top() const { return top_; } + int visibleRows() const { return visible_; } + + private: + std::string title_; + std::vector rows_; + int top_ = 0; + int visible_; + bool open_ = false; +}; + +} // namespace roro diff --git a/lib/input/src/key_mapper.cpp b/lib/input/src/key_mapper.cpp index 7c26726..c72066e 100644 --- a/lib/input/src/key_mapper.cpp +++ b/lib/input/src/key_mapper.cpp @@ -99,6 +99,20 @@ void KeyMapper::onChar(char c, const RawKeys& keys, std::vector& out) return; } break; + case 'h': + case 'H': + if (keys.fn) { // Fn+h: help, while typing too + out.push_back(KeyEvent::of(Key::Help)); + return; + } + break; + case '?': + if (!textEntry_ && !keys.fn) { // ? alone, when it wouldn't be typed + out.push_back(KeyEvent::of(Key::Help)); + return; + } + if (keys.fn) return; + break; default: if (keys.fn) return; // other Fn combos are unassigned break; diff --git a/lib/input/src/key_mapper.h b/lib/input/src/key_mapper.h index 4a4285c..52abcc8 100644 --- a/lib/input/src/key_mapper.h +++ b/lib/input/src/key_mapper.h @@ -23,7 +23,7 @@ struct RawKeys { // Turns keyboard state changes into logical KeyEvents: only newly pressed keys produce events; // Fn + ; . , / are arrows, and so are ; . , / alone when no text is being entered; ` is Back and -// Fn + ` is Home; the Compose Key (opt) followed by an accent and a letter types the accented +// Fn + ` is Home; Fn + h is Help anywhere, and so is ? when no text is being entered; the Compose Key (opt) followed by an accent and a letter types the accented // letter (opt ' e -> é). class KeyMapper { public: diff --git a/lib/services/src/settings.cpp b/lib/services/src/settings.cpp index e8a6147..962b325 100644 --- a/lib/services/src/settings.cpp +++ b/lib/services/src/settings.cpp @@ -44,6 +44,7 @@ const Definition kDefinitions[] = { {"check_updates", Kind::Bool, 1, nullptr, 0, 1}, // on: it only looks, and says so (R1, Q165) {"debug_on", Kind::Bool, 0, nullptr, 0, 1}, // off: nothing listens until the owner says so (Q189) {"debug_token", Kind::String, 0, "", 0, 64}, // empty, or a valid token + {"help_told", Kind::Bool, 0, nullptr, 0, 1}, }; static_assert(sizeof(kDefinitions) / sizeof(kDefinitions[0]) == static_cast(Setting::Count), "every Setting needs a definition"); diff --git a/lib/services/src/settings.h b/lib/services/src/settings.h index 2c8a0ca..97b9bc0 100644 --- a/lib/services/src/settings.h +++ b/lib/services/src/settings.h @@ -33,6 +33,7 @@ enum class Setting : uint8_t { CheckUpdates, // bool: look for a newer release on Gitea once a day (R1, Q165) DebugConsole, // bool: the Debug Console listens on Wi-Fi (ADR 0010, Q189: off unless switched on) DebugToken, // string: its token, tidied (debug_auth.h); empty until the console is first switched on + HelpTold, // bool: this device has been told about the help key once (issue #69, Q201) Count }; diff --git a/site/content/dev/debug/commands.md b/site/content/dev/debug/commands.md index 7bee5b6..a55199a 100644 --- a/site/content/dev/debug/commands.md +++ b/site/content/dev/debug/commands.md @@ -28,7 +28,7 @@ gnss quiet on|off pause the GNSS receiver while the LoRa radio listens (it cos gnss status | gnss restart | gnss track start|stop | gnss nmea on|off | gnss send crash the last crash: firmware, reason, task, backtrace coredump erase forget the core dump in flash -key press a key: up down left right select back home del tab space, or one character +key press a key: up down left right select back home del tab space help, or one character wifi status | wifi add wifi ip dhcp | wifi ip
/ [gateway] a Saved Network's IP setting wifi dns [b] | wifi dns always on|off | wifi ntp [b] DNS and NTP servers @@ -60,7 +60,7 @@ In **Safe Mode** (see [Crashes and Safe Mode](/dev/debug/crashes/)) only a few r | Command | Effect | |---|---| | `burst` | Publishes 5 Notifications at once | -| `key up\|down\|left\|right\|select\|back\|home`, or `key ` | Injects a key press | +| `key up\|down\|left\|right\|select\|back\|home\|del\|tab\|space\|help`, or `key ` | Injects a key press (`help` is Fn+h: the keys of the screen that is showing) | | `sound on` / `sound off` | Toggles the Sound setting (beep + LED) | | `short` / `normal` | Screen timeouts 5 s / 10 s, or 30 s / 60 s | | `wifi add ` | Adds a Saved Network (so credentials stay out of the repo) | diff --git a/site/content/dev/debug/drive-the-ui.md b/site/content/dev/debug/drive-the-ui.md index e95b2f6..f15ef9d 100644 --- a/site/content/dev/debug/drive-the-ui.md +++ b/site/content/dev/debug/drive-the-ui.md @@ -11,7 +11,7 @@ Everything the keyboard can do, a command can do, and everything on the screen c ## Keys ``` -key up|down|left|right|select|back|home|del|tab|space +key up|down|left|right|select|back|home|del|tab|space|help key a # any single character: it is typed ``` @@ -22,6 +22,8 @@ Two things to know before you use them: `Fn` combinations, modifiers and the compose key have no command: the arrows are `key up|down|left|right`, and `key back` is the back key (`` ` `` on the device). Text is typed one character at a time. +**`key help` opens the help panel** (Fn+h on the device): the keys of the screen that is showing. A screenshot of it is the quickest way to learn what a screen accepts, and it is how every screen's list was checked. Any key but the arrows closes it. + ## Look before you press **Take a screenshot before any key that deletes, renames or installs.** A blind sequence of `key` commands goes wrong the moment the screen is not where you think it is, and the screen is often not where you think it is: a Toast, a dialog that has not closed, a different App. A sequence that was meant to open a note once renamed real data instead. diff --git a/site/content/dev/milestones/u1.md b/site/content/dev/milestones/u1.md new file mode 100644 index 0000000..a6baa69 --- /dev/null +++ b/site/content/dev/milestones/u1.md @@ -0,0 +1,48 @@ ++++ +title = "Look and feel" +description = "The interface is consistent and uncrowded: the same thing is done the same way on every screen, and the 135 pixels of height go to content." +weight = 90 + +[extra] +docs = true +source = "docs/milestones/U1.md" +tag = "U1" ++++ +**Status:** in progress. The help key (issue #69) is built and checked on the device, in a pull request. Screen recording (#17) and the rest of the milestone are not started. + +**Goal:** the interface is consistent and uncrowded: the same thing is done the same way on every screen, and the 135 pixels of height go to content. + +## The help key (issue #69) + +Every screen used to say something about its keys, differently: a footer of abbreviations in one place (`c x v:paste r:name d:del n:new i:info s:sort`), a line under a text field in another (`Enter: save \`: cancel`), `Tab: sky` in a corner, and nothing at all in several. About 30 such strings, each costing a line of a small screen, and none of them complete. + +### Decisions (design round 2026-10-07) + +| # | Decision | +|---|---| +| Q196 | **Fn+h, on every screen,** text fields included (Fn is held, so nothing is typed). **`?` too, outside Text Entry.** | +| Q197 | It opens **a panel over the content area**, titled with where you are: the screen's own keys, then an "Everywhere" group (Back, Home, the arrows, the help key). The arrows scroll it; any other key closes it and is not passed on. | +| Q198 | **Each App answers "what are your keys right now?"** for the state it is in; pages, viewers, dialogs and text fields answer for themselves, with shared lists for dialogs, lists and text entry. The lists are constants; the panel's rows exist only while it is open. | +| Q199 | **Every hint that names a key goes,** text fields included. What stays is state: `REC 12 points`, `LOG 42`, `sort:signal`, `typing`/`saved`, what is waiting to be pasted, the Sweep's floor. Messages were reworded where they named a key ("v pastes a copy of…" is "Copied …: paste it where you like"). | +| Q200 | **The first-start Setup keeps its hints,** and is the one place that does: someone in their first minute doesn't know the help key yet. It tells them about it on its first and last screens. | +| Q201 | **Loud everywhere else:** the user guide opens with it, the FAQ has it first, and a device set up before this firmware gets one Toast, once: "Fn+h: the keys of any screen". | +| Q202 | `key help` over the consoles. Generating the website's key tables from the same lists is a follow-up, not this issue. | +| Q203 | The key and the panel first, host-tested; then one App at a time, declaring its keys and losing its hints in the same step; then every screen looked at on the device. | + +### As built + +- **`Key::Help`** from the key mapper: Fn+h in both modes, `?` only outside Text Entry (`lib/input`, 3 tests). +- **`App::help()` and `App::helpTitle()`** (`lib/core/src/app.h`), `KeyHelp` rows and `HelpModel` (`key_help.h`). The **App manager** opens the panel, appends the "Everywhere" group, and while it is open takes every key: nothing reaches the App, Home included. It closes when the App changes (5 tests). +- **Every App declares its keys by state:** the Launcher, IRC (chat, settings, a field), Wi-Fi Tools (4 views), GNSS, Gemini (page, saved page, address, answer, dialogs), the LoRa Scanner (4 views), Storage (browse, details, a name, the viewer's 6 modes, the editor, Maintenance, busy), Notes (list, editor, a file name), System (5 views), Settings (menu, text, choice, and the Wi-Fi, Firmware and Debug Console pages with their own states), Setup and the widget demo. +- **The hints are gone** from all of them. The footers that remain say state only. +- **It costs** 8.5 KB of flash and 40 bytes of static RAM. + +### Checks + +| Check | Result | +|---|---| +| Host tests | 476 pass (468 before) | +| On the device, `key help` and a screenshot | The Launcher and all nine Apps, and these states: Storage scrolled, System's tasks, a Settings text field, Wi-Fi Tools' networks. The panel is titled with the scope, lists the right keys, scrolls, and closes on Tab | +| The one-time Toast | `notification: Fn+h: the keys of any screen` on the first start after the update | + +**Not checked:** the real Fn+h and `?` on the keyboard (the mapper is host-tested; the device was driven with `key help`); the Setup screens, which only a device that was never set up shows, so their new text has not been seen on a screen; and the states that need something to happen first (a dialog, a copy in progress, a Gemini prompt, a packet's details): their lists were read against the key handling, not looked at. diff --git a/site/content/faq.md b/site/content/faq.md index 6915a75..45222f6 100644 --- a/site/content/faq.md +++ b/site/content/faq.md @@ -6,6 +6,10 @@ template = "guide-page.html" toc = true +++ +## Which keys work on this screen? + +Press **Fn + h**, on any screen: it lists the keys that work there, then the ones that work everywhere. ? does the same when you are not typing text. The screens themselves never name their keys, so this is the one key to remember. See [The basics](/guide/basics/). + ## Can I send messages over the mesh? **Not yet.** The LoRa Scanner **listens** to Meshtastic traffic and shows what it hears, but nothing is transmitted. A mesh messenger is the goal and is planned in two milestones, one for receiving and one for transmitting; it waits for a second node to test against. diff --git a/site/content/guide/_index.md b/site/content/guide/_index.md index fb66ef0..99ce495 100644 --- a/site/content/guide/_index.md +++ b/site/content/guide/_index.md @@ -6,7 +6,7 @@ sort_by = "weight" page_template = "guide-page.html" +++ -This guide says what the firmware does **today** and nothing else. Start with the basics (the keys, the Launcher, the first start), then read the page of any App. It describes the latest release; the numbers and key names come from the firmware's own source. +This guide says what the firmware does **today** and nothing else. Start with the basics (the keys, the Launcher, the first start), then read the page of any App. **On the device itself, Fn + h lists the keys of whatever screen you are on:** nothing else on a screen names them. It describes the latest release; the numbers and key names come from the firmware's own source. **The mesh messenger is planned, not built.** The LoRa Scanner listens to Meshtastic traffic and shows it, but the device sends nothing yet: that is the next milestone and waits for a second node to test with. diff --git a/site/content/guide/basics.md b/site/content/guide/basics.md index 4055682..c2e76f1 100644 --- a/site/content/guide/basics.md +++ b/site/content/guide/basics.md @@ -6,6 +6,14 @@ weight = 1 tag = "Start here" +++ +## One key to remember + +**Fn + h, on any screen, lists the keys that work there.** No screen names its keys: that key does. It works everywhere, in a text field too, and ? does the same whenever you are not typing. The arrows scroll the list; any other key closes it. + +The list is for *the screen you are on*: in Storage it is the file keys, in a dialog it is the dialog's, in a text field it is the editing keys. Each list ends with the keys that work everywhere. + +If you only read one paragraph of this guide, this was it. + ## The keys The Cardputer's keyboard has no arrow keys and no Escape, so the firmware gives a few keys a second job: @@ -17,11 +25,12 @@ The Cardputer's keyboard has no arrow keys and no Escape, so the firmware gives | Fn + ` | **Home**: back to the Launcher | | Fn + ; . , / | The arrows: up, down, left, right | | ; . , / alone | The same arrows, as long as you are **not** typing text | +| Fn + h, or ? when not typing | **Help:** the keys of the screen you are on | | Tab | Switches view in an App that has more than one | | Del | Deletes backwards when you type | | opt then an accent, then a letter | Types an accented letter: opt ' e gives é | -While you type text (a note, an IRC line, a setting), `;` `.` `,` `/` type their own characters and you need Fn for the arrows. The bottom of the screen shows `opt` while a compose is waiting for its letter. +While you type text (a note, an IRC line, a setting), `;` `.` `,` `/` type their own characters and you need Fn for the arrows. The Status Bar shows `opt` while a compose is waiting for its letter. ## The Launcher @@ -50,7 +59,7 @@ News from a background service (an IRC mention, a Storage warning, an update tha ## The first start -On a new device a short Setup asks four things, then never appears again: +On a new device a short Setup asks four things, then never appears again. It also tells you about Fn + h, twice, and it is the only part of the firmware that names keys on the screen: 1. **Long name**, up to 39 bytes. 2. **Short name**, up to 4 characters. diff --git a/site/content/guide/storage.md b/site/content/guide/storage.md index 889e9ab..03b4a35 100644 --- a/site/content/guide/storage.md +++ b/site/content/guide/storage.md @@ -13,7 +13,7 @@ Storage shows what is on the SD card: each folder's entries with their size and | Key | Does | |---|---| -| c / x | Copies or cuts the selected file or folder; the footer shows what v would paste | +| c / x | Copies or cuts the selected file or folder; the footer shows what is waiting to be pasted | | v | Pastes it into the folder shown. A copy next to its original is named `name (2).txt`; anything in the way is asked about first | | r | Renames | | d | Deletes, after saying what is inside: "Delete saved and its 42 files (1.2 MB)?" | diff --git a/site/tools/gen_dev_docs.py b/site/tools/gen_dev_docs.py index bab2ae2..d4e777f 100644 --- a/site/tools/gen_dev_docs.py +++ b/site/tools/gen_dev_docs.py @@ -23,7 +23,7 @@ REPO = SITE.parent OUT = SITE / "content" / "dev" REPO_URL = re.search(r'repo\s*=\s*"([^"]+)"', (SITE / "config.toml").read_text()).group(1) -MILESTONES = ["OTA", "M2", "G1", "M3", "S1", "F1", "R1", "W1"] # in the order they were done +MILESTONES = ["OTA", "M2", "G1", "M3", "S1", "F1", "R1", "W1", "U1"] # in the order they were done # Left out on purpose: docs/milestones/M0.md, M1.md and CONTEXT.md (the glossary) describe Wi-Fi monitoring, which this site does not publish. # They stay in the repository. diff --git a/src/apps/debug_console_page.cpp b/src/apps/debug_console_page.cpp index fc38ec9..053ab26 100644 --- a/src/apps/debug_console_page.cpp +++ b/src/apps/debug_console_page.cpp @@ -74,6 +74,13 @@ bool DebugConsolePage::onKey(const KeyEvent& e) { return true; } +void DebugConsolePage::help(std::vector& out) const { + if (confirm_) return help::dialog(out); + if (typing_) return help::textEntry(out); + help::list(out, "switch, or open"); + out.push_back({", /", "switch the console on or off"}); +} + void DebugConsolePage::draw(Canvas& c) { const auto& area = theme::kContent; c.setTextDatum(top_left); @@ -84,8 +91,6 @@ void DebugConsolePage::draw(Canvas& c) { widgets::lineEditor(c, editor_, {4, area.y + 22, area.w - 8, 0}); c.setTextColor(refusal_.empty() ? theme::kMuted : theme::kWarning); c.drawString(refusal_.empty() ? "16 to 64 characters. Capitals or not, it's the same." : refusal_.c_str(), 4, area.y + 44); - c.setTextColor(theme::kMuted); - c.drawString("Enter: save `: cancel", 4, area.y + 44 + theme::kLineHeight); return; } diff --git a/src/apps/debug_console_page.h b/src/apps/debug_console_page.h index 5cea89a..3ba46f8 100644 --- a/src/apps/debug_console_page.h +++ b/src/apps/debug_console_page.h @@ -5,6 +5,7 @@ #include "dialog_model.h" #include "key_event.h" +#include "key_help.h" #include "line_editor.h" #include "list_model.h" #include "services/wifi_service.h" @@ -25,6 +26,8 @@ class DebugConsolePage { bool onKey(const KeyEvent& e); // false: leave the page bool textEntryActive() const { return typing_; } void draw(Canvas& c); + void help(std::vector& out) const; + const char* helpTitle() const { return typing_ ? "A token of your own" : "Debug Console"; } private: enum Row { kSwitch, kAddress, kNewToken, kTypeToken, kRows }; diff --git a/src/apps/demo_app.cpp b/src/apps/demo_app.cpp index 4a46f8c..f101ff2 100644 --- a/src/apps/demo_app.cpp +++ b/src/apps/demo_app.cpp @@ -25,6 +25,15 @@ void DemoApp::notify(const char* text, NotificationLevel level) { bus_.publish(Event::withText(EventType::Notification, text, static_cast(level))); } +void DemoApp::help(std::vector& out) const { + if (dialog_) return help::dialog(out); + switch (page_) { + case Page::Menu: help::list(out, "try the widget"); break; + case Page::Text: out.push_back({"; .", "scroll"}); break; + case Page::Editor: help::textEntry(out, "show the text as a Toast"); break; + } +} + bool DemoApp::onKey(const KeyEvent& e) { requestRedraw(); if (dialog_) { @@ -97,7 +106,7 @@ void DemoApp::draw(Canvas& c) { } case Page::Editor: c.setTextColor(theme::kMuted); - c.drawString("Type (opt ' e = \xC3\xA9). Enter: toast", 4, area.y + 4); + c.drawString("A line editor", 4, area.y + 4); widgets::lineEditor(c, editor_, {4, area.y + 22, area.w - 8, 0}); c.setTextColor(theme::kMuted); c.drawString((std::to_string(editor_.text().size()) + "/39 bytes").c_str(), 4, area.y + 44); diff --git a/src/apps/demo_app.h b/src/apps/demo_app.h index 8f2e39e..a04c35d 100644 --- a/src/apps/demo_app.h +++ b/src/apps/demo_app.h @@ -21,6 +21,7 @@ class DemoApp : public App { bool onKey(const KeyEvent& e) override; bool textEntryActive() const override { return page_ == Page::Editor && !dialog_; } void draw(Canvas& c) override; + void help(std::vector& out) const override; private: enum class Page { Menu, Text, Editor }; diff --git a/src/apps/file_viewer.cpp b/src/apps/file_viewer.cpp index 3388f4a..06124d7 100644 --- a/src/apps/file_viewer.cpp +++ b/src/apps/file_viewer.cpp @@ -265,6 +265,30 @@ void FileViewer::openPacket(int index) { mode_ = Mode::Packet; } +void FileViewer::help(std::vector& out) const { + if (confirm_) return help::dialog(out); + switch (mode_) { + case Mode::Packet: + out.push_back({"; .", "scroll"}); + out.push_back({"Enter", "back to the packets"}); + return; + case Mode::Text: + case Mode::Hex: + out.push_back({"; .", "a line up, down"}); + out.push_back({", /", "a page up, down"}); + out.push_back({"t b", "the top, the end"}); + if (mode_ == Mode::Text) out.push_back({"e", "edit it (up to 16 KB)"}); + break; + case Mode::Pcap: + help::list(out, "the packet"); + out.push_back({", /", "a page up, down"}); + break; + case Mode::Ota: out.push_back({"Enter", "install it, if it's genuine"}); break; + case Mode::Gpx: break; + } + out.push_back({"Tab", mode_ != base_ ? "back to the file's own view" : base_ == Mode::Hex || base_ == Mode::Gpx ? "the file as text" : "the file as hex"}); +} + bool FileViewer::onKey(const KeyEvent& e) { if (confirm_) { confirm_->onKey(e); @@ -380,7 +404,7 @@ void FileViewer::draw(Canvas& c) { refresh(); c.setTextDatum(top_left); theme::Rect body{area.x, area.y + 10, area.w, kRows * theme::kLineHeight}; - std::string right, keys = "Tab: hex"; + std::string right; bool scanning = scan_ && !scan_->done; switch (mode_) { @@ -396,8 +420,6 @@ void FileViewer::draw(Canvas& c) { c.fillRect(area.w - 2, barY, 2, barH, theme::kMuted); } 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: { @@ -407,7 +429,6 @@ void FileViewer::draw(Canvas& c) { else if (scan_) lines = scan_->gpx.lines(); widgets::textLines(c, lines, 0, body); right = formatBytes(size_); - keys = "Tab: the file as text"; break; } case Mode::Pcap: { @@ -423,7 +444,6 @@ void FileViewer::draw(Canvas& c) { return row < shown_.size() ? shown_[row] : std::string(); }); right = std::to_string(scan_->packets.size()) + (scan_->morePackets ? "+ packets" : " packets"); - keys = "Enter: the packet Tab: hex"; } break; } @@ -447,7 +467,6 @@ void FileViewer::draw(Canvas& c) { } widgets::textLines(c, lines, 0, body); right = formatBytes(size_); - keys = ok ? "Enter: install Tab: hex" : "Tab: hex"; if (confirm_) { widgets::dialog(c, "Install update?", "The device restarts into " + scan_->version + " once it's written.", *confirm_); return; @@ -464,7 +483,7 @@ void FileViewer::draw(Canvas& c) { c.drawString(right.c_str(), area.w - 3, area.y + 1); c.setTextDatum(top_left); if (!message_.empty()) c.setTextColor(theme::kWarning); - c.drawString(message_.empty() ? keys.c_str() : message_.c_str(), 4, area.y + area.h - 9); + if (!message_.empty()) c.drawString(message_.c_str(), 4, area.y + area.h - 9); } } // namespace roro diff --git a/src/apps/file_viewer.h b/src/apps/file_viewer.h index 636efab..212290d 100644 --- a/src/apps/file_viewer.h +++ b/src/apps/file_viewer.h @@ -7,6 +7,7 @@ #include #include "dialog_model.h" +#include "key_help.h" #include "key_event.h" #include "list_model.h" #include "services/storage_service.h" @@ -27,6 +28,7 @@ class FileViewer { void open(const std::string& path, uint32_t size); void close(); bool onKey(const KeyEvent& e); // false: leave the viewer + void help(std::vector& out) const; bool update(uint32_t nowMs); // true: draw again void draw(Canvas& c); diff --git a/src/apps/firmware_page.cpp b/src/apps/firmware_page.cpp index ad3c7cd..cd85912 100644 --- a/src/apps/firmware_page.cpp +++ b/src/apps/firmware_page.cpp @@ -63,9 +63,9 @@ std::string FirmwarePage::latestText(bool& warn) const { release::Release r; if (g.status() == GiteaReleases::Status::Failed && !g.latest(r)) { warn = true; - return "failed: Enter retries"; + return "the check failed"; } - if (!g.latest(r)) return "Enter: check"; + if (!g.latest(r)) return "not checked yet"; return r.tag + (release::isNewer(r, update_.runningVersion()) ? " (new)" : " (current)"); } @@ -202,7 +202,7 @@ void FirmwarePage::drawRelease(Canvas& c) { c.setFont(&fonts::small); c.setTextDatum(top_left); c.setTextColor(theme::kMuted); - c.drawString(ok ? "Enter: install c: check again" : (why + " c: check").c_str(), 4, area.y + area.h - 9); + if (!ok) c.drawString(why.c_str(), 4, area.y + area.h - 9); // why it can't be installed from here } void FirmwarePage::drawOlder(Canvas& c) { @@ -232,9 +232,29 @@ void FirmwarePage::drawOlder(Canvas& c) { return r.tag + (r.tag == running ? " (running)" : release::versionNewer(r.tag, running) ? " (new)" : ""); }, [&](int i) { return olderReleases_[i].date(); }); - c.setFont(&fonts::small); - c.setTextColor(theme::kMuted); - c.drawString("Enter: details c: look again", 4, area.y + area.h - 9); +} + +void FirmwarePage::help(std::vector& out) const { + if (confirm_) return help::dialog(out); + switch (view_) { + case View::Main: + help::list(out, "check, open, or install"); + out.push_back({"c", "look for a newer release"}); + break; + case View::Release: + out.push_back({"; .", "scroll"}); + out.push_back({"Enter", "install it"}); + out.push_back({"c", "check again"}); + break; + case View::Older: + help::list(out, "its details"); + out.push_back({"c", "read the list again"}); + break; + } +} + +const char* FirmwarePage::helpTitle() const { + return view_ == View::Release ? "A release" : view_ == View::Older ? "Older releases" : "Firmware"; } void FirmwarePage::drawMain(Canvas& c) { diff --git a/src/apps/firmware_page.h b/src/apps/firmware_page.h index 25ce79e..bbab4d4 100644 --- a/src/apps/firmware_page.h +++ b/src/apps/firmware_page.h @@ -7,6 +7,7 @@ #include "dialog_model.h" #include "key_event.h" +#include "key_help.h" #include "list_model.h" #include "release_info.h" #include "services/storage_service.h" @@ -28,6 +29,8 @@ class FirmwarePage { void enter(); bool onKey(const KeyEvent& e); // false: leave the page void draw(Canvas& c); + void help(std::vector& out) const; + const char* helpTitle() const; private: enum Row { kVersion, kStatus, kAddress, kLatest, kOlder, kSdHeader, kFixed }; diff --git a/src/apps/gemini_app.cpp b/src/apps/gemini_app.cpp index 7e7c104..8770484 100644 --- a/src/apps/gemini_app.cpp +++ b/src/apps/gemini_app.cpp @@ -171,7 +171,7 @@ void GeminiApp::update(uint32_t nowMs) { else { std::string mime = result.header.mimeType(); showMessage(url, "# Not a text page\nThis is " + mime + - ", which the Cardputer can't show. s saves it to the card, in " + ", which the Cardputer can't show. Saving it puts it on the card, in " "/gemini/downloads.\n"); page_.header.meta = mime; // so s knows to download it } @@ -302,6 +302,32 @@ void GeminiApp::selectLink(int direction) { requestRedraw(); } +void GeminiApp::help(std::vector& out) const { + if (certDialog_ || deleteDialog_) return help::dialog(out); + if (inputOpen_) return help::textEntry(out, "send it"); + if (addressOpen_) return help::textEntry(out, "go there"); + out.push_back({"Tab", "the next link"}); + out.push_back({"Aa Tab", "the link before"}); + out.push_back({"Enter", "follow the link"}); + out.push_back({"` Del", "the page before"}); + out.push_back({"; .", "scroll"}); + out.push_back({"Space", "a page down"}); + out.push_back({", /", "sideways, in wide blocks"}); + out.push_back({"g", "type an address"}); + out.push_back({"b", "bookmark this page"}); + if (isSaved()) { + out.push_back({"r", "refresh this Saved Page"}); + out.push_back({"d", "delete this Saved Page"}); + } else { + out.push_back({"s", "save the page to the card"}); + out.push_back({"S", "...with the pages it links to"}); + } +} + +const char* GeminiApp::helpTitle() const { + return inputOpen_ ? "Gemini: an answer" : addressOpen_ ? "Gemini: an address" : nullptr; +} + bool GeminiApp::onKey(const KeyEvent& e) { requestRedraw(); if (certDialog_) { @@ -388,7 +414,7 @@ bool GeminiApp::onKey(const KeyEvent& e) { if (isGemini(page_.base())) gemini_.addBookmark(page_.base(), titleOf()); break; case 's': // Q82, Q73 - if (isSaved()) status_ = "Already saved; r refreshes it"; + if (isSaved()) status_ = "Already saved: it can be refreshed"; else if (!isGemini(page_.url)) status_ = "Only Gemini pages can be saved"; else if (page_.header.mimeType().rfind("text/", 0) == 0) gemini_.save(page_.url); else gemini_.download(page_.url); @@ -485,7 +511,7 @@ void GeminiApp::draw(Canvas& c) { c.fillRect(box.x - 2, box.y - 12, box.w + 4, box.h + 14, theme::kBackground); c.setFont(&fonts::small); c.setTextColor(theme::kMuted); - c.drawString("Go to (Enter, or ` to cancel)", box.x, box.y - 10); + c.drawString("Go to", box.x, box.y - 10); widgets::lineEditor(c, address_, box); } if (inputOpen_) { @@ -496,7 +522,7 @@ void GeminiApp::draw(Canvas& c) { c.drawString(displayText(inputPrompt_).substr(0, 39).c_str(), box.x, box.y - 22); c.setFont(&fonts::small); c.setTextColor(theme::kMuted); - c.drawString(inputSensitive_ ? "(hidden) Enter sends, ` cancels" : "Enter sends, ` cancels", box.x, box.y - 9); + if (inputSensitive_) c.drawString("(hidden)", box.x, box.y - 9); if (inputSensitive_) { LineEditor masked(1024); masked.setText(std::string(input_.text().size(), '*')); diff --git a/src/apps/gemini_app.h b/src/apps/gemini_app.h index 36f8114..7ac0546 100644 --- a/src/apps/gemini_app.h +++ b/src/apps/gemini_app.h @@ -24,6 +24,8 @@ class GeminiApp : public App { bool textEntryActive() const override { return addressOpen_ || inputOpen_; } void update(uint32_t nowMs) override; void draw(Canvas& c) override; + void help(std::vector& out) const override; + const char* helpTitle() const override; private: struct Visit { diff --git a/src/apps/gnss_app.cpp b/src/apps/gnss_app.cpp index 839d9bf..2741cae 100644 --- a/src/apps/gnss_app.cpp +++ b/src/apps/gnss_app.cpp @@ -80,11 +80,11 @@ void GnssApp::draw(Canvas& c) { return; } sky_ ? drawSky(c) : drawPosition(c); - c.setFont(&fonts::small); - c.setTextColor(theme::kMuted); - c.setTextDatum(top_right); - c.drawString(sky_ ? "Tab: position" : "Tab: sky", area.w - 3, area.y + area.h - 9); - c.setTextDatum(top_left); +} + +void GnssApp::help(std::vector& out) const { + out.push_back({"Tab", sky_ ? "the position" : "the sky"}); + out.push_back({"r", gnss_.tracking() ? "stop the Track" : "record a Track"}); } void GnssApp::drawPosition(Canvas& c) { @@ -139,15 +139,12 @@ void GnssApp::drawPosition(Canvas& c) { c.setFont(&fonts::small); if (gnss_.tracking()) { c.setTextColor(theme::kWarning); - std::snprintf(line, sizeof line, "REC %d point%s, %s r: stop", gnss_.trackPoints(), + std::snprintf(line, sizeof line, "REC %d point%s, %s", gnss_.trackPoints(), gnss_.trackPoints() == 1 ? "" : "s", duration(now - gnss_.trackStartMs()).c_str()); } else if (!refusal_.empty() && now - refusalMs_ < 4000) { c.setTextColor(theme::kWarning); std::snprintf(line, sizeof line, "%s", refusal_.c_str()); - } else { - c.setTextColor(theme::kMuted); - std::snprintf(line, sizeof line, "r: record a Track"); - } + } else line[0] = 0; c.drawString(line, 4, area.y + area.h - 9); } diff --git a/src/apps/gnss_app.h b/src/apps/gnss_app.h index 8ca2a9b..c32ce6f 100644 --- a/src/apps/gnss_app.h +++ b/src/apps/gnss_app.h @@ -17,6 +17,7 @@ class GnssApp : public App { bool onKey(const KeyEvent& e) override; void update(uint32_t nowMs) override; void draw(Canvas& c) override; + void help(std::vector& out) const override; private: void drawPosition(Canvas& c); diff --git a/src/apps/irc_app.cpp b/src/apps/irc_app.cpp index 098453b..f67986c 100644 --- a/src/apps/irc_app.cpp +++ b/src/apps/irc_app.cpp @@ -299,6 +299,36 @@ void IrcApp::drawChat(Canvas& c) { widgets::lineEditor(c, input_, {2, area.y + area.h - inputH, area.w - 4, 0}); } +void IrcApp::help(std::vector& out) const { + if (page_ == Page::Settings) { + if (editing_) return help::textEntry(out, "keep it"); + help::list(out, "edit, switch, or save"); + out.push_back({"`", "leave without saving"}); + return; + } + out.push_back({"Enter", "send the line"}); + out.push_back({"Tab", "the next buffer"}); + out.push_back({"Alt ; .", "scroll back, forward"}); + out.push_back({"Fn ; .", "lines you sent before"}); + out.push_back({"Fn , /", "move the cursor"}); + out.push_back({"Del", "delete backwards"}); + out.push_back({"/settings", "server, nick, passwords"}); + out.push_back({"/join #x", "join a channel"}); + out.push_back({"/part", "leave it"}); + out.push_back({"/msg nick", "a private chat"}); + out.push_back({"/me", "an action"}); + out.push_back({"/nick", "change your nick"}); + out.push_back({"/topic", "see or set the topic"}); + out.push_back({"/names", "who is there"}); + out.push_back({"/quit", "disconnect, and stay so"}); + out.push_back({"/raw", "a line as it is"}); + out.push_back({"`", "leave: IRC stays connected"}); +} + +const char* IrcApp::helpTitle() const { + return page_ == Page::Settings ? (editing_ ? "IRC: a setting" : "IRC settings") : nullptr; +} + void IrcApp::drawSettings(Canvas& c) { const auto& area = theme::kContent; if (editing_) { @@ -306,8 +336,7 @@ void IrcApp::drawSettings(Canvas& c) { c.setTextColor(theme::kMuted); c.drawString(fieldLabel(fields_.selected()).c_str(), 4, area.y + 4); widgets::lineEditor(c, fieldEditor_, {4, area.y + 22, area.w - 8, 0}); - c.drawString(fields_.selected() == kAutojoin ? "e.g. #roro, #private key" : "Enter: OK `: cancel", 4, - area.y + 44); + if (fields_.selected() == kAutojoin) c.drawString("e.g. #roro, #private key", 4, area.y + 44); return; } widgets::list( diff --git a/src/apps/irc_app.h b/src/apps/irc_app.h index 6bcd375..c7f3457 100644 --- a/src/apps/irc_app.h +++ b/src/apps/irc_app.h @@ -24,6 +24,8 @@ class IrcApp : public App { void update(uint32_t nowMs) override; bool textEntryActive() const override { return page_ == Page::Chat || editing_; } void draw(Canvas& c) override; + void help(std::vector& out) const override; + const char* helpTitle() const override; private: enum class Page { Chat, Settings }; diff --git a/src/apps/launcher_app.h b/src/apps/launcher_app.h index e75ef4f..dcd687b 100644 --- a/src/apps/launcher_app.h +++ b/src/apps/launcher_app.h @@ -14,6 +14,7 @@ class LauncherApp : public App { void onEnter() override; bool onKey(const KeyEvent& e) override; void draw(Canvas& c) override; + void help(std::vector& out) const override { help::list(out, "open the App"); } private: AppManager* manager_ = nullptr; diff --git a/src/apps/lora_scanner_app.cpp b/src/apps/lora_scanner_app.cpp index 0d4f350..f4fb245 100644 --- a/src/apps/lora_scanner_app.cpp +++ b/src/apps/lora_scanner_app.cpp @@ -165,6 +165,32 @@ void LoraScannerApp::update(uint32_t nowMs) { requestRedraw(); } +void LoraScannerApp::help(std::vector& out) const { + switch (view_) { + case View::Packets: + help::list(out, "the packet's details"); + out.push_back({"p", "pick a Meshtastic preset"}); + out.push_back({"c", capture_.capturing() ? "stop the Capture" : "start a Capture (pcap)"}); + out.push_back({"Tab", "the Sweep"}); + break; + case View::Details: + out.push_back({"; .", "scroll"}); + out.push_back({"Enter", "back to the list"}); + break; + case View::Presets: help::list(out, "listen with this preset"); break; + case View::Sweep: out.push_back({"Tab", "the Sniffer"}); break; + } +} + +const char* LoraScannerApp::helpTitle() const { + switch (view_) { + case View::Details: return "A LoRa packet"; + case View::Presets: return "LoRa presets"; + case View::Sweep: return "LoRa Sweep"; + default: return nullptr; + } +} + void LoraScannerApp::draw(Canvas& c) { lastDrawMs_ = millis(); c.setTextDatum(top_left); @@ -234,8 +260,7 @@ void LoraScannerApp::drawPackets(Canvas& c) { c.setFont(&fonts::small); bool showMessage = !message_.empty() && millis() - messageMs_ < kMessageMs; c.setTextColor(showMessage ? theme::kWarning : theme::kMuted); - std::string keys = std::string("Enter: details p: preset Tab: sweep c: ") + (capture_.capturing() ? "stop" : "capture"); - c.drawString(showMessage ? message_.c_str() : keys.c_str(), 4, area.y + area.h - 9); + if (showMessage) c.drawString(message_.c_str(), 4, area.y + area.h - 9); } void LoraScannerApp::drawDetails(Canvas& c) { @@ -329,9 +354,9 @@ void LoraScannerApp::drawSweep(Canvas& c) { c.drawString(line, x0 + f.steps * cell, wfTop + kWaterfallRows + 1); c.setTextDatum(top_left); if (st.peaks.empty()) - std::snprintf(line, sizeof line, "floor %d dBm, nothing above it Tab: sniffer", st.floor); + std::snprintf(line, sizeof line, "floor %d dBm, nothing above it", st.floor); else - std::snprintf(line, sizeof line, "floor %d, %.1f MHz at %d dBm Tab: sniffer", st.floor, st.peaks[0].hz / 1e6, + std::snprintf(line, sizeof line, "floor %d, %.1f MHz at %d dBm", st.floor, st.peaks[0].hz / 1e6, st.peaks[0].dbm); c.setTextColor(theme::kMuted); c.drawString(line, 4, y); diff --git a/src/apps/lora_scanner_app.h b/src/apps/lora_scanner_app.h index b8fe326..59eca27 100644 --- a/src/apps/lora_scanner_app.h +++ b/src/apps/lora_scanner_app.h @@ -26,6 +26,8 @@ class LoraScannerApp : public App { bool onKey(const KeyEvent& e) override; void update(uint32_t nowMs) override; void draw(Canvas& c) override; + void help(std::vector& out) const override; + const char* helpTitle() const override; private: enum class View { Packets, Details, Presets, Sweep }; diff --git a/src/apps/maintenance_page.cpp b/src/apps/maintenance_page.cpp index da1dbc7..097eba4 100644 --- a/src/apps/maintenance_page.cpp +++ b/src/apps/maintenance_page.cpp @@ -35,6 +35,11 @@ CleanupPlan MaintenancePage::planFor(int age) const { return CleanupPlan::make(listing_[categories_.selected()], cutoff, today); } +void MaintenancePage::help(std::vector& out) const { + if (dialog_) return help::dialog(out); + help::list(out, view_ == View::Main ? "open" : view_ == View::Categories ? "choose what to clean" : "choose how old"); +} + bool MaintenancePage::onKey(const KeyEvent& e) { if (dialog_) { dialog_->onKey(e); diff --git a/src/apps/maintenance_page.h b/src/apps/maintenance_page.h index fe8de7f..54e90af 100644 --- a/src/apps/maintenance_page.h +++ b/src/apps/maintenance_page.h @@ -5,6 +5,7 @@ #include "cleanup_plan.h" #include "dialog_model.h" +#include "key_help.h" #include "event_bus.h" #include "key_event.h" #include "list_model.h" @@ -24,6 +25,7 @@ class MaintenancePage { void enter(); bool onKey(const KeyEvent& e); // false: leave the page void draw(Canvas& c); + void help(std::vector& out) const; private: enum class View { Main, Categories, Ages }; diff --git a/src/apps/note_editor.cpp b/src/apps/note_editor.cpp index faf99a0..78d0318 100644 --- a/src/apps/note_editor.cpp +++ b/src/apps/note_editor.cpp @@ -182,6 +182,18 @@ bool NoteEditor::save() { return true; } +void NoteEditor::help(std::vector& out) const { + if (dialog_) return help::dialog(out); + out.push_back({"Enter", "a new line"}); + out.push_back({"Del", "delete backwards"}); + out.push_back({"Tab", "two spaces"}); + out.push_back({"Fn ; . , /", "move the cursor"}); + out.push_back({"Alt Fn ; .", "a page up, down"}); + out.push_back({"Ctrl a e", "start, end of the line"}); + out.push_back({"opt ' e", "an accent: \xC3\xA9"}); + out.push_back({"`", "done: it saves by itself"}); +} + bool NoteEditor::onKey(const KeyEvent& e) { if (!text_) return false; redraw_ = true; @@ -290,7 +302,7 @@ void NoteEditor::draw(Canvas& c) { 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 (showMessage) c.drawString(message_.c_str(), 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 " + diff --git a/src/apps/note_editor.h b/src/apps/note_editor.h index 04c7931..a9a8e4c 100644 --- a/src/apps/note_editor.h +++ b/src/apps/note_editor.h @@ -5,6 +5,7 @@ #include #include "dialog_model.h" +#include "key_help.h" #include "key_event.h" #include "note_text.h" #include "services/clock_service.h" @@ -33,6 +34,7 @@ class NoteEditor { const std::string& path() const { return path_; } // "" for a new note nothing was typed in bool onKey(const KeyEvent& e); // false: done, and saved + void help(std::vector& out) const; bool update(uint32_t nowMs); // true: draw again void draw(Canvas& c); diff --git a/src/apps/notes_app.cpp b/src/apps/notes_app.cpp index 7e0525c..380dd03 100644 --- a/src/apps/notes_app.cpp +++ b/src/apps/notes_app.cpp @@ -173,6 +173,23 @@ std::string NotesApp::titleOf(int row) { return files::fitName(list_.name(notes_[row]), kTitleChars); // an empty note, or not a text } +void NotesApp::help(std::vector& out) const { + if (view_ == View::Edit) return editor_.help(out); + if (dialog_) return help::dialog(out); + if (view_ == View::Name) return help::textEntry(out, "rename the file"); + if (view_ == View::NoMemory) return; + help::list(out, "open the note"); + out.push_back({", /", "a page up, down"}); + out.push_back({"n", "a new note"}); + out.push_back({"r", "rename its file"}); + out.push_back({"d Del", "delete it"}); + out.push_back({"s", "sort: newest, or by name"}); +} + +const char* NotesApp::helpTitle() const { + return view_ == View::Edit ? "Notes: the editor" : view_ == View::Name ? "Notes: a file name" : nullptr; +} + bool NotesApp::onKey(const KeyEvent& e) { requestRedraw(); if (view_ == View::NoMemory) return false; @@ -315,7 +332,6 @@ void NotesApp::draw(Canvas& c) { } 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( @@ -329,7 +345,7 @@ void NotesApp::draw(Canvas& c) { 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 (showMessage) c.drawString(message_.c_str(), 4, area.y + area.h - 9); if (view_ == View::Name) { theme::Rect box{16, 44, theme::kWidth - 32, 48}; diff --git a/src/apps/notes_app.h b/src/apps/notes_app.h index b72e8c5..749f1f7 100644 --- a/src/apps/notes_app.h +++ b/src/apps/notes_app.h @@ -28,6 +28,8 @@ class NotesApp : public App { bool textEntryActive() const override { return view_ == View::Edit || view_ == View::Name; } void update(uint32_t nowMs) override; void draw(Canvas& c) override; + void help(std::vector& out) const override; + const char* helpTitle() const override; private: enum class View { List, Edit, Name, NoMemory }; diff --git a/src/apps/settings_app.cpp b/src/apps/settings_app.cpp index 8939cf9..d902285 100644 --- a/src/apps/settings_app.cpp +++ b/src/apps/settings_app.cpp @@ -129,6 +129,31 @@ bool SettingsApp::onAboutKey(const KeyEvent& e) { return true; } +void SettingsApp::help(std::vector& out) const { + switch (page_) { + case Page::Menu: + help::list(out, "edit, or open the page"); + out.push_back({", /", "change a switch or a slider"}); + break; + case Page::Text: help::textEntry(out); break; + case Page::Choice: help::list(out, "choose it"); break; + case Page::About: break; + case Page::Wifi: wifiPage_.help(out); break; + case Page::Firmware: firmwarePage_.help(out); break; + case Page::Debug: debugPage_.help(out); break; + } +} + +const char* SettingsApp::helpTitle() const { + switch (page_) { + case Page::Wifi: return wifiPage_.helpTitle(); + case Page::Firmware: return firmwarePage_.helpTitle(); + case Page::Debug: return debugPage_.helpTitle(); + case Page::About: return "About"; + default: return nullptr; + } +} + void SettingsApp::update(uint32_t nowMs) { // Live values on About and Firmware. bool live = page_ == Page::About || page_ == Page::Firmware || page_ == Page::Debug || @@ -177,10 +202,8 @@ void SettingsApp::draw(Canvas& c) { c.setTextColor(theme::kMuted); c.drawString(menu_.label(editingRow_).c_str(), 4, area.y + 4); widgets::lineEditor(c, editor_, {4, area.y + 22, area.w - 8, 0}); - c.drawString(("Enter: save `: cancel " + std::to_string(editor_.text().size()) + "/" + - std::to_string(editor_.maxBytes()) + " bytes") - .c_str(), - 4, area.y + 44); + c.drawString((std::to_string(editor_.text().size()) + " of " + std::to_string(editor_.maxBytes()) + " bytes").c_str(), 4, + area.y + 44); break; case Page::Choice: { auto options = menu_.choices(editingRow_); diff --git a/src/apps/settings_app.h b/src/apps/settings_app.h index 1fca253..49ea4e3 100644 --- a/src/apps/settings_app.h +++ b/src/apps/settings_app.h @@ -50,6 +50,8 @@ class SettingsApp : public App { (page_ == Page::Debug && debugPage_.textEntryActive()); } void draw(Canvas& c) override; + void help(std::vector& out) const override; + const char* helpTitle() const override; private: enum class Page { Menu, Text, Choice, About, Wifi, Firmware, Debug }; diff --git a/src/apps/setup_app.cpp b/src/apps/setup_app.cpp index f967fa9..17244a5 100644 --- a/src/apps/setup_app.cpp +++ b/src/apps/setup_app.cpp @@ -44,6 +44,24 @@ bool SetupApp::onKey(const KeyEvent& e) { return true; } +void SetupApp::help(std::vector& out) const { + switch (wizard_.step()) { + case Step::LongName: + case Step::ShortName: help::textEntry(out, "next step"); break; + case Step::Region: + case Step::Timezone: + help::list(out, "choose it, next step"); + out.push_back({"`", "the step before"}); + break; + default: + out.push_back({"Enter", "continue"}); + out.push_back({"`", "the step before"}); + break; + } +} + +// The one place that names keys on the screen (Q200): someone in their first minute doesn't know +// the help key yet, and this is where they learn it. void SetupApp::draw(Canvas& c) { const auto& area = theme::kContent; auto heading = [&](const char* text) { @@ -63,6 +81,7 @@ void SetupApp::draw(Canvas& c) { heading("Welcome to roro9stack"); note("Let's set up this device: your names on the mesh, your radio region and your timezone.", area.y + 22, theme::kText); + note("One key to know: Fn + h lists the keys of the screen you're on.", area.y + 22 + 4 * theme::kLineHeight, theme::kAccent); note("Enter: continue", area.y + area.h - 14); break; case Step::LongName: @@ -88,8 +107,10 @@ void SetupApp::draw(Canvas& c) { [this](int i) { return wizard_.choiceLabel(i); }); break; case Step::Done: - heading("All set"); - note("You can change all of this later in Settings.", area.y + 22, theme::kText); + heading("All set. One key to remember:"); + note("Fn + h, on any screen, lists the keys that work there. No screen names its keys: that key does. Try it now.", + area.y + 22, theme::kAccent); + note("The rest can be changed later in Settings.", area.y + 22 + 4 * theme::kLineHeight, theme::kText); note("Enter: start `: go back", area.y + area.h - 14); break; } diff --git a/src/apps/setup_app.h b/src/apps/setup_app.h index 2a9921a..466c28c 100644 --- a/src/apps/setup_app.h +++ b/src/apps/setup_app.h @@ -17,6 +17,8 @@ class SetupApp : public App { return wizard_.step() == SetupWizard::Step::LongName || wizard_.step() == SetupWizard::Step::ShortName; } void draw(Canvas& c) override; + void help(std::vector& out) const override; + const char* helpTitle() const override { return "Setup"; } private: AppManager& apps_; diff --git a/src/apps/storage_app.cpp b/src/apps/storage_app.cpp index 12807cd..30a622f 100644 --- a/src/apps/storage_app.cpp +++ b/src/apps/storage_app.cpp @@ -199,7 +199,7 @@ void StorageApp::onFinished(const FileOps::Status& s) { } void StorageApp::paste(bool replace) { - if (!clip_) return say("Nothing to paste: c copies, x cuts"); + if (!clip_) return say("Nothing to paste: copy or cut something first"); std::string name = baseName(clip_.path), why; if (cut_) { why = ops_.move(clip_.path, clip_.folder, joinPath(cwd_, name), replace); @@ -273,6 +273,45 @@ void StorageApp::showDetails(const Item& item) { view_ = View::Details; } +void StorageApp::help(std::vector& out) const { + switch (view_) { + case View::NoMemory: return; + case View::Maintenance: return maintenance_.help(out); + case View::Editor: return noteEditor_.help(out); + case View::Viewer: return viewer_.help(out); + case View::Details: + out.push_back({"; .", "scroll"}); + out.push_back({"Enter", "back to the folder"}); + return; + default: break; + } + if (dialog_) return help::dialog(out); + if (wantList_ || wait_ != Wait::None) return (void)out.push_back({"`", "stop the copy or the delete"}); + if (view_ == View::Name) return help::textEntry(out, renaming_ ? "rename it" : "make the folder"); + help::list(out, "open the folder or the file"); + out.push_back({", /", "a page up, down"}); + out.push_back({"c x", "copy, cut"}); + out.push_back({"v", "paste here"}); + out.push_back({"r", "rename"}); + out.push_back({"d Del", "delete, after asking"}); + out.push_back({"n", "a new folder"}); + out.push_back({"i", "details: size, date, type"}); + out.push_back({"s", "sort: name, date, size"}); + out.push_back({"m", "Maintenance: clean-up, erase"}); + out.push_back({"`", "the folder above"}); +} + +const char* StorageApp::helpTitle() const { + switch (view_) { + case View::Maintenance: return "Storage: maintenance"; + case View::Editor: return "Storage: the editor"; + case View::Viewer: return "Storage: a file"; + case View::Details: return "Storage: details"; + case View::Name: return "Storage: a name"; + default: return nullptr; + } +} + bool StorageApp::onKey(const KeyEvent& e) { requestRedraw(); if (view_ == View::NoMemory) return false; @@ -434,7 +473,7 @@ bool StorageApp::onBrowseKey(const KeyEvent& e) { if (ch == 'c') { clip_ = item; cut_ = false; - say("v pastes a copy of " + name); + say("Copied " + name + ": paste it where you like"); return true; } // Cut, rename and delete change the original: the rules of Q130 first. @@ -446,7 +485,7 @@ bool StorageApp::onBrowseKey(const KeyEvent& e) { if (ch == 'x') { clip_ = item; cut_ = true; - say("v moves " + name + " here"); + say("Cut " + name + ": paste it where it should go"); } else if (ch == 'r') { target_ = item; renaming_ = true; @@ -542,7 +581,6 @@ void StorageApp::drawBrowse(Canvas& c) { } else if (rows_.count() == 0) { c.setTextColor(theme::kMuted); c.drawString("Nothing in this folder.", 4, rows.y + 4); - c.drawString("n makes a folder, v pastes.", 4, rows.y + 4 + theme::kLineHeight); } else { int entries = static_cast(list_.count()); auto value = [&](int i) { @@ -558,13 +596,13 @@ void StorageApp::drawBrowse(Canvas& c) { value); } - // Footer: a message for a few seconds, what v would paste, or the keys. + // Footer: a message for a few seconds, or what a paste would do. Never the keys (Fn+h). c.setFont(&fonts::small); bool showMessage = !message_.empty() && millis() - messageMs_ < kMessageMs; c.setTextColor(showMessage ? theme::kWarning : theme::kMuted); - std::string keys = "c x v:paste r:name d:del n:new i:info s:sort"; + std::string keys; if ((wantList_ || wait_ == Wait::List) && millis() - waitSinceMs_ >= kQuietMs) keys = "Reading " + files::fitName(want_, 30) + "..."; - else if (clip_) keys = std::string("v: ") + (cut_ ? "move " : "copy ") + files::fitName(baseName(clip_.path), 20) + " here"; + else if (clip_) keys = std::string(cut_ ? "Cut: " : "Copied: ") + files::fitName(baseName(clip_.path), 28); c.drawString(showMessage ? message_.c_str() : keys.c_str(), 4, area.y + area.h - 9); } @@ -589,8 +627,6 @@ void StorageApp::drawProgress(Canvas& c) { line = formatBytes(s.doneBytes) + " of " + formatBytes(s.totalBytes); } c.drawString(line.c_str(), box.x + 6, box.y + 31); - c.setFont(&fonts::small); - c.drawString("Back: cancel", box.x + 6, box.y + box.h - 10); } } // namespace roro diff --git a/src/apps/storage_app.h b/src/apps/storage_app.h index d468031..c095892 100644 --- a/src/apps/storage_app.h +++ b/src/apps/storage_app.h @@ -34,6 +34,8 @@ class StorageApp : public App { bool textEntryActive() const override { return view_ == View::Name || view_ == View::Editor; } void update(uint32_t nowMs) override; void draw(Canvas& c) override; + void help(std::vector& out) const override; + const char* helpTitle() const override; private: enum class View { Browse, Details, Name, Viewer, Editor, Maintenance, NoMemory }; diff --git a/src/apps/system_app.cpp b/src/apps/system_app.cpp index 618357c..4186e6a 100644 --- a/src/apps/system_app.cpp +++ b/src/apps/system_app.cpp @@ -73,6 +73,26 @@ void SystemApp::onExit() { // nothing is kept while it's closed (Q120) load_[0] = load_[1] = -1; } +void SystemApp::help(std::vector& out) const { + out.push_back({"Tab", "the next view"}); + out.push_back({"Aa Tab", "the view before"}); + if (view_ == View::Tasks) { + out.push_back({"; .", "scroll"}); + out.push_back({"s", "sort: cpu, stack, name"}); + } + if (view_ == View::System) out.push_back({"; .", "scroll"}); +} + +const char* SystemApp::helpTitle() const { + switch (view_) { + case View::Tasks: return "System: tasks"; + case View::Memory: return "System: memory"; + case View::Network: return "System: network"; + case View::System: return "System: system"; + default: return "System: overview"; + } +} + bool SystemApp::onKey(const KeyEvent& e) { if (e.key == Key::Tab) { int n = static_cast(View::Count), v = static_cast(view_); @@ -219,7 +239,6 @@ void SystemApp::drawOverview(Canvas& c) { } } } - footer(c, "Tab: tasks, memory, network, system"); } void SystemApp::drawTasks(Canvas& c) { @@ -249,8 +268,7 @@ void SystemApp::drawTasks(Canvas& c) { c.drawString(std::to_string(r.stackFree).c_str(), area.w - 4, y); } c.setTextDatum(top_left); - std::string keys = std::string("s: by ") + (sort_ == TaskSort::Share ? "stack" : sort_ == TaskSort::Stack ? "name" : "cpu") + - " " + std::to_string(rows_.size()) + " tasks Tab: memory"; + std::string keys = std::to_string(rows_.size()) + " tasks, by " + (sort_ == TaskSort::Share ? "cpu" : sort_ == TaskSort::Stack ? "stack" : "name"); footer(c, keys.c_str()); } @@ -288,7 +306,7 @@ void SystemApp::drawMemory(Canvas& c) { int x0 = gx + 1 + static_cast((i - 1) * (gw - 2) / kHistory), x1 = gx + 1 + static_cast(i * (gw - 2) / kHistory); c.drawLine(x0, yOf(heapHistory_.at(i - 1)), x1, yOf(heapHistory_.at(i)), theme::kAccent); } - footer(c, "KB free, 2 min, with the floors Tab: network"); + footer(c, "KB free, 2 min, with the floors"); } void SystemApp::drawNetwork(Canvas& c) { @@ -331,7 +349,7 @@ void SystemApp::drawNetwork(Canvas& c) { y += theme::kLineHeight; } c.setTextDatum(top_left); - footer(c, "bytes since boot Tab: system"); + footer(c, "bytes since boot"); } void SystemApp::drawSystem(Canvas& c) { @@ -355,7 +373,6 @@ void SystemApp::drawSystem(Canvas& c) { wrappedLines_ = static_cast(wrapped.size()); systemTop_ = std::clamp(systemTop_, 0, std::max(0, wrappedLines_ - 8)); widgets::textLines(c, wrapped, systemTop_, {area.x + 2, area.y + 2, area.w - 2, area.h - 12}); - footer(c, "Tab: overview"); } } // namespace roro diff --git a/src/apps/system_app.h b/src/apps/system_app.h index 7fa1501..8b1228c 100644 --- a/src/apps/system_app.h +++ b/src/apps/system_app.h @@ -28,6 +28,8 @@ class SystemApp : public App { bool onKey(const KeyEvent& e) override; void update(uint32_t nowMs) override; void draw(Canvas& c) override; + void help(std::vector& out) const override; + const char* helpTitle() const override; private: enum class View : uint8_t { Overview, Tasks, Memory, Network, System, Count }; diff --git a/src/apps/wifi_settings_page.cpp b/src/apps/wifi_settings_page.cpp index 39e39a9..3543cc3 100644 --- a/src/apps/wifi_settings_page.cpp +++ b/src/apps/wifi_settings_page.cpp @@ -413,7 +413,6 @@ void WifiSettingsPage::draw(Canvas& c) { c.setTextColor(theme::kMuted); c.drawString(editTitle_.c_str(), 4, area.y + 4); widgets::lineEditor(c, editor_, {4, area.y + 22, area.w - 8, 0}); - c.drawString("Enter: save `: cancel", 4, area.y + 44); break; } @@ -427,7 +426,7 @@ void WifiSettingsPage::draw(Canvas& c) { if (!n.ssid.empty()) found.push_back(n); if (scan_.count() != static_cast(found.size())) scan_.setCount(static_cast(found.size())); if (found.empty()) { - widgets::textLines(c, {"No networks found.", "", "`: back"}, 0, area); + widgets::textLines(c, {"No networks found."}, 0, area); break; } widgets::list( @@ -443,20 +442,55 @@ void WifiSettingsPage::draw(Canvas& c) { std::string title = view_ == View::Ssid ? "Hidden network name" : "Password for " + newSsid_; c.drawString(title.c_str(), 4, area.y + 4); widgets::lineEditor(c, editor_, {4, area.y + 22, area.w - 8, 0}); - c.drawString(view_ == View::Ssid ? "Enter: next `: cancel" : "Enter: save (empty = open) `: cancel", 4, - area.y + 44); + if (view_ == View::Password) c.drawString("Leave it empty for an open network.", 4, area.y + 44); break; } } } +void WifiSettingsPage::help(std::vector& out) const { + if (forgetDialog_) return help::dialog(out); + switch (view_) { + case View::Main: + help::list(out, "open, or change"); + out.push_back({", /", "switch Wi-Fi on or off"}); + break; + case View::Servers: + help::list(out, "edit"); + out.push_back({", /", "Always use my DNS: on, off"}); + break; + case View::Network: + help::list(out, "edit, or forget"); + out.push_back({", /", "Automatic or Fixed"}); + break; + case View::Details: out.push_back({"Enter", "back"}); break; + case View::Scan: help::list(out, "choose this network"); break; + case View::Ssid: help::textEntry(out, "next: the password"); break; + case View::Password: + case View::Edit: help::textEntry(out); break; + } +} + +const char* WifiSettingsPage::helpTitle() const { + switch (view_) { + case View::Servers: return "DNS and NTP"; + case View::Network: return "A saved network"; + case View::Details: return "Wi-Fi status"; + case View::Scan: return "Add a network"; + case View::Ssid: return "Hidden network name"; + case View::Password: return "Wi-Fi password"; + case View::Edit: return "Wi-Fi: a field"; + default: return "Wi-Fi"; + } +} + // What the device is using and where each part came from (Q113). void WifiSettingsPage::drawDetails(Canvas& c) { const auto& area = theme::kContent; WifiService::Connection n = wifi_.connection(); std::vector lines; if (!n.connected) { - lines = {statusText(), "", "`: back"}; + lines = {statusText()}; } else { lines.push_back(wifi_.ssid() + ", " + std::to_string(wifi_.rssi()) + " dBm"); lines.push_back("Address " + n.address + "/" + std::to_string(n.prefix) + (n.fixed ? " (fixed)" : " (DHCP)")); diff --git a/src/apps/wifi_settings_page.h b/src/apps/wifi_settings_page.h index d69f4cf..e549545 100644 --- a/src/apps/wifi_settings_page.h +++ b/src/apps/wifi_settings_page.h @@ -6,6 +6,7 @@ #include "dialog_model.h" #include "event_bus.h" #include "key_event.h" +#include "key_help.h" #include "line_editor.h" #include "list_model.h" #include "saved_networks.h" @@ -30,6 +31,8 @@ class WifiSettingsPage { // Redraw periodically: the status, a scan, the connection details. bool live() const { return view_ == View::Main || view_ == View::Scan || view_ == View::Details; } void draw(Canvas& c); + void help(std::vector& out) const; + const char* helpTitle() const; private: enum class View { Main, Scan, Ssid, Password, Details, Servers, Network, Edit }; diff --git a/src/apps/wifi_tools_app.cpp b/src/apps/wifi_tools_app.cpp index d26daa9..224a563 100644 --- a/src/apps/wifi_tools_app.cpp +++ b/src/apps/wifi_tools_app.cpp @@ -153,6 +153,31 @@ void WifiToolsApp::draw(Canvas& c) { } } +void WifiToolsApp::help(std::vector& out) const { + switch (view_) { + case View::Menu: help::list(out); break; + case View::Networks: + help::list(out, "track its signal"); + out.push_back({"s", "sort: signal, channel, name"}); + out.push_back({"o", "open networks only"}); + out.push_back({"h", "hide the hidden ones"}); + out.push_back({"w", "strong ones only"}); + out.push_back({"l", "log the scans to the card"}); + break; + case View::Channels: break; + case View::Tracker: out.push_back({"m", "clicks on or off"}); break; + } +} + +const char* WifiToolsApp::helpTitle() const { + switch (view_) { + case View::Networks: return "Networks nearby"; + case View::Channels: return "Channel occupancy"; + case View::Tracker: return "Signal tracker"; + default: return nullptr; + } +} + void WifiToolsApp::drawNetworks(Canvas& c) { const auto& area = theme::kContent; const auto& scan = wifi_.listScan(); @@ -178,7 +203,7 @@ void WifiToolsApp::drawNetworks(Canvas& c) { widgets::textLines(c, {wifi_.listScanDone() ? (any ? "Nothing matches the filter." : "No networks found yet.") : "Scanning...", - "", "s sort o open h hide-hidden w strong l log"}, + }, 0, list); return; } @@ -233,7 +258,7 @@ void WifiToolsApp::drawTracker(Canvas& c) { c.drawString(targetName_.c_str(), 4, area.y + 2); c.setFont(&fonts::small); c.setTextColor(theme::kMuted); - std::string sub = targetBssid_ + " ch" + std::to_string(targetChannel_) + " m: clicks " + (clicks_ ? "on" : "off"); + std::string sub = targetBssid_ + " ch" + std::to_string(targetChannel_) + " clicks " + (clicks_ ? "on" : "off"); c.drawString(sub.c_str(), 4, area.y + 17); c.setFont(&fonts::bold); diff --git a/src/apps/wifi_tools_app.h b/src/apps/wifi_tools_app.h index 2fee3bf..5762ffb 100644 --- a/src/apps/wifi_tools_app.h +++ b/src/apps/wifi_tools_app.h @@ -27,6 +27,8 @@ class WifiToolsApp : public App { bool onKey(const KeyEvent& e) override; void update(uint32_t nowMs) override; void draw(Canvas& c) override; + void help(std::vector& out) const override; + const char* helpTitle() const override; private: enum class View { Menu, Networks, Channels, Tracker }; diff --git a/src/main.cpp b/src/main.cpp index 6d33e6e..440a468 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -225,6 +225,13 @@ void setup() { services.startAll(millis()); apps->begin(); if (!settings.getBool(Setting::SetupDone)) apps->openModal("setup"); + // No screen names its keys any more (issue #69): Setup teaches the help key to a new device, and + // one that was set up before this firmware is told once, here. + if (!settings.getBool(Setting::HelpTold)) { + if (settings.getBool(Setting::SetupDone)) + bus.publish(Event::withText(EventType::Notification, "Fn+h: the keys of any screen", static_cast(NotificationLevel::Info))); + settings.setBool(Setting::HelpTold, true); + } console.printf("%s %s ready, free heap %u, last start: %s\n", kProductName, versionString(), ESP.getFreeHeap(), system_info::resetReason()); if (system_info::resetWasCrash()) { @@ -555,7 +562,7 @@ static const char* const kHelp = "gnss status | gnss restart | gnss track start|stop | gnss nmea on|off | gnss send \n" "crash the last crash: firmware, reason, task, backtrace\n" "coredump erase forget the core dump in flash\n" - "key press a key: up down left right select back home del tab space, or one character\n" + "key press a key: up down left right select back home del tab space help, or one character\n" "wifi status | wifi add \n" "wifi ip dhcp | wifi ip
/ [gateway] a Saved Network's IP setting\n" "wifi dns [b] | wifi dns always on|off | wifi ntp [b] DNS and NTP servers\n" @@ -842,7 +849,7 @@ static void runCommand(String line, bool fromSerial = false) { String k = line.substring(4); Key key = k == "up" ? Key::Up : k == "down" ? Key::Down : k == "left" ? Key::Left : k == "right" ? Key::Right : k == "back" ? Key::Back : k == "home" ? Key::Home - : k == "del" ? Key::Delete : k == "tab" ? Key::Tab : Key::Select; + : k == "del" ? Key::Delete : k == "tab" ? Key::Tab : k == "help" ? Key::Help : Key::Select; KeyEvent ev = k == "space" ? KeyEvent::character(' ') : k.length() == 1 && k[0] > ' ' ? KeyEvent::character((unsigned char)k[0]) : KeyEvent::of(key); diff --git a/src/services/gemini_service.cpp b/src/services/gemini_service.cpp index fe95d69..59ab1d9 100644 --- a/src/services/gemini_service.cpp +++ b/src/services/gemini_service.cpp @@ -459,8 +459,7 @@ void GeminiService::openSaved(const std::string& path, GeminiPage& page) { // about:start (Q78, Q84): bookmarks, then Saved Pages by capsule, newest first, then defaults. void GeminiService::composeStart(GeminiPage& page) { - std::string doc = "# Gemini\nTab picks a link, Enter follows it, Back returns, g types an address. " - "On a page: b bookmarks it, s saves it to read offline, S with the pages it links to.\n"; + std::string doc = "# Gemini\nYour bookmarks and Saved Pages, then a few places to start.\n"; std::string bookmarks; std::vector saved; bool card = storage_.runAndWait([&]() { diff --git a/src/ui/screen.cpp b/src/ui/screen.cpp index 67b3a62..f0aa02b 100644 --- a/src/ui/screen.cpp +++ b/src/ui/screen.cpp @@ -17,6 +17,7 @@ void Screen::render(AppManager& apps, const StatusInfo& status, const Toast* toa const auto& area = theme::kContent; canvas_.setClipRect(area.x, area.y, area.w, area.h); apps.foreground().draw(canvas_); + if (apps.help().isOpen()) widgets::help(canvas_, apps.help()); // over the App, under the Status Bar canvas_.clearClipRect(); widgets::statusBar(canvas_, status); if (toast) widgets::toast(canvas_, *toast); diff --git a/src/ui/widgets.cpp b/src/ui/widgets.cpp index b8e476d..3a5f8fe 100644 --- a/src/ui/widgets.cpp +++ b/src/ui/widgets.cpp @@ -175,4 +175,40 @@ void toast(Canvas& c, const Toast& t) { c.clearClipRect(); } +void help(Canvas& c, const HelpModel& model) { + const auto& area = kContent; + c.fillRect(area.x, area.y, area.w, area.h, kBackground); + c.setTextDatum(top_left); + c.setFont(&fonts::bold); + c.setTextColor(kAccent); + c.drawString(("Keys: " + model.title()).c_str(), 4, area.y + 1); + c.drawFastHLine(4, area.y + 13, area.w - 8, kMuted); + + const int top = area.y + 16, keysX = 4, actionX = 70; + const auto& rows = model.rows(); + int count = static_cast(rows.size()); + for (int row = 0; row < model.visibleRows(); row++) { + int i = model.top() + row; + if (i >= count) break; + int y = top + row * kLineHeight; + if (!rows[i].action) { // a heading + c.setFont(&fonts::bold); + c.setTextColor(kMuted); + c.drawString(rows[i].keys, keysX, y); + continue; + } + c.setFont(&fonts::body); + c.setTextColor(kAccent); + c.drawString(rows[i].keys, keysX, y); + c.setTextColor(kText); + c.drawString(rows[i].action, actionX, y); + } + if (count > model.visibleRows()) { + int h = model.visibleRows() * kLineHeight; + int barH = h * model.visibleRows() / count; + int barY = top + (h - barH) * model.top() / (count - model.visibleRows()); + c.fillRect(area.x + area.w - 2, barY, 2, barH, kMuted); + } +} + } // namespace roro::widgets diff --git a/src/ui/widgets.h b/src/ui/widgets.h index 518571b..4058584 100644 --- a/src/ui/widgets.h +++ b/src/ui/widgets.h @@ -6,6 +6,7 @@ #include "canvas.h" #include "dialog_model.h" +#include "key_help.h" #include "line_editor.h" #include "list_model.h" #include "text_wrap.h" @@ -59,6 +60,9 @@ void lineEditor(Canvas& c, const LineEditor& editor, theme::Rect area); void dialog(Canvas& c, const std::string& title, const std::string& message, const DialogModel& model); +// The help panel (Fn+h): the keys of the screen under it, over the whole content area. +void help(Canvas& c, const HelpModel& model); + void toast(Canvas& c, const Toast& t); } // namespace widgets diff --git a/test/test_app_manager/test_app_manager.cpp b/test/test_app_manager/test_app_manager.cpp index f2e869f..20e11dc 100644 --- a/test/test_app_manager/test_app_manager.cpp +++ b/test/test_app_manager/test_app_manager.cpp @@ -20,6 +20,12 @@ struct FakeApp : App { } void update(uint32_t nowMs) override { lastUpdate = nowMs; } void draw(Canvas&) override {} + void help(std::vector& out) const override { + if (helpKey) out.push_back({helpKey, "does a thing"}); + } + const char* helpTitle() const override { return scope; } + const char* helpKey = nullptr; + const char* scope = nullptr; uint32_t lastUpdate = 0; std::vector keys; bool handles = false; @@ -181,6 +187,79 @@ void test_modal_app_cannot_be_switched_away_by_open() { TEST_ASSERT_EQUAL_PTR(&f.demo, &f.manager.foreground()); } +// Issue #69: Fn+h lists the foreground App's keys, then the ones that work everywhere. +void test_help_lists_the_apps_keys_then_the_global_ones() { + Fixture f; + f.manager.begin(); + f.manager.open("notes"); + f.notes.helpKey = "n"; + f.manager.handleKey(KeyEvent::of(Key::Help)); + TEST_ASSERT_TRUE(f.manager.help().isOpen()); + TEST_ASSERT_EQUAL_STRING("Notes", f.manager.help().title().c_str()); + const auto& rows = f.manager.help().rows(); + TEST_ASSERT_EQUAL_STRING("n", rows[0].keys); + TEST_ASSERT_EQUAL_STRING("Everywhere", rows[1].keys); + TEST_ASSERT_NULL(rows[1].action); // a heading + TEST_ASSERT_TRUE(rows.size() > 3); + TEST_ASSERT_EQUAL(0, f.notes.keys.size()); // the App never saw the key + TEST_ASSERT_TRUE(f.manager.takeRedraw()); +} + +void test_help_is_titled_with_the_scope_when_the_app_names_one() { + Fixture f; + f.manager.begin(); + f.manager.handleKey(KeyEvent::of(Key::Help)); + TEST_ASSERT_EQUAL_STRING("Launcher", f.manager.help().title().c_str()); + f.manager.handleKey(KeyEvent::of(Key::Select)); // closes it + f.manager.open("notes"); + f.notes.scope = "Notes: editor"; + f.manager.handleKey(KeyEvent::of(Key::Help)); + TEST_ASSERT_EQUAL_STRING("Notes: editor", f.manager.help().title().c_str()); +} + +void test_open_help_takes_every_key_and_any_but_the_arrows_closes_it() { + Fixture f; + f.manager.begin(); + f.manager.open("notes"); + f.manager.handleKey(KeyEvent::of(Key::Help)); + f.manager.handleKey(KeyEvent::of(Key::Down)); // scrolls + TEST_ASSERT_TRUE(f.manager.help().isOpen()); + f.manager.handleKey(KeyEvent::of(Key::Home)); // closes; doesn't go home + TEST_ASSERT_FALSE(f.manager.help().isOpen()); + TEST_ASSERT_TRUE(&f.manager.foreground() == &f.notes); + TEST_ASSERT_EQUAL(0, f.notes.keys.size()); + f.manager.handleKey(KeyEvent::character('x')); // closed: keys reach the App again + TEST_ASSERT_EQUAL(1, f.notes.keys.size()); +} + +void test_help_works_in_a_modal_app_and_closes_when_the_app_changes() { + Fixture f; + f.manager.begin(); + f.manager.openModal("demo"); + f.manager.handleKey(KeyEvent::of(Key::Help)); + TEST_ASSERT_TRUE(f.manager.help().isOpen()); + f.manager.endModal(); + TEST_ASSERT_FALSE(f.manager.help().isOpen()); +} + +void test_the_help_panel_scrolls_within_its_rows() { + HelpModel m(3); + std::vector rows; + for (int i = 0; i < 8; i++) rows.push_back({"k", "a"}); + m.open("T", rows); + m.onKey(KeyEvent::of(Key::Up)); + TEST_ASSERT_EQUAL(0, m.top()); + m.onKey(KeyEvent::of(Key::Right)); // a page + TEST_ASSERT_EQUAL(3, m.top()); + for (int i = 0; i < 9; i++) m.onKey(KeyEvent::of(Key::Down)); + TEST_ASSERT_EQUAL(5, m.top()); // the last row at the bottom, no further + m.onKey(KeyEvent::of(Key::Left)); + TEST_ASSERT_EQUAL(2, m.top()); + m.onKey(KeyEvent::of(Key::Tab)); + TEST_ASSERT_FALSE(m.isOpen()); + TEST_ASSERT_EQUAL(0, m.rows().size()); +} + int main() { UNITY_BEGIN(); RUN_TEST(test_launcher_is_in_foreground_after_begin); @@ -199,5 +278,10 @@ int main() { RUN_TEST(test_modal_app_keeps_home_and_back); RUN_TEST(test_ending_the_modal_returns_to_launcher_and_unlocks_home); RUN_TEST(test_modal_app_cannot_be_switched_away_by_open); + RUN_TEST(test_help_lists_the_apps_keys_then_the_global_ones); + RUN_TEST(test_help_is_titled_with_the_scope_when_the_app_names_one); + RUN_TEST(test_open_help_takes_every_key_and_any_but_the_arrows_closes_it); + RUN_TEST(test_help_works_in_a_modal_app_and_closes_when_the_app_changes); + RUN_TEST(test_the_help_panel_scrolls_within_its_rows); return UNITY_END(); } diff --git a/test/test_key_mapper/test_key_mapper.cpp b/test/test_key_mapper/test_key_mapper.cpp index 99d178f..ccc346f 100644 --- a/test/test_key_mapper/test_key_mapper.cpp +++ b/test/test_key_mapper/test_key_mapper.cpp @@ -220,6 +220,42 @@ void test_shifted_arrow_keys_type_their_symbols_when_not_typing() { TEST_ASSERT_EQUAL(':', press(m, r)[0].ch); } +// Issue #69, Q196: Fn+h everywhere, ? when it wouldn't be typed. +void test_fn_h_is_help_in_both_modes() { + for (bool typing : {true, false}) { + KeyMapper m; + m.setTextEntry(typing); + auto ev = press(m, withFn({'h'})); + TEST_ASSERT_EQUAL(1, ev.size()); + TEST_ASSERT_EQUAL(static_cast(Key::Help), static_cast(ev[0].key)); + } +} + +void test_plain_h_still_types() { + for (bool typing : {true, false}) { + KeyMapper m; + m.setTextEntry(typing); + auto ev = press(m, chars({'h'})); + TEST_ASSERT_EQUAL(1, ev.size()); + TEST_ASSERT_EQUAL(static_cast(Key::Char), static_cast(ev[0].key)); + TEST_ASSERT_EQUAL('h', ev[0].ch); + } +} + +void test_question_mark_is_help_only_when_not_typing() { + KeyMapper m; + m.setTextEntry(false); + auto ev = press(m, chars({'?'})); + TEST_ASSERT_EQUAL(1, ev.size()); + TEST_ASSERT_EQUAL(static_cast(Key::Help), static_cast(ev[0].key)); + release(m); + m.setTextEntry(true); + ev = press(m, chars({'?'})); + TEST_ASSERT_EQUAL(1, ev.size()); + TEST_ASSERT_EQUAL(static_cast(Key::Char), static_cast(ev[0].key)); + TEST_ASSERT_EQUAL('?', ev[0].ch); +} + int main() { UNITY_BEGIN(); RUN_TEST(test_plain_character); @@ -241,5 +277,8 @@ int main() { RUN_TEST(test_fn_arrows_work_in_both_modes); RUN_TEST(test_other_characters_still_type_when_not_typing); RUN_TEST(test_shifted_arrow_keys_type_their_symbols_when_not_typing); + RUN_TEST(test_fn_h_is_help_in_both_modes); + RUN_TEST(test_plain_h_still_types); + RUN_TEST(test_question_mark_is_help_only_when_not_typing); return UNITY_END(); } -- 2.53.0