From 4cdb4c342fe03d39fe11742a062f76184656a118 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Martin?= Date: Wed, 7 Oct 2026 21:39:16 +0200 Subject: [PATCH] Screenshots: Fn+p on every screen (#83) Fn+p saves the screen as it is to /screenshots, as the Shell's `screenshot` does, from anywhere: text fields, dialogs and the help panel included. The key never reaches an App. It refuses on Settings > Debug Console, which shows the token: a picture of that page is a copy of the token in a file (App::showsSecret). `key shot` presses it over the consoles. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT --- README.md | 2 +- docs/milestones/U1.md | 15 +++++++++++++ lib/core/src/app.h | 4 ++++ lib/core/src/app_keys.h | 1 + lib/core/src/key_event.h | 1 + lib/input/src/key_mapper.cpp | 7 ++++++ site/content/dev/debug/commands.md | 4 ++-- site/content/dev/debug/drive-the-ui.md | 2 +- site/content/dev/milestones/u1.md | 15 +++++++++++++ site/content/guide/basics.md | 1 + site/content/guide/shell.md | 2 +- site/data/keys.toml | 1 + src/apps/settings_app.h | 1 + src/apps/shell_app.cpp | 2 +- src/main.cpp | 28 ++++++++++++++++++++---- test/test_key_mapper/test_key_mapper.cpp | 17 ++++++++++++++ 16 files changed, 93 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index a40a27b..4120084 100644 --- a/README.md +++ b/README.md @@ -168,7 +168,7 @@ The Shell App (docs/milestones/S1.md) runs the commands below on the device's ow | Command | Effect | |---|---| | `burst` | Publishes 5 Notifications at once | -| `key up\|down\|left\|right\|select\|back\|home\|del\|tab\|space\|help`, or `key ` | Injects a key press (`help` is Fn+h: the keys of the screen that is showing). `ctrl-`, `alt-` and `shift-` before it hold that key: `key ctrl-down`, `key alt-up`, `key ctrl-b` | +| `key up\|down\|left\|right\|select\|back\|home\|del\|tab\|space\|help\|shot`, or `key ` | Injects a key press (`help` is Fn+h: the keys of the screen that is showing; `shot` is Fn+p: a screenshot). `ctrl-`, `alt-` and `shift-` before it hold that key: `key ctrl-down`, `key alt-up`, `key ctrl-b` | | `sound on` / `sound off` | Toggles the Sound setting (beep + LED) | | `short` / `normal` | Screen timeouts 5 s / 10 s, or 30 s / 60 s | | `wifi add ` | Adds a Saved Network (so credentials stay out of the repo) | diff --git a/docs/milestones/U1.md b/docs/milestones/U1.md index 8affe82..b24bbae 100644 --- a/docs/milestones/U1.md +++ b/docs/milestones/U1.md @@ -44,3 +44,18 @@ The lists first lived in each App's `help()`, as code. They are now **data, in o Three rows lost their second wording on the way, since a table is constant: GNSS's `Tab` and `r`, and the Scanner's `c`, now say both things they do ("record a Track, or stop it") instead of the one that applies. The guide pages keep their written tables too, where they say more than a key list can; those can still drift, and the generated ones under them are the reference. **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. + +## The screenshot key (issue #83) + +A screenshot could only be taken by typing `screenshot` in the Shell, where "now" is a picture of the Shell. + +- **Fn+p, on every screen**, text fields included, saves the screen as it is (a dialog, the help panel or a Toast if one is showing) as a PNG in `/screenshots`, the way the Shell's command does. A Toast says so once the file is written, so it is never in the picture. +- **It never reaches an App.** `Key::Screenshot` comes out of the key mapper and is handled before the App manager, so the help panel stays open and a dialog keeps its selection. +- **Not on Settings > Debug Console:** that page shows the token, and a picture of it is a copy of the token in a file. `App::showsSecret()` says so, and the key answers with a Toast instead. +- **No card:** a Toast says so. +- No setting to switch it off: Fn with a letter isn't pressed by accident. +- It is in the "Everywhere" group of the help panel, and so in the website's key tables. `key shot` presses it over the consoles. + +**Checked on the device** (2026-10-07, with `key shot`): in the Launcher, a 33,383-byte PNG appears in `/screenshots` and is the Launcher; with the help panel open, the picture is of the panel (which now lists Fn p) and the panel stays open; on Settings > Debug Console, no file is written; back on the Settings list, one is. The test pictures were removed. + +**Not checked:** the real Fn+p on the keyboard (the mapper is host-tested); the two Toasts that refuse, which weren't looked at (one of them is on the page that mustn't be photographed); a device with no card. diff --git a/lib/core/src/app.h b/lib/core/src/app.h index 99d97c5..ca8bee1 100644 --- a/lib/core/src/app.h +++ b/lib/core/src/app.h @@ -39,6 +39,10 @@ class App { virtual void draw(Canvas& canvas) = 0; + // True while the screen shows something a picture of it shouldn't hold: the screenshot key + // (Fn+p, issue #83) then refuses, and says so. + virtual bool showsSecret() const { return false; } + // An App whose screen is costly to draw again (a picture decoded from the card, issue #45) can // keep what it drew: while this is true its part of the screen isn't cleared before draw(), // which then draws only what changed. contentLost() says that it was cleared after all, or diff --git a/lib/core/src/app_keys.h b/lib/core/src/app_keys.h index b14051e..0569107 100644 --- a/lib/core/src/app_keys.h +++ b/lib/core/src/app_keys.h @@ -27,6 +27,7 @@ inline constexpr KeyHelp kEverywhere[] = { {"Fn `", "home, the Launcher"}, {"; . , /", "arrows (Fn+ while typing)"}, {"Fn h ?", "these keys (? not typing)"}, + {"Fn p", "a screenshot, on the card"}, }; // dialog: A question diff --git a/lib/core/src/key_event.h b/lib/core/src/key_event.h index 222d251..690a09b 100644 --- a/lib/core/src/key_event.h +++ b/lib/core/src/key_event.h @@ -17,6 +17,7 @@ enum class Key : uint8_t { Tab, Delete, Help, // Fn+h anywhere, or ? outside Text Entry: the keys of this screen (issue #69) + Screenshot, // Fn+p anywhere: the screen as a PNG on the card (issue #83). Never reaches an App }; struct KeyEvent { diff --git a/lib/input/src/key_mapper.cpp b/lib/input/src/key_mapper.cpp index c72066e..2f1cc65 100644 --- a/lib/input/src/key_mapper.cpp +++ b/lib/input/src/key_mapper.cpp @@ -106,6 +106,13 @@ void KeyMapper::onChar(char c, const RawKeys& keys, std::vector& out) return; } break; + case 'p': + case 'P': + if (keys.fn) { // Fn+p: a screenshot, while typing too + out.push_back(KeyEvent::of(Key::Screenshot)); + return; + } + break; case '?': if (!textEntry_ && !keys.fn) { // ? alone, when it wouldn't be typed out.push_back(KeyEvent::of(Key::Help)); diff --git a/site/content/dev/debug/commands.md b/site/content/dev/debug/commands.md index 15a82ba..655ec12 100644 --- a/site/content/dev/debug/commands.md +++ b/site/content/dev/debug/commands.md @@ -29,7 +29,7 @@ gnss quiet on|off pause the GNSS receiver while the LoRa radio listens (it cos gnss status | gnss restart | gnss track start|stop | gnss nmea on|off | gnss send crash the last crash: firmware, reason, task, backtrace coredump erase forget the core dump in flash -key press a key: up down left right select back home del tab space help, or one character; ctrl- alt- shift- before it (key ctrl-down) +key press a key: up down left right select back home del tab space help shot, or one character; ctrl- alt- shift- before it (key ctrl-down) wifi status | wifi add wifi ip dhcp | wifi ip
/ [gateway] a Saved Network's IP setting wifi dns [b] | wifi dns always on|off | wifi ntp [b] DNS and NTP servers @@ -62,7 +62,7 @@ In **Safe Mode** (see [Crashes and Safe Mode](/dev/debug/crashes/)) only a few r | Command | Effect | |---|---| | `burst` | Publishes 5 Notifications at once | -| `key up\|down\|left\|right\|select\|back\|home\|del\|tab\|space\|help`, or `key ` | Injects a key press (`help` is Fn+h: the keys of the screen that is showing). `ctrl-`, `alt-` and `shift-` before it hold that key: `key ctrl-down`, `key alt-up`, `key ctrl-b` | +| `key up\|down\|left\|right\|select\|back\|home\|del\|tab\|space\|help\|shot`, or `key ` | Injects a key press (`help` is Fn+h: the keys of the screen that is showing; `shot` is Fn+p: a screenshot). `ctrl-`, `alt-` and `shift-` before it hold that key: `key ctrl-down`, `key alt-up`, `key ctrl-b` | | `sound on` / `sound off` | Toggles the Sound setting (beep + LED) | | `short` / `normal` | Screen timeouts 5 s / 10 s, or 30 s / 60 s | | `wifi add ` | Adds a Saved Network (so credentials stay out of the repo) | diff --git a/site/content/dev/debug/drive-the-ui.md b/site/content/dev/debug/drive-the-ui.md index 9f96839..16ec1c8 100644 --- a/site/content/dev/debug/drive-the-ui.md +++ b/site/content/dev/debug/drive-the-ui.md @@ -11,7 +11,7 @@ Everything the keyboard can do, a command can do, and everything on the screen c ## Keys ``` -key up|down|left|right|select|back|home|del|tab|space|help +key up|down|left|right|select|back|home|del|tab|space|help|shot key a # any single character: it is typed ``` diff --git a/site/content/dev/milestones/u1.md b/site/content/dev/milestones/u1.md index 4086c7a..bbe2f95 100644 --- a/site/content/dev/milestones/u1.md +++ b/site/content/dev/milestones/u1.md @@ -52,3 +52,18 @@ The lists first lived in each App's `help()`, as code. They are now **data, in o Three rows lost their second wording on the way, since a table is constant: GNSS's `Tab` and `r`, and the Scanner's `c`, now say both things they do ("record a Track, or stop it") instead of the one that applies. The guide pages keep their written tables too, where they say more than a key list can; those can still drift, and the generated ones under them are the reference. **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. + +## The screenshot key (issue #83) + +A screenshot could only be taken by typing `screenshot` in the Shell, where "now" is a picture of the Shell. + +- **Fn+p, on every screen**, text fields included, saves the screen as it is (a dialog, the help panel or a Toast if one is showing) as a PNG in `/screenshots`, the way the Shell's command does. A Toast says so once the file is written, so it is never in the picture. +- **It never reaches an App.** `Key::Screenshot` comes out of the key mapper and is handled before the App manager, so the help panel stays open and a dialog keeps its selection. +- **Not on Settings > Debug Console:** that page shows the token, and a picture of it is a copy of the token in a file. `App::showsSecret()` says so, and the key answers with a Toast instead. +- **No card:** a Toast says so. +- No setting to switch it off: Fn with a letter isn't pressed by accident. +- It is in the "Everywhere" group of the help panel, and so in the website's key tables. `key shot` presses it over the consoles. + +**Checked on the device** (2026-10-07, with `key shot`): in the Launcher, a 33,383-byte PNG appears in `/screenshots` and is the Launcher; with the help panel open, the picture is of the panel (which now lists Fn p) and the panel stays open; on Settings > Debug Console, no file is written; back on the Settings list, one is. The test pictures were removed. + +**Not checked:** the real Fn+p on the keyboard (the mapper is host-tested); the two Toasts that refuse, which weren't looked at (one of them is on the page that mustn't be photographed); a device with no card. diff --git a/site/content/guide/basics.md b/site/content/guide/basics.md index 553afbb..b0cb152 100644 --- a/site/content/guide/basics.md +++ b/site/content/guide/basics.md @@ -26,6 +26,7 @@ The Cardputer's keyboard has no arrow keys and no Escape, so the firmware gives | Fn + ; . , / | The arrows: up, down, left, right | | ; . , / alone | The same arrows, as long as you are **not** typing text | | Fn + h, or ? when not typing | **Help:** the keys of the screen you are on | +| Fn + p | **A screenshot:** the screen as it is, saved as a picture in `/screenshots` on the SD card | | Tab | Switches view in an App that has more than one | | Del | Deletes backwards when you type | | opt then an accent, then a letter | Types an accented letter: opt ' e gives é | diff --git a/site/content/guide/shell.md b/site/content/guide/shell.md index f63e28a..5198552 100644 --- a/site/content/guide/shell.md +++ b/site/content/guide/shell.md @@ -77,7 +77,7 @@ screenshot the screen, now screenshot 5 the screen in 5 seconds: time to go to another App ``` -The picture is saved as a PNG in `/screenshots` on the SD card, named by date and time, and a Toast says so once it is written (so the Toast is never in the picture). From the Shell, "now" is always a picture of the Shell: use the pause to get to the screen you want. The [Storage App](/guide/storage/) shows the files; to look at them, take the card to a computer. +The picture is saved as a PNG in `/screenshots` on the SD card, named by date and time, and a Toast says so once it is written (so the Toast is never in the picture). From the Shell, "now" is always a picture of the Shell: use the pause to get to the screen you want, or, simpler, press Fn + p on that screen: it takes the same picture from anywhere. The [Storage App](/guide/storage/) lists the files and [shows them](/guide/storage/#pictures). ## What it costs diff --git a/site/data/keys.toml b/site/data/keys.toml index 9e2c012..8457053 100644 --- a/site/data/keys.toml +++ b/site/data/keys.toml @@ -9,6 +9,7 @@ rows = [ ["Fn `", "home, the Launcher"], ["; . , /", "arrows (Fn+ while typing)"], ["Fn h ?", "these keys (? not typing)"], + ["Fn p", "a screenshot, on the card"], ] [[scope]] diff --git a/src/apps/settings_app.h b/src/apps/settings_app.h index 49ea4e3..dde0825 100644 --- a/src/apps/settings_app.h +++ b/src/apps/settings_app.h @@ -52,6 +52,7 @@ class SettingsApp : public App { void draw(Canvas& c) override; void help(std::vector& out) const override; const char* helpTitle() const override; + bool showsSecret() const override { return page_ == Page::Debug; } // the Debug Console's token private: enum class Page { Menu, Text, Choice, About, Wifi, Firmware, Debug }; diff --git a/src/apps/shell_app.cpp b/src/apps/shell_app.cpp index 6daaa79..549742b 100644 --- a/src/apps/shell_app.cpp +++ b/src/apps/shell_app.cpp @@ -26,7 +26,7 @@ void ShellApp::onEnter() { scroll_ = 0; confirm_.reset(); // What Tab completes besides the firmware's own commands, written as `help` writes them. - ownHelp_ = "help | clear | quit | exit\nkey up|down|left|right|select|back|home|del|tab|space|help\n"; + ownHelp_ = "help | clear | quit | exit\nkey up|down|left|right|select|back|home|del|tab|space|help|shot\n"; log_.clear(); log_.add(open_ ? "The console's commands. `help` lists them." : "No memory for the Shell: leave an App, or stop IRC."); } diff --git a/src/main.cpp b/src/main.cpp index a40cdde..6b5b088 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -656,7 +656,7 @@ static const char* const kHelp = "gnss status | gnss restart | gnss track start|stop | gnss nmea on|off | gnss send \n" "crash the last crash: firmware, reason, task, backtrace\n" "coredump erase forget the core dump in flash\n" - "key press a key: up down left right select back home del tab space help, or one character; ctrl- alt- shift- before it (key ctrl-down)\n" + "key press a key: up down left right select back home del tab space help shot, or one character; ctrl- alt- shift- before it (key ctrl-down)\n" "wifi status | wifi add \n" "wifi ip dhcp | wifi ip
/ [gateway] a Saved Network's IP setting\n" "wifi dns [b] | wifi dns always on|off | wifi ntp [b] DNS and NTP servers\n" @@ -725,6 +725,26 @@ static void saveScreenshot() { }); } +// Fn+p (issue #83): the screen as it is, dialog, help panel or Toast included. Not the page that +// shows the Debug Console's token: a picture of it is a copy of the token in a file. +static void screenshotKey() { + const char* why = nullptr; + if (apps->foreground().showsSecret()) why = "No screenshot here: it shows the token"; + else if (!storageService || !storageService->state().present) why = "No SD card for the screenshot"; + if (why) { + bus.publish(Event::withText(EventType::Notification, why, static_cast(NotificationLevel::Warning))); + return; + } + shotFrom = Console::Origin::System; + saveScreenshot(); +} + +// Every key goes through here, from the keyboard or from a console's `key`. +static void handleKey(const KeyEvent& e) { + if (e.key == Key::Screenshot) return screenshotKey(); + apps->handleKey(e); +} + static void screenshotStep() { if (shotPending && static_cast(millis() - shotDueMs) >= 0) { shotPending = false; @@ -1039,14 +1059,14 @@ static void runCommand(String line, bool fromSerial = false) { } Key key = k == "up" ? Key::Up : k == "down" ? Key::Down : k == "left" ? Key::Left : k == "right" ? Key::Right : k == "back" ? Key::Back : k == "home" ? Key::Home - : k == "del" ? Key::Delete : k == "tab" ? Key::Tab : k == "help" ? Key::Help : Key::Select; + : k == "del" ? Key::Delete : k == "tab" ? Key::Tab : k == "help" ? Key::Help : k == "shot" ? Key::Screenshot : Key::Select; KeyEvent ev = k == "space" ? KeyEvent::character(' ') : k.length() == 1 && k[0] > ' ' ? KeyEvent::character((unsigned char)k[0]) : KeyEvent::of(key); ev.ctrl = ctrl; ev.alt = alt; ev.shift = shift; - if (!power->onKey(millis())) apps->handleKey(ev); + if (!power->onKey(millis())) handleKey(ev); } if (line.startsWith("wifi add ")) { // wifi add \t: credentials never touch the repo int tab = line.indexOf('\t'); @@ -1385,7 +1405,7 @@ static void loopPass() { keyMapper.setTextEntry(apps->foreground().textEntryActive()); auto events = keyMapper.update(readKeys()); // always, so held keys are tracked if (!swallow) - for (auto& e : events) apps->handleKey(e); + for (auto& e : events) handleKey(e); } // Issue #20: the GNSS receiver costs the LoRa radio 8 dB while it runs. If the user chose so, diff --git a/test/test_key_mapper/test_key_mapper.cpp b/test/test_key_mapper/test_key_mapper.cpp index ccc346f..cb6734e 100644 --- a/test/test_key_mapper/test_key_mapper.cpp +++ b/test/test_key_mapper/test_key_mapper.cpp @@ -231,6 +231,22 @@ void test_fn_h_is_help_in_both_modes() { } } +// Issue #83: Fn+p everywhere. +void test_fn_p_is_a_screenshot_in_both_modes() { + for (bool typing : {true, false}) { + KeyMapper m; + m.setTextEntry(typing); + auto ev = press(m, withFn({'p'})); + TEST_ASSERT_EQUAL(1, ev.size()); + TEST_ASSERT_EQUAL(static_cast(Key::Screenshot), static_cast(ev[0].key)); + release(m); + ev = press(m, chars({'p'})); + TEST_ASSERT_EQUAL(1, ev.size()); + TEST_ASSERT_EQUAL(static_cast(Key::Char), static_cast(ev[0].key)); + TEST_ASSERT_EQUAL('p', ev[0].ch); + } +} + void test_plain_h_still_types() { for (bool typing : {true, false}) { KeyMapper m; @@ -278,6 +294,7 @@ int main() { RUN_TEST(test_other_characters_still_type_when_not_typing); RUN_TEST(test_shifted_arrow_keys_type_their_symbols_when_not_typing); RUN_TEST(test_fn_h_is_help_in_both_modes); + RUN_TEST(test_fn_p_is_a_screenshot_in_both_modes); RUN_TEST(test_plain_h_still_types); RUN_TEST(test_question_mark_is_help_only_when_not_typing); return UNITY_END();