Compare commits

..
Author SHA1 Message Date
twislaandClaude Opus 5.5 7fe8b3d22a Help: Fn+h lists the keys of the screen you're on, and no screen names its keys any more (#69)
CI / build (pull_request) Successful in 7m22s
Site / build (pull_request) Successful in 14s
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-07 01:29:51 +02:00
twisla 1c6ae0e04f Merge pull request 'Devlog: "It was off" (the website, and one firmware with the Debug Console in it)' (#71) from devlog-console into main
Site / build (push) Successful in 9s
Reviewed-on: #71
2026-10-06 22:20:56 +00:00
62 changed files with 855 additions and 81 deletions
+4
View File
@@ -106,6 +106,10 @@ The regulatory band plan the device transmits under (here EU868). It sets the al
**Duty Cycle Budget**: **Duty Cycle Budget**:
The share of airtime the Region allows this device to transmit. When it's used up, outgoing traffic waits. 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**: **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. 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 _Avoid_: edit mode, insert mode
+5 -1
View File
@@ -8,6 +8,10 @@ A multi-app firmware for the **M5Stack Cardputer ADV** with the **Cap LoRa-1262*
- Decisions: [docs/adr/](docs/adr/) - Decisions: [docs/adr/](docs/adr/)
- Milestones: [docs/milestones/](docs/milestones/) - 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 ## 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. 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 | | Command | Effect |
|---|---| |---|---|
| `burst` | Publishes 5 Notifications at once | | `burst` | Publishes 5 Notifications at once |
| `key up\|down\|left\|right\|select\|back\|home`, or `key <char>` | Injects a key press | | `key up\|down\|left\|right\|select\|back\|home\|del\|tab\|space\|help`, or `key <char>` | 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) | | `sound on` / `sound off` | Toggles the Sound setting (beep + LED) |
| `short` / `normal` | Screen timeouts 5 s / 10 s, or 30 s / 60 s | | `short` / `normal` | Screen timeouts 5 s / 10 s, or 30 s / 60 s |
| `wifi add <ssid><TAB><password>` | Adds a Saved Network (so credentials stay out of the repo) | | `wifi add <ssid><TAB><password>` | Adds a Saved Network (so credentials stay out of the repo) |
+40
View File
@@ -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.
+9
View File
@@ -2,7 +2,10 @@
#include <cstdint> #include <cstdint>
#include <vector>
#include "key_event.h" #include "key_event.h"
#include "key_help.h"
namespace roro { 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. // True while the App is editing text: the arrow keys then type ; . , / and need Fn to move.
virtual bool textEntryActive() const { return false; } 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<KeyHelp>& 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). // Called every main-loop pass while in the foreground (e.g. to refresh live values).
virtual void update(uint32_t nowMs) { (void)nowMs; } virtual void update(uint32_t nowMs) { (void)nowMs; }
+16
View File
@@ -52,6 +52,21 @@ void AppManager::endModal() {
} }
void AppManager::handleKey(const KeyEvent& event) { 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<KeyHelp> 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_) { if (modal_) {
foreground_->onKey(event); foreground_->onKey(event);
return; return;
@@ -77,6 +92,7 @@ bool AppManager::takeRedraw() {
} }
void AppManager::switchTo(App& app) { 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; if (&app == foreground_) return;
foreground_->onExit(); foreground_->onExit();
foreground_ = &app; foreground_ = &app;
+4
View File
@@ -37,6 +37,9 @@ class AppManager {
App& foreground() const { return *foreground_; } App& foreground() const { return *foreground_; }
const char* foregroundTitle() const; // nullptr for the Launcher 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). // True once after the screen needs redrawing (App switch, or the App asked for it).
bool takeRedraw(); bool takeRedraw();
@@ -47,6 +50,7 @@ class AppManager {
App& launcher_; App& launcher_;
App* foreground_; App* foreground_;
std::vector<AppInfo> apps_; std::vector<AppInfo> apps_;
HelpModel help_;
bool redraw_ = true; bool redraw_ = true;
bool modal_ = false; bool modal_ = false;
}; };
+1
View File
@@ -16,6 +16,7 @@ enum class Key : uint8_t {
Home, Home,
Tab, Tab,
Delete, Delete,
Help, // Fn+h anywhere, or ? outside Text Entry: the keys of this screen (issue #69)
}; };
struct KeyEvent { struct KeyEvent {
+92
View File
@@ -0,0 +1,92 @@
#pragma once
#include <algorithm>
#include <string>
#include <vector>
#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<KeyHelp>& out, const char* enter = "open") {
out.push_back({"; .", "up, down"});
out.push_back({"Enter", enter});
}
inline void dialog(std::vector<KeyHelp>& out) {
out.push_back({", /", "the other answer"});
out.push_back({"Enter", "choose it"});
out.push_back({"`", "cancel"});
}
inline void textEntry(std::vector<KeyHelp>& 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<KeyHelp>& 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<KeyHelp> 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<int>(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<KeyHelp>& rows() const { return rows_; }
int top() const { return top_; }
int visibleRows() const { return visible_; }
private:
std::string title_;
std::vector<KeyHelp> rows_;
int top_ = 0;
int visible_;
bool open_ = false;
};
} // namespace roro
+14
View File
@@ -99,6 +99,20 @@ void KeyMapper::onChar(char c, const RawKeys& keys, std::vector<KeyEvent>& out)
return; return;
} }
break; 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: default:
if (keys.fn) return; // other Fn combos are unassigned if (keys.fn) return; // other Fn combos are unassigned
break; break;
+1 -1
View File
@@ -23,7 +23,7 @@ struct RawKeys {
// Turns keyboard state changes into logical KeyEvents: only newly pressed keys produce events; // 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 + ; . , / 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 -> é). // letter (opt ' e -> é).
class KeyMapper { class KeyMapper {
public: public:
+1
View File
@@ -44,6 +44,7 @@ const Definition kDefinitions[] = {
{"check_updates", Kind::Bool, 1, nullptr, 0, 1}, // on: it only looks, and says so (R1, Q165) {"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_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 {"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<size_t>(Setting::Count), static_assert(sizeof(kDefinitions) / sizeof(kDefinitions[0]) == static_cast<size_t>(Setting::Count),
"every Setting needs a definition"); "every Setting needs a definition");
+1
View File
@@ -33,6 +33,7 @@ enum class Setting : uint8_t {
CheckUpdates, // bool: look for a newer release on Gitea once a day (R1, Q165) 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) 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 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 Count
}; };
+2 -2
View File
@@ -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 <sentence without $ and checksum> gnss status | gnss restart | gnss track start|stop | gnss nmea on|off | gnss send <sentence without $ and checksum>
crash the last crash: firmware, reason, task, backtrace crash the last crash: firmware, reason, task, backtrace
coredump erase forget the core dump in flash coredump erase forget the core dump in flash
key <name|char> press a key: up down left right select back home del tab space, or one character key <name|char> press a key: up down left right select back home del tab space help, or one character
wifi status | wifi add <ssid><TAB><password> wifi status | wifi add <ssid><TAB><password>
wifi ip <ssid> dhcp | wifi ip <ssid> <address>/<prefix> [gateway] a Saved Network's IP setting wifi ip <ssid> dhcp | wifi ip <ssid> <address>/<prefix> [gateway] a Saved Network's IP setting
wifi dns <a> [b] | wifi dns always on|off | wifi ntp <a> [b] DNS and NTP servers wifi dns <a> [b] | wifi dns always on|off | wifi ntp <a> [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 | | Command | Effect |
|---|---| |---|---|
| `burst` | Publishes 5 Notifications at once | | `burst` | Publishes 5 Notifications at once |
| `key up\|down\|left\|right\|select\|back\|home`, or `key <char>` | Injects a key press | | `key up\|down\|left\|right\|select\|back\|home\|del\|tab\|space\|help`, or `key <char>` | 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) | | `sound on` / `sound off` | Toggles the Sound setting (beep + LED) |
| `short` / `normal` | Screen timeouts 5 s / 10 s, or 30 s / 60 s | | `short` / `normal` | Screen timeouts 5 s / 10 s, or 30 s / 60 s |
| `wifi add <ssid><TAB><password>` | Adds a Saved Network (so credentials stay out of the repo) | | `wifi add <ssid><TAB><password>` | Adds a Saved Network (so credentials stay out of the repo) |
+3 -1
View File
@@ -11,7 +11,7 @@ Everything the keyboard can do, a command can do, and everything on the screen c
## Keys ## 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 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. `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** (<kbd>Fn</kbd>+<kbd>h</kbd> 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 ## 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. **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.
+48
View File
@@ -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.
+4
View File
@@ -6,6 +6,10 @@ template = "guide-page.html"
toc = true toc = true
+++ +++
## Which keys work on this screen?
Press **<kbd>Fn</kbd> + <kbd>h</kbd>**, on any screen: it lists the keys that work there, then the ones that work everywhere. <kbd>?</kbd> 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? ## 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. **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.
+1 -1
View File
@@ -6,7 +6,7 @@ sort_by = "weight"
page_template = "guide-page.html" 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, <kbd>Fn</kbd> + <kbd>h</kbd> 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. **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.
+11 -2
View File
@@ -6,6 +6,14 @@ weight = 1
tag = "Start here" tag = "Start here"
+++ +++
## One key to remember
**<kbd>Fn</kbd> + <kbd>h</kbd>, 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 <kbd>?</kbd> 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 keys
The Cardputer's keyboard has no arrow keys and no Escape, so the firmware gives a few keys a second job: 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
| <kbd>Fn</kbd> + <kbd>`</kbd> | **Home**: back to the Launcher | | <kbd>Fn</kbd> + <kbd>`</kbd> | **Home**: back to the Launcher |
| <kbd>Fn</kbd> + <kbd>;</kbd> <kbd>.</kbd> <kbd>,</kbd> <kbd>/</kbd> | The arrows: up, down, left, right | | <kbd>Fn</kbd> + <kbd>;</kbd> <kbd>.</kbd> <kbd>,</kbd> <kbd>/</kbd> | The arrows: up, down, left, right |
| <kbd>;</kbd> <kbd>.</kbd> <kbd>,</kbd> <kbd>/</kbd> alone | The same arrows, as long as you are **not** typing text | | <kbd>;</kbd> <kbd>.</kbd> <kbd>,</kbd> <kbd>/</kbd> alone | The same arrows, as long as you are **not** typing text |
| <kbd>Fn</kbd> + <kbd>h</kbd>, or <kbd>?</kbd> when not typing | **Help:** the keys of the screen you are on |
| <kbd>Tab</kbd> | Switches view in an App that has more than one | | <kbd>Tab</kbd> | Switches view in an App that has more than one |
| <kbd>Del</kbd> | Deletes backwards when you type | | <kbd>Del</kbd> | Deletes backwards when you type |
| <kbd>opt</kbd> then an accent, then a letter | Types an accented letter: <kbd>opt</kbd> <kbd>'</kbd> <kbd>e</kbd> gives é | | <kbd>opt</kbd> then an accent, then a letter | Types an accented letter: <kbd>opt</kbd> <kbd>'</kbd> <kbd>e</kbd> gives é |
While you type text (a note, an IRC line, a setting), `;` `.` `,` `/` type their own characters and you need <kbd>Fn</kbd> 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 <kbd>Fn</kbd> for the arrows. The Status Bar shows `opt` while a compose is waiting for its letter.
## The Launcher ## The Launcher
@@ -50,7 +59,7 @@ News from a background service (an IRC mention, a Storage warning, an update tha
## The first start ## 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 <kbd>Fn</kbd> + <kbd>h</kbd>, twice, and it is the only part of the firmware that names keys on the screen:
1. **Long name**, up to 39 bytes. 1. **Long name**, up to 39 bytes.
2. **Short name**, up to 4 characters. 2. **Short name**, up to 4 characters.
+1 -1
View File
@@ -13,7 +13,7 @@ Storage shows what is on the SD card: each folder's entries with their size and
| Key | Does | | Key | Does |
|---|---| |---|---|
| <kbd>c</kbd> / <kbd>x</kbd> | Copies or cuts the selected file or folder; the footer shows what <kbd>v</kbd> would paste | | <kbd>c</kbd> / <kbd>x</kbd> | Copies or cuts the selected file or folder; the footer shows what is waiting to be pasted |
| <kbd>v</kbd> | 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 | | <kbd>v</kbd> | 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 |
| <kbd>r</kbd> | Renames | | <kbd>r</kbd> | Renames |
| <kbd>d</kbd> | Deletes, after saying what is inside: "Delete saved and its 42 files (1.2 MB)?" | | <kbd>d</kbd> | Deletes, after saying what is inside: "Delete saved and its 42 files (1.2 MB)?" |
+1 -1
View File
@@ -23,7 +23,7 @@ REPO = SITE.parent
OUT = SITE / "content" / "dev" OUT = SITE / "content" / "dev"
REPO_URL = re.search(r'repo\s*=\s*"([^"]+)"', (SITE / "config.toml").read_text()).group(1) 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. # 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. # They stay in the repository.
+7 -2
View File
@@ -74,6 +74,13 @@ bool DebugConsolePage::onKey(const KeyEvent& e) {
return true; return true;
} }
void DebugConsolePage::help(std::vector<KeyHelp>& 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) { void DebugConsolePage::draw(Canvas& c) {
const auto& area = theme::kContent; const auto& area = theme::kContent;
c.setTextDatum(top_left); 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}); widgets::lineEditor(c, editor_, {4, area.y + 22, area.w - 8, 0});
c.setTextColor(refusal_.empty() ? theme::kMuted : theme::kWarning); 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.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; return;
} }
+3
View File
@@ -5,6 +5,7 @@
#include "dialog_model.h" #include "dialog_model.h"
#include "key_event.h" #include "key_event.h"
#include "key_help.h"
#include "line_editor.h" #include "line_editor.h"
#include "list_model.h" #include "list_model.h"
#include "services/wifi_service.h" #include "services/wifi_service.h"
@@ -25,6 +26,8 @@ class DebugConsolePage {
bool onKey(const KeyEvent& e); // false: leave the page bool onKey(const KeyEvent& e); // false: leave the page
bool textEntryActive() const { return typing_; } bool textEntryActive() const { return typing_; }
void draw(Canvas& c); void draw(Canvas& c);
void help(std::vector<KeyHelp>& out) const;
const char* helpTitle() const { return typing_ ? "A token of your own" : "Debug Console"; }
private: private:
enum Row { kSwitch, kAddress, kNewToken, kTypeToken, kRows }; enum Row { kSwitch, kAddress, kNewToken, kTypeToken, kRows };
+10 -1
View File
@@ -25,6 +25,15 @@ void DemoApp::notify(const char* text, NotificationLevel level) {
bus_.publish(Event::withText(EventType::Notification, text, static_cast<int32_t>(level))); bus_.publish(Event::withText(EventType::Notification, text, static_cast<int32_t>(level)));
} }
void DemoApp::help(std::vector<KeyHelp>& 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) { bool DemoApp::onKey(const KeyEvent& e) {
requestRedraw(); requestRedraw();
if (dialog_) { if (dialog_) {
@@ -97,7 +106,7 @@ void DemoApp::draw(Canvas& c) {
} }
case Page::Editor: case Page::Editor:
c.setTextColor(theme::kMuted); 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}); widgets::lineEditor(c, editor_, {4, area.y + 22, area.w - 8, 0});
c.setTextColor(theme::kMuted); c.setTextColor(theme::kMuted);
c.drawString((std::to_string(editor_.text().size()) + "/39 bytes").c_str(), 4, area.y + 44); c.drawString((std::to_string(editor_.text().size()) + "/39 bytes").c_str(), 4, area.y + 44);
+1
View File
@@ -21,6 +21,7 @@ class DemoApp : public App {
bool onKey(const KeyEvent& e) override; bool onKey(const KeyEvent& e) override;
bool textEntryActive() const override { return page_ == Page::Editor && !dialog_; } bool textEntryActive() const override { return page_ == Page::Editor && !dialog_; }
void draw(Canvas& c) override; void draw(Canvas& c) override;
void help(std::vector<KeyHelp>& out) const override;
private: private:
enum class Page { Menu, Text, Editor }; enum class Page { Menu, Text, Editor };
+26 -7
View File
@@ -265,6 +265,30 @@ void FileViewer::openPacket(int index) {
mode_ = Mode::Packet; mode_ = Mode::Packet;
} }
void FileViewer::help(std::vector<KeyHelp>& 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) { bool FileViewer::onKey(const KeyEvent& e) {
if (confirm_) { if (confirm_) {
confirm_->onKey(e); confirm_->onKey(e);
@@ -380,7 +404,7 @@ void FileViewer::draw(Canvas& c) {
refresh(); refresh();
c.setTextDatum(top_left); c.setTextDatum(top_left);
theme::Rect body{area.x, area.y + 10, area.w, kRows * theme::kLineHeight}; 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; bool scanning = scan_ && !scan_->done;
switch (mode_) { switch (mode_) {
@@ -396,8 +420,6 @@ void FileViewer::draw(Canvas& c) {
c.fillRect(area.w - 2, barY, 2, barH, theme::kMuted); c.fillRect(area.w - 2, barY, 2, barH, theme::kMuted);
} }
right = formatBytes(size_); 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; break;
} }
case Mode::Gpx: { case Mode::Gpx: {
@@ -407,7 +429,6 @@ void FileViewer::draw(Canvas& c) {
else if (scan_) lines = scan_->gpx.lines(); else if (scan_) lines = scan_->gpx.lines();
widgets::textLines(c, lines, 0, body); widgets::textLines(c, lines, 0, body);
right = formatBytes(size_); right = formatBytes(size_);
keys = "Tab: the file as text";
break; break;
} }
case Mode::Pcap: { case Mode::Pcap: {
@@ -423,7 +444,6 @@ void FileViewer::draw(Canvas& c) {
return row < shown_.size() ? shown_[row] : std::string(); return row < shown_.size() ? shown_[row] : std::string();
}); });
right = std::to_string(scan_->packets.size()) + (scan_->morePackets ? "+ packets" : " packets"); right = std::to_string(scan_->packets.size()) + (scan_->morePackets ? "+ packets" : " packets");
keys = "Enter: the packet Tab: hex";
} }
break; break;
} }
@@ -447,7 +467,6 @@ void FileViewer::draw(Canvas& c) {
} }
widgets::textLines(c, lines, 0, body); widgets::textLines(c, lines, 0, body);
right = formatBytes(size_); right = formatBytes(size_);
keys = ok ? "Enter: install Tab: hex" : "Tab: hex";
if (confirm_) { if (confirm_) {
widgets::dialog(c, "Install update?", "The device restarts into " + scan_->version + " once it's written.", *confirm_); widgets::dialog(c, "Install update?", "The device restarts into " + scan_->version + " once it's written.", *confirm_);
return; return;
@@ -464,7 +483,7 @@ void FileViewer::draw(Canvas& c) {
c.drawString(right.c_str(), area.w - 3, area.y + 1); c.drawString(right.c_str(), area.w - 3, area.y + 1);
c.setTextDatum(top_left); c.setTextDatum(top_left);
if (!message_.empty()) c.setTextColor(theme::kWarning); 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 } // namespace roro
+2
View File
@@ -7,6 +7,7 @@
#include <vector> #include <vector>
#include "dialog_model.h" #include "dialog_model.h"
#include "key_help.h"
#include "key_event.h" #include "key_event.h"
#include "list_model.h" #include "list_model.h"
#include "services/storage_service.h" #include "services/storage_service.h"
@@ -27,6 +28,7 @@ class FileViewer {
void open(const std::string& path, uint32_t size); void open(const std::string& path, uint32_t size);
void close(); void close();
bool onKey(const KeyEvent& e); // false: leave the viewer bool onKey(const KeyEvent& e); // false: leave the viewer
void help(std::vector<KeyHelp>& out) const;
bool update(uint32_t nowMs); // true: draw again bool update(uint32_t nowMs); // true: draw again
void draw(Canvas& c); void draw(Canvas& c);
+26 -6
View File
@@ -63,9 +63,9 @@ std::string FirmwarePage::latestText(bool& warn) const {
release::Release r; release::Release r;
if (g.status() == GiteaReleases::Status::Failed && !g.latest(r)) { if (g.status() == GiteaReleases::Status::Failed && !g.latest(r)) {
warn = true; 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)"); return r.tag + (release::isNewer(r, update_.runningVersion()) ? " (new)" : " (current)");
} }
@@ -202,7 +202,7 @@ void FirmwarePage::drawRelease(Canvas& c) {
c.setFont(&fonts::small); c.setFont(&fonts::small);
c.setTextDatum(top_left); c.setTextDatum(top_left);
c.setTextColor(theme::kMuted); 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) { 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)" : ""); return r.tag + (r.tag == running ? " (running)" : release::versionNewer(r.tag, running) ? " (new)" : "");
}, },
[&](int i) { return olderReleases_[i].date(); }); [&](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<KeyHelp>& 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) { void FirmwarePage::drawMain(Canvas& c) {
+3
View File
@@ -7,6 +7,7 @@
#include "dialog_model.h" #include "dialog_model.h"
#include "key_event.h" #include "key_event.h"
#include "key_help.h"
#include "list_model.h" #include "list_model.h"
#include "release_info.h" #include "release_info.h"
#include "services/storage_service.h" #include "services/storage_service.h"
@@ -28,6 +29,8 @@ class FirmwarePage {
void enter(); void enter();
bool onKey(const KeyEvent& e); // false: leave the page bool onKey(const KeyEvent& e); // false: leave the page
void draw(Canvas& c); void draw(Canvas& c);
void help(std::vector<KeyHelp>& out) const;
const char* helpTitle() const;
private: private:
enum Row { kVersion, kStatus, kAddress, kLatest, kOlder, kSdHeader, kFixed }; enum Row { kVersion, kStatus, kAddress, kLatest, kOlder, kSdHeader, kFixed };
+30 -4
View File
@@ -171,7 +171,7 @@ void GeminiApp::update(uint32_t nowMs) {
else { else {
std::string mime = result.header.mimeType(); std::string mime = result.header.mimeType();
showMessage(url, "# Not a text page\nThis is " + mime + 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"); "/gemini/downloads.\n");
page_.header.meta = mime; // so s knows to download it page_.header.meta = mime; // so s knows to download it
} }
@@ -302,6 +302,32 @@ void GeminiApp::selectLink(int direction) {
requestRedraw(); requestRedraw();
} }
void GeminiApp::help(std::vector<KeyHelp>& 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) { bool GeminiApp::onKey(const KeyEvent& e) {
requestRedraw(); requestRedraw();
if (certDialog_) { if (certDialog_) {
@@ -388,7 +414,7 @@ bool GeminiApp::onKey(const KeyEvent& e) {
if (isGemini(page_.base())) gemini_.addBookmark(page_.base(), titleOf()); if (isGemini(page_.base())) gemini_.addBookmark(page_.base(), titleOf());
break; break;
case 's': // Q82, Q73 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 (!isGemini(page_.url)) status_ = "Only Gemini pages can be saved";
else if (page_.header.mimeType().rfind("text/", 0) == 0) gemini_.save(page_.url); else if (page_.header.mimeType().rfind("text/", 0) == 0) gemini_.save(page_.url);
else gemini_.download(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.fillRect(box.x - 2, box.y - 12, box.w + 4, box.h + 14, theme::kBackground);
c.setFont(&fonts::small); c.setFont(&fonts::small);
c.setTextColor(theme::kMuted); 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); widgets::lineEditor(c, address_, box);
} }
if (inputOpen_) { 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.drawString(displayText(inputPrompt_).substr(0, 39).c_str(), box.x, box.y - 22);
c.setFont(&fonts::small); c.setFont(&fonts::small);
c.setTextColor(theme::kMuted); 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_) { if (inputSensitive_) {
LineEditor masked(1024); LineEditor masked(1024);
masked.setText(std::string(input_.text().size(), '*')); masked.setText(std::string(input_.text().size(), '*'));
+2
View File
@@ -24,6 +24,8 @@ class GeminiApp : public App {
bool textEntryActive() const override { return addressOpen_ || inputOpen_; } bool textEntryActive() const override { return addressOpen_ || inputOpen_; }
void update(uint32_t nowMs) override; void update(uint32_t nowMs) override;
void draw(Canvas& c) override; void draw(Canvas& c) override;
void help(std::vector<KeyHelp>& out) const override;
const char* helpTitle() const override;
private: private:
struct Visit { struct Visit {
+7 -10
View File
@@ -80,11 +80,11 @@ void GnssApp::draw(Canvas& c) {
return; return;
} }
sky_ ? drawSky(c) : drawPosition(c); sky_ ? drawSky(c) : drawPosition(c);
c.setFont(&fonts::small); }
c.setTextColor(theme::kMuted);
c.setTextDatum(top_right); void GnssApp::help(std::vector<KeyHelp>& out) const {
c.drawString(sky_ ? "Tab: position" : "Tab: sky", area.w - 3, area.y + area.h - 9); out.push_back({"Tab", sky_ ? "the position" : "the sky"});
c.setTextDatum(top_left); out.push_back({"r", gnss_.tracking() ? "stop the Track" : "record a Track"});
} }
void GnssApp::drawPosition(Canvas& c) { void GnssApp::drawPosition(Canvas& c) {
@@ -139,15 +139,12 @@ void GnssApp::drawPosition(Canvas& c) {
c.setFont(&fonts::small); c.setFont(&fonts::small);
if (gnss_.tracking()) { if (gnss_.tracking()) {
c.setTextColor(theme::kWarning); 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()); gnss_.trackPoints() == 1 ? "" : "s", duration(now - gnss_.trackStartMs()).c_str());
} else if (!refusal_.empty() && now - refusalMs_ < 4000) { } else if (!refusal_.empty() && now - refusalMs_ < 4000) {
c.setTextColor(theme::kWarning); c.setTextColor(theme::kWarning);
std::snprintf(line, sizeof line, "%s", refusal_.c_str()); std::snprintf(line, sizeof line, "%s", refusal_.c_str());
} else { } else line[0] = 0;
c.setTextColor(theme::kMuted);
std::snprintf(line, sizeof line, "r: record a Track");
}
c.drawString(line, 4, area.y + area.h - 9); c.drawString(line, 4, area.y + area.h - 9);
} }
+1
View File
@@ -17,6 +17,7 @@ class GnssApp : public App {
bool onKey(const KeyEvent& e) override; bool onKey(const KeyEvent& e) override;
void update(uint32_t nowMs) override; void update(uint32_t nowMs) override;
void draw(Canvas& c) override; void draw(Canvas& c) override;
void help(std::vector<KeyHelp>& out) const override;
private: private:
void drawPosition(Canvas& c); void drawPosition(Canvas& c);
+31 -2
View File
@@ -299,6 +299,36 @@ void IrcApp::drawChat(Canvas& c) {
widgets::lineEditor(c, input_, {2, area.y + area.h - inputH, area.w - 4, 0}); widgets::lineEditor(c, input_, {2, area.y + area.h - inputH, area.w - 4, 0});
} }
void IrcApp::help(std::vector<KeyHelp>& 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) { void IrcApp::drawSettings(Canvas& c) {
const auto& area = theme::kContent; const auto& area = theme::kContent;
if (editing_) { if (editing_) {
@@ -306,8 +336,7 @@ void IrcApp::drawSettings(Canvas& c) {
c.setTextColor(theme::kMuted); c.setTextColor(theme::kMuted);
c.drawString(fieldLabel(fields_.selected()).c_str(), 4, area.y + 4); c.drawString(fieldLabel(fields_.selected()).c_str(), 4, area.y + 4);
widgets::lineEditor(c, fieldEditor_, {4, area.y + 22, area.w - 8, 0}); 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, if (fields_.selected() == kAutojoin) c.drawString("e.g. #roro, #private key", 4, area.y + 44);
area.y + 44);
return; return;
} }
widgets::list( widgets::list(
+2
View File
@@ -24,6 +24,8 @@ class IrcApp : public App {
void update(uint32_t nowMs) override; void update(uint32_t nowMs) override;
bool textEntryActive() const override { return page_ == Page::Chat || editing_; } bool textEntryActive() const override { return page_ == Page::Chat || editing_; }
void draw(Canvas& c) override; void draw(Canvas& c) override;
void help(std::vector<KeyHelp>& out) const override;
const char* helpTitle() const override;
private: private:
enum class Page { Chat, Settings }; enum class Page { Chat, Settings };
+1
View File
@@ -14,6 +14,7 @@ class LauncherApp : public App {
void onEnter() override; void onEnter() override;
bool onKey(const KeyEvent& e) override; bool onKey(const KeyEvent& e) override;
void draw(Canvas& c) override; void draw(Canvas& c) override;
void help(std::vector<KeyHelp>& out) const override { help::list(out, "open the App"); }
private: private:
AppManager* manager_ = nullptr; AppManager* manager_ = nullptr;
+29 -4
View File
@@ -165,6 +165,32 @@ void LoraScannerApp::update(uint32_t nowMs) {
requestRedraw(); requestRedraw();
} }
void LoraScannerApp::help(std::vector<KeyHelp>& 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) { void LoraScannerApp::draw(Canvas& c) {
lastDrawMs_ = millis(); lastDrawMs_ = millis();
c.setTextDatum(top_left); c.setTextDatum(top_left);
@@ -234,8 +260,7 @@ void LoraScannerApp::drawPackets(Canvas& c) {
c.setFont(&fonts::small); c.setFont(&fonts::small);
bool showMessage = !message_.empty() && millis() - messageMs_ < kMessageMs; bool showMessage = !message_.empty() && millis() - messageMs_ < kMessageMs;
c.setTextColor(showMessage ? theme::kWarning : theme::kMuted); c.setTextColor(showMessage ? theme::kWarning : theme::kMuted);
std::string keys = std::string("Enter: details p: preset Tab: sweep c: ") + (capture_.capturing() ? "stop" : "capture"); if (showMessage) c.drawString(message_.c_str(), 4, area.y + area.h - 9);
c.drawString(showMessage ? message_.c_str() : keys.c_str(), 4, area.y + area.h - 9);
} }
void LoraScannerApp::drawDetails(Canvas& c) { 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.drawString(line, x0 + f.steps * cell, wfTop + kWaterfallRows + 1);
c.setTextDatum(top_left); c.setTextDatum(top_left);
if (st.peaks.empty()) 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 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); st.peaks[0].dbm);
c.setTextColor(theme::kMuted); c.setTextColor(theme::kMuted);
c.drawString(line, 4, y); c.drawString(line, 4, y);
+2
View File
@@ -26,6 +26,8 @@ class LoraScannerApp : public App {
bool onKey(const KeyEvent& e) override; bool onKey(const KeyEvent& e) override;
void update(uint32_t nowMs) override; void update(uint32_t nowMs) override;
void draw(Canvas& c) override; void draw(Canvas& c) override;
void help(std::vector<KeyHelp>& out) const override;
const char* helpTitle() const override;
private: private:
enum class View { Packets, Details, Presets, Sweep }; enum class View { Packets, Details, Presets, Sweep };
+5
View File
@@ -35,6 +35,11 @@ CleanupPlan MaintenancePage::planFor(int age) const {
return CleanupPlan::make(listing_[categories_.selected()], cutoff, today); return CleanupPlan::make(listing_[categories_.selected()], cutoff, today);
} }
void MaintenancePage::help(std::vector<KeyHelp>& 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) { bool MaintenancePage::onKey(const KeyEvent& e) {
if (dialog_) { if (dialog_) {
dialog_->onKey(e); dialog_->onKey(e);
+2
View File
@@ -5,6 +5,7 @@
#include "cleanup_plan.h" #include "cleanup_plan.h"
#include "dialog_model.h" #include "dialog_model.h"
#include "key_help.h"
#include "event_bus.h" #include "event_bus.h"
#include "key_event.h" #include "key_event.h"
#include "list_model.h" #include "list_model.h"
@@ -24,6 +25,7 @@ class MaintenancePage {
void enter(); void enter();
bool onKey(const KeyEvent& e); // false: leave the page bool onKey(const KeyEvent& e); // false: leave the page
void draw(Canvas& c); void draw(Canvas& c);
void help(std::vector<KeyHelp>& out) const;
private: private:
enum class View { Main, Categories, Ages }; enum class View { Main, Categories, Ages };
+13 -1
View File
@@ -182,6 +182,18 @@ bool NoteEditor::save() {
return true; return true;
} }
void NoteEditor::help(std::vector<KeyHelp>& 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) { bool NoteEditor::onKey(const KeyEvent& e) {
if (!text_) return false; if (!text_) return false;
redraw_ = true; redraw_ = true;
@@ -290,7 +302,7 @@ void NoteEditor::draw(Canvas& c) {
c.setFont(&fonts::small); c.setFont(&fonts::small);
bool showMessage = !message_.empty() && millis() - messageMs_ < kMessageMs; bool showMessage = !message_.empty() && millis() - messageMs_ < kMessageMs;
c.setTextColor(showMessage ? theme::kWarning : theme::kMuted); 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) 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 " + widgets::dialog(c, "Unsaved copy", "A save of this note was cut short. Its copy has " + formatBytes(recovered_.size()) + ", the note " +
+2
View File
@@ -5,6 +5,7 @@
#include <vector> #include <vector>
#include "dialog_model.h" #include "dialog_model.h"
#include "key_help.h"
#include "key_event.h" #include "key_event.h"
#include "note_text.h" #include "note_text.h"
#include "services/clock_service.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 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 onKey(const KeyEvent& e); // false: done, and saved
void help(std::vector<KeyHelp>& out) const;
bool update(uint32_t nowMs); // true: draw again bool update(uint32_t nowMs); // true: draw again
void draw(Canvas& c); void draw(Canvas& c);
+18 -2
View File
@@ -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 return files::fitName(list_.name(notes_[row]), kTitleChars); // an empty note, or not a text
} }
void NotesApp::help(std::vector<KeyHelp>& 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) { bool NotesApp::onKey(const KeyEvent& e) {
requestRedraw(); requestRedraw();
if (view_ == View::NoMemory) return false; if (view_ == View::NoMemory) return false;
@@ -315,7 +332,6 @@ void NotesApp::draw(Canvas& c) {
} else if (notes_.empty()) { } else if (notes_.empty()) {
c.setTextColor(theme::kMuted); c.setTextColor(theme::kMuted);
c.drawString("No notes yet.", 4, rows.y + 4); c.drawString("No notes yet.", 4, rows.y + 4);
c.drawString("n starts one.", 4, rows.y + 4 + theme::kLineHeight);
} else { } else {
readTitles(); readTitles();
widgets::list( widgets::list(
@@ -329,7 +345,7 @@ void NotesApp::draw(Canvas& c) {
c.setFont(&fonts::small); c.setFont(&fonts::small);
bool showMessage = !message_.empty() && millis() - messageMs_ < kMessageMs; bool showMessage = !message_.empty() && millis() - messageMs_ < kMessageMs;
c.setTextColor(showMessage ? theme::kWarning : theme::kMuted); 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) { if (view_ == View::Name) {
theme::Rect box{16, 44, theme::kWidth - 32, 48}; theme::Rect box{16, 44, theme::kWidth - 32, 48};
+2
View File
@@ -28,6 +28,8 @@ class NotesApp : public App {
bool textEntryActive() const override { return view_ == View::Edit || view_ == View::Name; } bool textEntryActive() const override { return view_ == View::Edit || view_ == View::Name; }
void update(uint32_t nowMs) override; void update(uint32_t nowMs) override;
void draw(Canvas& c) override; void draw(Canvas& c) override;
void help(std::vector<KeyHelp>& out) const override;
const char* helpTitle() const override;
private: private:
enum class View { List, Edit, Name, NoMemory }; enum class View { List, Edit, Name, NoMemory };
+27 -4
View File
@@ -129,6 +129,31 @@ bool SettingsApp::onAboutKey(const KeyEvent& e) {
return true; return true;
} }
void SettingsApp::help(std::vector<KeyHelp>& 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) { void SettingsApp::update(uint32_t nowMs) {
// Live values on About and Firmware. // Live values on About and Firmware.
bool live = page_ == Page::About || page_ == Page::Firmware || page_ == Page::Debug || 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.setTextColor(theme::kMuted);
c.drawString(menu_.label(editingRow_).c_str(), 4, area.y + 4); c.drawString(menu_.label(editingRow_).c_str(), 4, area.y + 4);
widgets::lineEditor(c, editor_, {4, area.y + 22, area.w - 8, 0}); widgets::lineEditor(c, editor_, {4, area.y + 22, area.w - 8, 0});
c.drawString(("Enter: save `: cancel " + std::to_string(editor_.text().size()) + "/" + c.drawString((std::to_string(editor_.text().size()) + " of " + std::to_string(editor_.maxBytes()) + " bytes").c_str(), 4,
std::to_string(editor_.maxBytes()) + " bytes") area.y + 44);
.c_str(),
4, area.y + 44);
break; break;
case Page::Choice: { case Page::Choice: {
auto options = menu_.choices(editingRow_); auto options = menu_.choices(editingRow_);
+2
View File
@@ -50,6 +50,8 @@ class SettingsApp : public App {
(page_ == Page::Debug && debugPage_.textEntryActive()); (page_ == Page::Debug && debugPage_.textEntryActive());
} }
void draw(Canvas& c) override; void draw(Canvas& c) override;
void help(std::vector<KeyHelp>& out) const override;
const char* helpTitle() const override;
private: private:
enum class Page { Menu, Text, Choice, About, Wifi, Firmware, Debug }; enum class Page { Menu, Text, Choice, About, Wifi, Firmware, Debug };
+23 -2
View File
@@ -44,6 +44,24 @@ bool SetupApp::onKey(const KeyEvent& e) {
return true; return true;
} }
void SetupApp::help(std::vector<KeyHelp>& 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) { void SetupApp::draw(Canvas& c) {
const auto& area = theme::kContent; const auto& area = theme::kContent;
auto heading = [&](const char* text) { auto heading = [&](const char* text) {
@@ -63,6 +81,7 @@ void SetupApp::draw(Canvas& c) {
heading("Welcome to roro9stack"); heading("Welcome to roro9stack");
note("Let's set up this device: your names on the mesh, your radio region and your timezone.", note("Let's set up this device: your names on the mesh, your radio region and your timezone.",
area.y + 22, theme::kText); 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); note("Enter: continue", area.y + area.h - 14);
break; break;
case Step::LongName: case Step::LongName:
@@ -88,8 +107,10 @@ void SetupApp::draw(Canvas& c) {
[this](int i) { return wizard_.choiceLabel(i); }); [this](int i) { return wizard_.choiceLabel(i); });
break; break;
case Step::Done: case Step::Done:
heading("All set"); heading("All set. One key to remember:");
note("You can change all of this later in Settings.", area.y + 22, theme::kText); 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); note("Enter: start `: go back", area.y + area.h - 14);
break; break;
} }
+2
View File
@@ -17,6 +17,8 @@ class SetupApp : public App {
return wizard_.step() == SetupWizard::Step::LongName || wizard_.step() == SetupWizard::Step::ShortName; return wizard_.step() == SetupWizard::Step::LongName || wizard_.step() == SetupWizard::Step::ShortName;
} }
void draw(Canvas& c) override; void draw(Canvas& c) override;
void help(std::vector<KeyHelp>& out) const override;
const char* helpTitle() const override { return "Setup"; }
private: private:
AppManager& apps_; AppManager& apps_;
+45 -9
View File
@@ -199,7 +199,7 @@ void StorageApp::onFinished(const FileOps::Status& s) {
} }
void StorageApp::paste(bool replace) { 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; std::string name = baseName(clip_.path), why;
if (cut_) { if (cut_) {
why = ops_.move(clip_.path, clip_.folder, joinPath(cwd_, name), replace); why = ops_.move(clip_.path, clip_.folder, joinPath(cwd_, name), replace);
@@ -273,6 +273,45 @@ void StorageApp::showDetails(const Item& item) {
view_ = View::Details; view_ = View::Details;
} }
void StorageApp::help(std::vector<KeyHelp>& 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) { bool StorageApp::onKey(const KeyEvent& e) {
requestRedraw(); requestRedraw();
if (view_ == View::NoMemory) return false; if (view_ == View::NoMemory) return false;
@@ -434,7 +473,7 @@ bool StorageApp::onBrowseKey(const KeyEvent& e) {
if (ch == 'c') { if (ch == 'c') {
clip_ = item; clip_ = item;
cut_ = false; cut_ = false;
say("v pastes a copy of " + name); say("Copied " + name + ": paste it where you like");
return true; return true;
} }
// Cut, rename and delete change the original: the rules of Q130 first. // 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') { if (ch == 'x') {
clip_ = item; clip_ = item;
cut_ = true; cut_ = true;
say("v moves " + name + " here"); say("Cut " + name + ": paste it where it should go");
} else if (ch == 'r') { } else if (ch == 'r') {
target_ = item; target_ = item;
renaming_ = true; renaming_ = true;
@@ -542,7 +581,6 @@ void StorageApp::drawBrowse(Canvas& c) {
} else if (rows_.count() == 0) { } else if (rows_.count() == 0) {
c.setTextColor(theme::kMuted); c.setTextColor(theme::kMuted);
c.drawString("Nothing in this folder.", 4, rows.y + 4); 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 { } else {
int entries = static_cast<int>(list_.count()); int entries = static_cast<int>(list_.count());
auto value = [&](int i) { auto value = [&](int i) {
@@ -558,13 +596,13 @@ void StorageApp::drawBrowse(Canvas& c) {
value); 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); c.setFont(&fonts::small);
bool showMessage = !message_.empty() && millis() - messageMs_ < kMessageMs; bool showMessage = !message_.empty() && millis() - messageMs_ < kMessageMs;
c.setTextColor(showMessage ? theme::kWarning : theme::kMuted); 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) + "..."; 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); 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); line = formatBytes(s.doneBytes) + " of " + formatBytes(s.totalBytes);
} }
c.drawString(line.c_str(), box.x + 6, box.y + 31); 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 } // namespace roro
+2
View File
@@ -34,6 +34,8 @@ class StorageApp : public App {
bool textEntryActive() const override { return view_ == View::Name || view_ == View::Editor; } bool textEntryActive() const override { return view_ == View::Name || view_ == View::Editor; }
void update(uint32_t nowMs) override; void update(uint32_t nowMs) override;
void draw(Canvas& c) override; void draw(Canvas& c) override;
void help(std::vector<KeyHelp>& out) const override;
const char* helpTitle() const override;
private: private:
enum class View { Browse, Details, Name, Viewer, Editor, Maintenance, NoMemory }; enum class View { Browse, Details, Name, Viewer, Editor, Maintenance, NoMemory };
+23 -6
View File
@@ -73,6 +73,26 @@ void SystemApp::onExit() { // nothing is kept while it's closed (Q120)
load_[0] = load_[1] = -1; load_[0] = load_[1] = -1;
} }
void SystemApp::help(std::vector<KeyHelp>& 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) { bool SystemApp::onKey(const KeyEvent& e) {
if (e.key == Key::Tab) { if (e.key == Key::Tab) {
int n = static_cast<int>(View::Count), v = static_cast<int>(view_); int n = static_cast<int>(View::Count), v = static_cast<int>(view_);
@@ -219,7 +239,6 @@ void SystemApp::drawOverview(Canvas& c) {
} }
} }
} }
footer(c, "Tab: tasks, memory, network, system");
} }
void SystemApp::drawTasks(Canvas& c) { 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.drawString(std::to_string(r.stackFree).c_str(), area.w - 4, y);
} }
c.setTextDatum(top_left); c.setTextDatum(top_left);
std::string keys = std::string("s: by ") + (sort_ == TaskSort::Share ? "stack" : sort_ == TaskSort::Stack ? "name" : "cpu") + std::string keys = std::to_string(rows_.size()) + " tasks, by " + (sort_ == TaskSort::Share ? "cpu" : sort_ == TaskSort::Stack ? "stack" : "name");
" " + std::to_string(rows_.size()) + " tasks Tab: memory";
footer(c, keys.c_str()); footer(c, keys.c_str());
} }
@@ -288,7 +306,7 @@ void SystemApp::drawMemory(Canvas& c) {
int x0 = gx + 1 + static_cast<int>((i - 1) * (gw - 2) / kHistory), x1 = gx + 1 + static_cast<int>(i * (gw - 2) / kHistory); int x0 = gx + 1 + static_cast<int>((i - 1) * (gw - 2) / kHistory), x1 = gx + 1 + static_cast<int>(i * (gw - 2) / kHistory);
c.drawLine(x0, yOf(heapHistory_.at(i - 1)), x1, yOf(heapHistory_.at(i)), theme::kAccent); 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) { void SystemApp::drawNetwork(Canvas& c) {
@@ -331,7 +349,7 @@ void SystemApp::drawNetwork(Canvas& c) {
y += theme::kLineHeight; y += theme::kLineHeight;
} }
c.setTextDatum(top_left); c.setTextDatum(top_left);
footer(c, "bytes since boot Tab: system"); footer(c, "bytes since boot");
} }
void SystemApp::drawSystem(Canvas& c) { void SystemApp::drawSystem(Canvas& c) {
@@ -355,7 +373,6 @@ void SystemApp::drawSystem(Canvas& c) {
wrappedLines_ = static_cast<int>(wrapped.size()); wrappedLines_ = static_cast<int>(wrapped.size());
systemTop_ = std::clamp(systemTop_, 0, std::max(0, wrappedLines_ - 8)); 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}); widgets::textLines(c, wrapped, systemTop_, {area.x + 2, area.y + 2, area.w - 2, area.h - 12});
footer(c, "Tab: overview");
} }
} // namespace roro } // namespace roro
+2
View File
@@ -28,6 +28,8 @@ class SystemApp : public App {
bool onKey(const KeyEvent& e) override; bool onKey(const KeyEvent& e) override;
void update(uint32_t nowMs) override; void update(uint32_t nowMs) override;
void draw(Canvas& c) override; void draw(Canvas& c) override;
void help(std::vector<KeyHelp>& out) const override;
const char* helpTitle() const override;
private: private:
enum class View : uint8_t { Overview, Tasks, Memory, Network, System, Count }; enum class View : uint8_t { Overview, Tasks, Memory, Network, System, Count };
+39 -5
View File
@@ -413,7 +413,6 @@ void WifiSettingsPage::draw(Canvas& c) {
c.setTextColor(theme::kMuted); c.setTextColor(theme::kMuted);
c.drawString(editTitle_.c_str(), 4, area.y + 4); c.drawString(editTitle_.c_str(), 4, area.y + 4);
widgets::lineEditor(c, editor_, {4, area.y + 22, area.w - 8, 0}); widgets::lineEditor(c, editor_, {4, area.y + 22, area.w - 8, 0});
c.drawString("Enter: save `: cancel", 4, area.y + 44);
break; break;
} }
@@ -427,7 +426,7 @@ void WifiSettingsPage::draw(Canvas& c) {
if (!n.ssid.empty()) found.push_back(n); if (!n.ssid.empty()) found.push_back(n);
if (scan_.count() != static_cast<int>(found.size())) scan_.setCount(static_cast<int>(found.size())); if (scan_.count() != static_cast<int>(found.size())) scan_.setCount(static_cast<int>(found.size()));
if (found.empty()) { if (found.empty()) {
widgets::textLines(c, {"No networks found.", "", "`: back"}, 0, area); widgets::textLines(c, {"No networks found."}, 0, area);
break; break;
} }
widgets::list( widgets::list(
@@ -443,20 +442,55 @@ void WifiSettingsPage::draw(Canvas& c) {
std::string title = view_ == View::Ssid ? "Hidden network name" : "Password for " + newSsid_; std::string title = view_ == View::Ssid ? "Hidden network name" : "Password for " + newSsid_;
c.drawString(title.c_str(), 4, area.y + 4); c.drawString(title.c_str(), 4, area.y + 4);
widgets::lineEditor(c, editor_, {4, area.y + 22, area.w - 8, 0}); 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, if (view_ == View::Password) c.drawString("Leave it empty for an open network.", 4, area.y + 44);
area.y + 44);
break; break;
} }
} }
} }
void WifiSettingsPage::help(std::vector<KeyHelp>& 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). // What the device is using and where each part came from (Q113).
void WifiSettingsPage::drawDetails(Canvas& c) { void WifiSettingsPage::drawDetails(Canvas& c) {
const auto& area = theme::kContent; const auto& area = theme::kContent;
WifiService::Connection n = wifi_.connection(); WifiService::Connection n = wifi_.connection();
std::vector<std::string> lines; std::vector<std::string> lines;
if (!n.connected) { if (!n.connected) {
lines = {statusText(), "", "`: back"}; lines = {statusText()};
} else { } else {
lines.push_back(wifi_.ssid() + ", " + std::to_string(wifi_.rssi()) + " dBm"); 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)")); lines.push_back("Address " + n.address + "/" + std::to_string(n.prefix) + (n.fixed ? " (fixed)" : " (DHCP)"));
+3
View File
@@ -6,6 +6,7 @@
#include "dialog_model.h" #include "dialog_model.h"
#include "event_bus.h" #include "event_bus.h"
#include "key_event.h" #include "key_event.h"
#include "key_help.h"
#include "line_editor.h" #include "line_editor.h"
#include "list_model.h" #include "list_model.h"
#include "saved_networks.h" #include "saved_networks.h"
@@ -30,6 +31,8 @@ class WifiSettingsPage {
// Redraw periodically: the status, a scan, the connection details. // Redraw periodically: the status, a scan, the connection details.
bool live() const { return view_ == View::Main || view_ == View::Scan || view_ == View::Details; } bool live() const { return view_ == View::Main || view_ == View::Scan || view_ == View::Details; }
void draw(Canvas& c); void draw(Canvas& c);
void help(std::vector<KeyHelp>& out) const;
const char* helpTitle() const;
private: private:
enum class View { Main, Scan, Ssid, Password, Details, Servers, Network, Edit }; enum class View { Main, Scan, Ssid, Password, Details, Servers, Network, Edit };
+27 -2
View File
@@ -153,6 +153,31 @@ void WifiToolsApp::draw(Canvas& c) {
} }
} }
void WifiToolsApp::help(std::vector<KeyHelp>& 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) { void WifiToolsApp::drawNetworks(Canvas& c) {
const auto& area = theme::kContent; const auto& area = theme::kContent;
const auto& scan = wifi_.listScan(); const auto& scan = wifi_.listScan();
@@ -178,7 +203,7 @@ void WifiToolsApp::drawNetworks(Canvas& c) {
widgets::textLines(c, widgets::textLines(c,
{wifi_.listScanDone() ? (any ? "Nothing matches the filter." : "No networks found yet.") {wifi_.listScanDone() ? (any ? "Nothing matches the filter." : "No networks found yet.")
: "Scanning...", : "Scanning...",
"", "s sort o open h hide-hidden w strong l log"}, },
0, list); 0, list);
return; return;
} }
@@ -233,7 +258,7 @@ void WifiToolsApp::drawTracker(Canvas& c) {
c.drawString(targetName_.c_str(), 4, area.y + 2); c.drawString(targetName_.c_str(), 4, area.y + 2);
c.setFont(&fonts::small); c.setFont(&fonts::small);
c.setTextColor(theme::kMuted); 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.drawString(sub.c_str(), 4, area.y + 17);
c.setFont(&fonts::bold); c.setFont(&fonts::bold);
+2
View File
@@ -27,6 +27,8 @@ class WifiToolsApp : public App {
bool onKey(const KeyEvent& e) override; bool onKey(const KeyEvent& e) override;
void update(uint32_t nowMs) override; void update(uint32_t nowMs) override;
void draw(Canvas& c) override; void draw(Canvas& c) override;
void help(std::vector<KeyHelp>& out) const override;
const char* helpTitle() const override;
private: private:
enum class View { Menu, Networks, Channels, Tracker }; enum class View { Menu, Networks, Channels, Tracker };
+9 -2
View File
@@ -225,6 +225,13 @@ void setup() {
services.startAll(millis()); services.startAll(millis());
apps->begin(); apps->begin();
if (!settings.getBool(Setting::SetupDone)) apps->openModal("setup"); 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<int32_t>(NotificationLevel::Info)));
settings.setBool(Setting::HelpTold, true);
}
console.printf("%s %s ready, free heap %u, last start: %s\n", kProductName, versionString(), ESP.getFreeHeap(), console.printf("%s %s ready, free heap %u, last start: %s\n", kProductName, versionString(), ESP.getFreeHeap(),
system_info::resetReason()); system_info::resetReason());
if (system_info::resetWasCrash()) { 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 <sentence without $ and checksum>\n" "gnss status | gnss restart | gnss track start|stop | gnss nmea on|off | gnss send <sentence without $ and checksum>\n"
"crash the last crash: firmware, reason, task, backtrace\n" "crash the last crash: firmware, reason, task, backtrace\n"
"coredump erase forget the core dump in flash\n" "coredump erase forget the core dump in flash\n"
"key <name|char> press a key: up down left right select back home del tab space, or one character\n" "key <name|char> press a key: up down left right select back home del tab space help, or one character\n"
"wifi status | wifi add <ssid><TAB><password>\n" "wifi status | wifi add <ssid><TAB><password>\n"
"wifi ip <ssid> dhcp | wifi ip <ssid> <address>/<prefix> [gateway] a Saved Network's IP setting\n" "wifi ip <ssid> dhcp | wifi ip <ssid> <address>/<prefix> [gateway] a Saved Network's IP setting\n"
"wifi dns <a> [b] | wifi dns always on|off | wifi ntp <a> [b] DNS and NTP servers\n" "wifi dns <a> [b] | wifi dns always on|off | wifi ntp <a> [b] DNS and NTP servers\n"
@@ -842,7 +849,7 @@ static void runCommand(String line, bool fromSerial = false) {
String k = line.substring(4); String k = line.substring(4);
Key key = k == "up" ? Key::Up : k == "down" ? Key::Down : k == "left" ? Key::Left 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 == "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(' ') KeyEvent ev = k == "space" ? KeyEvent::character(' ')
: k.length() == 1 && k[0] > ' ' ? KeyEvent::character((unsigned char)k[0]) : k.length() == 1 && k[0] > ' ' ? KeyEvent::character((unsigned char)k[0])
: KeyEvent::of(key); : KeyEvent::of(key);
+1 -2
View File
@@ -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. // about:start (Q78, Q84): bookmarks, then Saved Pages by capsule, newest first, then defaults.
void GeminiService::composeStart(GeminiPage& page) { void GeminiService::composeStart(GeminiPage& page) {
std::string doc = "# Gemini\nTab picks a link, Enter follows it, Back returns, g types an address. " std::string doc = "# Gemini\nYour bookmarks and Saved Pages, then a few places to start.\n";
"On a page: b bookmarks it, s saves it to read offline, S with the pages it links to.\n";
std::string bookmarks; std::string bookmarks;
std::vector<SavedEntry> saved; std::vector<SavedEntry> saved;
bool card = storage_.runAndWait([&]() { bool card = storage_.runAndWait([&]() {
+1
View File
@@ -17,6 +17,7 @@ void Screen::render(AppManager& apps, const StatusInfo& status, const Toast* toa
const auto& area = theme::kContent; const auto& area = theme::kContent;
canvas_.setClipRect(area.x, area.y, area.w, area.h); canvas_.setClipRect(area.x, area.y, area.w, area.h);
apps.foreground().draw(canvas_); apps.foreground().draw(canvas_);
if (apps.help().isOpen()) widgets::help(canvas_, apps.help()); // over the App, under the Status Bar
canvas_.clearClipRect(); canvas_.clearClipRect();
widgets::statusBar(canvas_, status); widgets::statusBar(canvas_, status);
if (toast) widgets::toast(canvas_, *toast); if (toast) widgets::toast(canvas_, *toast);
+36
View File
@@ -175,4 +175,40 @@ void toast(Canvas& c, const Toast& t) {
c.clearClipRect(); 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<int>(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 } // namespace roro::widgets
+4
View File
@@ -6,6 +6,7 @@
#include "canvas.h" #include "canvas.h"
#include "dialog_model.h" #include "dialog_model.h"
#include "key_help.h"
#include "line_editor.h" #include "line_editor.h"
#include "list_model.h" #include "list_model.h"
#include "text_wrap.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); 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); void toast(Canvas& c, const Toast& t);
} // namespace widgets } // namespace widgets
@@ -20,6 +20,12 @@ struct FakeApp : App {
} }
void update(uint32_t nowMs) override { lastUpdate = nowMs; } void update(uint32_t nowMs) override { lastUpdate = nowMs; }
void draw(Canvas&) override {} void draw(Canvas&) override {}
void help(std::vector<KeyHelp>& 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; uint32_t lastUpdate = 0;
std::vector<KeyEvent> keys; std::vector<KeyEvent> keys;
bool handles = false; 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()); 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<KeyHelp> 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() { int main() {
UNITY_BEGIN(); UNITY_BEGIN();
RUN_TEST(test_launcher_is_in_foreground_after_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_modal_app_keeps_home_and_back);
RUN_TEST(test_ending_the_modal_returns_to_launcher_and_unlocks_home); 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_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(); return UNITY_END();
} }
+39
View File
@@ -220,6 +220,42 @@ void test_shifted_arrow_keys_type_their_symbols_when_not_typing() {
TEST_ASSERT_EQUAL(':', press(m, r)[0].ch); 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<int>(Key::Help), static_cast<int>(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<int>(Key::Char), static_cast<int>(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<int>(Key::Help), static_cast<int>(ev[0].key));
release(m);
m.setTextEntry(true);
ev = press(m, chars({'?'}));
TEST_ASSERT_EQUAL(1, ev.size());
TEST_ASSERT_EQUAL(static_cast<int>(Key::Char), static_cast<int>(ev[0].key));
TEST_ASSERT_EQUAL('?', ev[0].ch);
}
int main() { int main() {
UNITY_BEGIN(); UNITY_BEGIN();
RUN_TEST(test_plain_character); RUN_TEST(test_plain_character);
@@ -241,5 +277,8 @@ int main() {
RUN_TEST(test_fn_arrows_work_in_both_modes); RUN_TEST(test_fn_arrows_work_in_both_modes);
RUN_TEST(test_other_characters_still_type_when_not_typing); 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_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(); return UNITY_END();
} }