Launcher: a grid of icons, with badges; the list stays as a Setting

The Launcher shows the Apps as a 4 x 3 grid of 32 x 32 icons, the
selected one's name under it (issue #9). The icons are the website's
own, doubled, and two drawn the same way for SSH and the Shell; they
are PNGs in assets/icons, turned into arrays by scripts/make_icons.py,
which CI checks. The selected tile is the website's: dark blue on cyan,
with notched corners.

- GridModel (lib/ui, host-tested) holds the selection and the scrolling.
- App::badge(): a dot on the tile for unread IRC messages, a Track
  recording, a Capture running, an SSH session open.
- Settings > Launcher: Grid or List.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162FokPdvY2KsS4NBfwyWPk
This commit is contained in:
2026-10-09 23:14:37 +02:00
co-authored by Claude Opus 5.5
parent aadfd8cb37
commit e2326e113b
40 changed files with 907 additions and 24 deletions
+3
View File
@@ -83,6 +83,9 @@ jobs:
git checkout -q --detach "${{ github.sha }}"
git describe --tags --always
- name: The icons are what their pictures give
run: python3 scripts/make_icons.py --check
- name: Host tests, and their coverage of lib/
if: github.event_name != 'workflow_dispatch'
run: |
+2
View File
@@ -43,6 +43,8 @@ scripts/ci.sh
This runs the host-side unit tests (`test/`, `native` environment), then builds the firmware. The output is `.pio/build/cardputer-adv/firmware.factory.bin`.
`scripts/make_icons.py` turns the Launcher's icons (`assets/icons/<App id>.png`, 32 × 32, one colour) into the arrays in `src/ui/icons.cpp`; run it after changing a picture. CI fails when the two don't match.
`scripts/coverage.sh` runs the same tests with coverage counters and writes a line-by-line report to `.pio/coverage/index.html`. The badge above is its figure for `main`: the share of the lines of `lib/` that the host tests run. `lib/` is the logic that compiles on a PC; `lib/SD` (the card's driver) and `src/` (the Apps, the Services, everything that needs the device) have no host tests and aren't in that figure.
The framework is rebuilt with the TLS settings in `platformio.ini` (`custom_sdkconfig`, ADR 0006), so the first build after a fresh checkout takes about 6 minutes; later builds take about 15 seconds when little has changed. The platform knows the framework is already rebuilt by `sdkconfig.defaults` in the project folder, which it writes and git ignores: delete it and the next build rebuilds the framework. CI keeps that file, a build cache and ccache in its volume (docs/milestones/R1.md).
Binary file not shown.

After

Width:  |  Height:  |  Size: 108 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 B

+19 -1
View File
@@ -1,6 +1,6 @@
# U1 — Look and feel
**Status:** in progress. Shipped: the help key (issue #69) and the key tables the website shares with it (#72), both in **v0.13.0**; the screenshot key (#83, **v0.17.0**). Not started: screen recording (#17), a Launcher of tiles (#9), themes (#10).
**Status:** in progress. Shipped: the help key (issue #69) and the key tables the website shares with it (#72), both in **v0.13.0**; the screenshot key (#83, **v0.17.0**). The Launcher as a grid of icons (#9) is done, not released yet. Not started: screen recording (#17), themes (#10).
**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.
@@ -60,6 +60,24 @@ A screenshot could only be taken by typing `screenshot` in the Shell, where "now
**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.
## The Launcher as a grid (issue #9)
The Launcher was a list of names. It is now a grid of icons.
| Question in the issue | Decided |
|---|---|
| Layout | **4 × 3** tiles of 60 × 36, so the 11 Apps are on one page. Only the selected App's name is written, on a line under the grid: a name under every tile doesn't fit 60 pixels ("LoRa Scanner"). More than 12 Apps: the rows scroll |
| Icons | **The website's own**: its nine 16 × 16 pictures (`site/static/img/icons-dark.svg`), doubled to 32 × 32, and two drawn the same way for SSH and the Shell. 1 bit a pixel (128 bytes each, in flash). The pictures are PNGs in `assets/icons/`, named after the App's id; `scripts/make_icons.py` writes `src/ui/icons.cpp` from them and CI checks the two match |
| Colours | **The website's**: cyan (#00dbff) on black, and the selected tile dark blue (#000055) on a cyan fill with notched corners. Both are RGB332 colours. The name under the grid is cyan too |
| Live state | **A dot** on the tile, from `App::badge()`: IRC (unread messages), GNSS (a Track recording), the LoRa Scanner (a Capture running), SSH (a session open). The Launcher asks every App on each pass and redraws when an answer changes |
| Order | As registered, as before |
| Shortcuts | None for now |
| The list | Kept: **Settings > Launcher**, Grid or List. Both keep the same selection |
- `GridModel` (`lib/ui`) holds the selection and the scrolling, host-tested: left and right go through every item and wrap; up and down stay in the column, and land on the last item where the last row is short.
- An App registered without an icon shows its first letter in a frame.
- No animation: not asked for, and not measured.
## The website's colours, on every screen
Every screen takes its colours from `src/ui/theme.h`, so the palette changed there, to the website's (`site/static/css/site.css`, dark side). Each is a colour the RGB332 frame buffer holds exactly.
+3 -1
View File
@@ -19,7 +19,7 @@ const RowDef kRows[] = {
{Row::LongName, Kind::Text, "Long name"}, {Row::ShortName, Kind::Text, "Short name"},
{Row::Region, Kind::Choice, "Region"}, {Row::Timezone, Kind::Choice, "Timezone"},
{Row::Brightness, Kind::Slider, "Brightness"}, {Row::DimTimeout, Kind::Choice, "Dim after"},
{Row::OffTimeout, Kind::Choice, "Screen off after"}, {Row::Sound, Kind::Toggle, "Sound & LED"},
{Row::OffTimeout, Kind::Choice, "Screen off after"}, {Row::Launcher, Kind::Toggle, "Launcher"}, {Row::Sound, Kind::Toggle, "Sound & LED"},
{Row::Gnss, Kind::Toggle, "GNSS"}, {Row::GnssQuiet, Kind::Toggle, "Pause GNSS for LoRa"},
{Row::Coordinates, Kind::Toggle, "Coordinates"},
{Row::ProbeMacs, Kind::Toggle, "Probe MACs"}, {Row::Wifi, Kind::Page, "Wi-Fi"},
@@ -80,6 +80,7 @@ std::string SettingsMenu::value(int i) const {
case Row::Brightness: return std::to_string(settings_.getInt(Setting::Brightness)) + "%";
case Row::DimTimeout: return formatSeconds(settings_.getInt(Setting::DimTimeoutS));
case Row::OffTimeout: return formatSeconds(settings_.getInt(Setting::OffTimeoutS));
case Row::Launcher: return settings_.getBool(Setting::LauncherList) ? "List" : "Grid";
case Row::Sound: return settings_.getBool(Setting::Sound) ? "On" : "Off";
case Row::Gnss: return settings_.getBool(Setting::GnssEnabled) ? "On" : "Off";
case Row::GnssQuiet: return settings_.getBool(Setting::GnssQuietForLora) ? "On" : "Off";
@@ -129,6 +130,7 @@ std::string SettingsMenu::choose(int i, int c) {
}
void SettingsMenu::toggle(int i) {
if (row(i) == Row::Launcher) settings_.setBool(Setting::LauncherList, !settings_.getBool(Setting::LauncherList));
if (row(i) == Row::Sound) settings_.setBool(Setting::Sound, !settings_.getBool(Setting::Sound));
if (row(i) == Row::Gnss) settings_.setBool(Setting::GnssEnabled, !settings_.getBool(Setting::GnssEnabled));
if (row(i) == Row::GnssQuiet) settings_.setBool(Setting::GnssQuietForLora, !settings_.getBool(Setting::GnssQuietForLora));
+1 -1
View File
@@ -11,7 +11,7 @@ namespace roro {
// values, choice lists and validation messages. Rendering and navigation live in the App.
class SettingsMenu {
public:
enum class Row { LongName, ShortName, Region, Timezone, Brightness, DimTimeout, OffTimeout, Sound, Gnss, GnssQuiet, Coordinates, ProbeMacs, Wifi, CheckUpdates, Firmware, Vpn, DebugConsole, About };
enum class Row { LongName, ShortName, Region, Timezone, Brightness, DimTimeout, OffTimeout, Launcher, Sound, Gnss, GnssQuiet, Coordinates, ProbeMacs, Wifi, CheckUpdates, Firmware, Vpn, DebugConsole, About };
enum class Kind { Text, Choice, Toggle, Slider, Page };
explicit SettingsMenu(Settings& settings) : settings_(settings) {}
+4
View File
@@ -50,6 +50,10 @@ class App {
virtual bool retainsContent() const { return false; }
virtual void contentLost() {}
// True while something goes on in the App that is worth a look, whether it is in front or
// not (unread messages, a recording running): the Launcher marks its tile (issue #9).
virtual bool badge() const { return false; }
void requestRedraw() { redraw_ = true; }
bool consumeRedraw() {
+1 -1
View File
@@ -48,7 +48,7 @@ inline constexpr KeyHelp kText[] = {
// launcher: The Launcher
inline constexpr KeyHelp kLauncher[] = {
{"; .", "up, down"},
{"; . , /", "move"},
{"Enter", "open the App"},
};
+3
View File
@@ -1,5 +1,7 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
@@ -12,6 +14,7 @@ struct AppInfo {
const char* title;
bool hidden; // not listed in the Launcher, but can still be opened
App* app;
const uint8_t* icon = nullptr; // the Launcher's picture of it (ui/icons.h); none: its first letter
};
// Owns which App is in the foreground and routes keys. Home always returns to the Launcher;
+1
View File
@@ -53,6 +53,7 @@ const Definition kDefinitions[] = {
{"ssh_key", Kind::String, 0, "", 0, 800},
{"ssh_public", Kind::String, 0, "", 0, 200},
{"ssh_font", Kind::Int, 1, nullptr, 0, 4},
{"launcher_list", Kind::Bool, 0, nullptr, 0, 1}, // off: the grid
};
static_assert(sizeof(kDefinitions) / sizeof(kDefinitions[0]) == static_cast<size_t>(Setting::Count),
"every Setting needs a definition");
+1
View File
@@ -41,6 +41,7 @@ enum class Setting : uint8_t {
SshKey, // string: this device's own SSH private key, as OpenSSH writes one: never shown
SshPublic, // string: its public half, the line to put in a server's authorized_keys
SshFont, // int: the terminal's font, 0 (smallest) to 4
LauncherList, // bool: the Launcher is a list of names instead of a grid of icons (issue #9)
Count
};
+47
View File
@@ -0,0 +1,47 @@
#include "grid_model.h"
namespace roro {
void GridModel::setCount(int count) {
count_ = count < 0 ? 0 : count;
if (count_ == 0) {
selected_ = -1;
first_ = 0;
return;
}
select(selected_ < 0 ? 0 : selected_);
}
void GridModel::select(int index) {
if (count_ == 0) return;
selected_ = index < 0 ? 0 : index >= count_ ? count_ - 1 : index;
int row = selected_ / cols_;
if (row < first_) first_ = row;
if (row >= first_ + rows_) first_ = row - rows_ + 1;
int maxFirst = rows() > rows_ ? rows() - rows_ : 0;
if (first_ > maxFirst) first_ = maxFirst;
}
void GridModel::left() {
if (count_) select(selected_ == 0 ? count_ - 1 : selected_ - 1);
}
void GridModel::right() {
if (count_) select(selected_ == count_ - 1 ? 0 : selected_ + 1);
}
void GridModel::up() {
if (!count_) return;
int col = selected_ % cols_, row = selected_ / cols_;
row = row == 0 ? rows() - 1 : row - 1;
select(row * cols_ + col); // past the end of a short last row: the last item
}
void GridModel::down() {
if (!count_) return;
int col = selected_ % cols_, row = selected_ / cols_;
row = row == rows() - 1 ? 0 : row + 1;
select(row * cols_ + col);
}
} // namespace roro
+33
View File
@@ -0,0 +1,33 @@
#pragma once
namespace roro {
// Selection and scrolling for a grid of `cols` columns showing `visibleRows` rows at a time, filled
// row by row. Left and Right go through every item and wrap around; Up and Down stay in the
// column and wrap around, landing on the last item where the last row has no such column.
class GridModel {
public:
GridModel(int cols, int visibleRows) : cols_(cols), rows_(visibleRows) {}
void setCount(int count);
int count() const { return count_; }
int selected() const { return selected_; } // -1 when empty
int cols() const { return cols_; }
int rows() const { return count_ ? (count_ - 1) / cols_ + 1 : 0; }
int firstVisibleRow() const { return first_; }
int visibleRows() const { return rows_; }
void select(int index);
void left();
void right();
void up();
void down();
private:
int cols_, rows_;
int count_ = 0;
int selected_ = -1;
int first_ = 0;
};
} // namespace roro
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""Turns the Launcher's icons (assets/icons/<App id>.png) into C arrays: src/ui/icons.h and icons.cpp.
An icon is 32 x 32 and one colour: a pixel is drawn where the picture is light and opaque, and left
alone where it is dark or transparent. Any image editor's PNG will do (grey, RGB, palette, with or
without alpha, 1 or 8 bits a channel, not interlaced). Run this after changing a picture:
python3 scripts/make_icons.py
CI runs it with --check, which fails when the arrays are not what the pictures give.
"""
import os
import struct
import sys
import zlib
ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")
SOURCE = os.path.join(ROOT, "assets", "icons")
SIZE = 32
def read_png(path):
"""The picture as rows of booleans: True where a pixel is drawn."""
data = open(path, "rb").read()
if data[:8] != b"\x89PNG\r\n\x1a\n":
raise ValueError("not a PNG")
at, idat, palette, alpha = 8, b"", None, None
while at < len(data):
(length,), kind = struct.unpack(">I", data[at:at + 4]), data[at + 4:at + 8]
body = data[at + 8:at + 8 + length]
at += 12 + length
if kind == b"IHDR":
width, height, depth, colour, _, _, interlace = struct.unpack(">IIBBBBB", body)
elif kind == b"PLTE":
palette = [body[i:i + 3] for i in range(0, len(body), 3)]
elif kind == b"tRNS":
alpha = body
elif kind == b"IDAT":
idat += body
if interlace or depth not in (1, 8) or (depth == 1 and colour not in (0, 3)):
raise ValueError("save it as a plain 8-bit or 1-bit PNG, not interlaced")
channels = {0: 1, 2: 3, 3: 1, 4: 2, 6: 4}[colour]
step = channels if depth == 8 else 1 # bytes a pixel, for the filters
stride = (width * channels * depth + 7) // 8
raw, rows, before = zlib.decompress(idat), [], bytes(stride)
for y in range(height):
kind, line = raw[y * (stride + 1)], bytearray(raw[y * (stride + 1) + 1:(y + 1) * (stride + 1)])
for i in range(stride):
a = line[i - step] if i >= step else 0
b, c = before[i], before[i - step] if i >= step else 0
if kind == 1:
line[i] = (line[i] + a) & 255
elif kind == 2:
line[i] = (line[i] + b) & 255
elif kind == 3:
line[i] = (line[i] + (a + b) // 2) & 255
elif kind == 4:
p = a + b - c
pa, pb, pc = abs(p - a), abs(p - b), abs(p - c)
line[i] = (line[i] + (a if pa <= pb and pa <= pc else b if pb <= pc else c)) & 255
before = bytes(line)
row = []
for x in range(width):
if depth == 1:
v = (line[x // 8] >> (7 - x % 8)) & 1
px = [v * 255] if colour == 0 else [v]
else:
px = line[x * channels:(x + 1) * channels]
if colour == 3:
opaque = alpha is None or px[0] >= len(alpha) or alpha[px[0]] > 127
light = sum(palette[px[0]]) > 381
elif colour in (0, 4):
opaque, light = colour == 0 or px[1] > 127, px[0] > 127
else:
opaque, light = colour == 2 or px[3] > 127, sum(px[:3]) > 381
row.append(opaque and light)
rows.append(row)
return rows
def main():
names = sorted(f[:-4] for f in os.listdir(SOURCE) if f.endswith(".png"))
header = ["// Made by scripts/make_icons.py from assets/icons/*.png: change the pictures, not this file.", "#pragma once", "",
"#include <cstdint>", "", "namespace roro::icons {", "",
"// One bit a pixel, the leftmost first, a row after another: what Canvas::drawBitmap takes.",
f"constexpr int kSize = {SIZE};", ""]
body = ["// Made by scripts/make_icons.py from assets/icons/*.png: change the pictures, not this file.", '#include "icons.h"', "",
"namespace roro::icons {", ""]
for name in names:
rows = read_png(os.path.join(SOURCE, name + ".png"))
if len(rows) != SIZE or len(rows[0]) != SIZE:
sys.exit(f"{name}.png is {len(rows[0])} x {len(rows)}: an icon is {SIZE} x {SIZE}")
ident = name.replace("-", "_")
header.append(f"extern const uint8_t {ident}[{SIZE * SIZE // 8}]; // {name}.png")
body.append(f"const uint8_t {ident}[{SIZE * SIZE // 8}] = {{")
for row in rows:
packed = [sum(0x80 >> i for i in range(8) if row[b * 8 + i]) for b in range(SIZE // 8)]
body.append(" " + ", ".join(f"0x{v:02x}" for v in packed) + ", // " + "".join("#" if v else "." for v in row))
body += ["};", ""]
header += ["", "} // namespace roro::icons", ""]
body += ["} // namespace roro::icons", ""]
out = {os.path.join(ROOT, "src", "ui", "icons.h"): "\n".join(header), os.path.join(ROOT, "src", "ui", "icons.cpp"): "\n".join(body)}
if "--check" in sys.argv:
stale = [os.path.relpath(p, ROOT) for p, text in out.items() if not os.path.exists(p) or open(p).read() != text]
if stale:
sys.exit("out of date: " + ", ".join(stale) + " (run scripts/make_icons.py)")
print(f"{len(names)} icons, up to date")
return
for path, text in out.items():
open(path, "w").write(text)
print(f"{len(names)} icons written")
if __name__ == "__main__":
main()
+2
View File
@@ -20,6 +20,8 @@ scripts/ci.sh
This runs the host-side unit tests (`test/`, `native` environment), then builds the firmware. The output is `.pio/build/cardputer-adv/firmware.factory.bin`.
`scripts/make_icons.py` turns the Launcher's icons (`assets/icons/<App id>.png`, 32 × 32, one colour) into the arrays in `src/ui/icons.cpp`; run it after changing a picture. CI fails when the two don't match.
`scripts/coverage.sh` runs the same tests with coverage counters and writes a line-by-line report to `.pio/coverage/index.html`. The badge above is its figure for `main`: the share of the lines of `lib/` that the host tests run. `lib/` is the logic that compiles on a PC; `lib/SD` (the card's driver) and `src/` (the Apps, the Services, everything that needs the device) have no host tests and aren't in that figure.
The framework is rebuilt with the TLS settings in `platformio.ini` (`custom_sdkconfig`, ADR 0006), so the first build after a fresh checkout takes about 6 minutes; later builds take about 15 seconds when little has changed. The platform knows the framework is already rebuilt by `sdkconfig.defaults` in the project folder, which it writes and git ignores: delete it and the next build rebuilds the framework. CI keeps that file, a build cache and ccache in its volume (docs/milestones/R1.md).
+19 -1
View File
@@ -8,7 +8,7 @@ docs = true
source = "docs/milestones/U1.md"
tag = "U1"
+++
**Status:** in progress. Shipped: the help key (issue #69) and the key tables the website shares with it (#72), both in **v0.13.0**; the screenshot key (#83, **v0.17.0**). Not started: screen recording (#17), a Launcher of tiles (#9), themes (#10).
**Status:** in progress. Shipped: the help key (issue #69) and the key tables the website shares with it (#72), both in **v0.13.0**; the screenshot key (#83, **v0.17.0**). The Launcher as a grid of icons (#9) is done, not released yet. Not started: screen recording (#17), themes (#10).
**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.
@@ -68,6 +68,24 @@ A screenshot could only be taken by typing `screenshot` in the Shell, where "now
**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.
## The Launcher as a grid (issue #9)
The Launcher was a list of names. It is now a grid of icons.
| Question in the issue | Decided |
|---|---|
| Layout | **4 × 3** tiles of 60 × 36, so the 11 Apps are on one page. Only the selected App's name is written, on a line under the grid: a name under every tile doesn't fit 60 pixels ("LoRa Scanner"). More than 12 Apps: the rows scroll |
| Icons | **The website's own**: its nine 16 × 16 pictures (`site/static/img/icons-dark.svg`), doubled to 32 × 32, and two drawn the same way for SSH and the Shell. 1 bit a pixel (128 bytes each, in flash). The pictures are PNGs in `assets/icons/`, named after the App's id; `scripts/make_icons.py` writes `src/ui/icons.cpp` from them and CI checks the two match |
| Colours | **The website's**: cyan (#00dbff) on black, and the selected tile dark blue (#000055) on a cyan fill with notched corners. Both are RGB332 colours. The name under the grid is cyan too |
| Live state | **A dot** on the tile, from `App::badge()`: IRC (unread messages), GNSS (a Track recording), the LoRa Scanner (a Capture running), SSH (a session open). The Launcher asks every App on each pass and redraws when an answer changes |
| Order | As registered, as before |
| Shortcuts | None for now |
| The list | Kept: **Settings > Launcher**, Grid or List. Both keep the same selection |
- `GridModel` (`lib/ui`) holds the selection and the scrolling, host-tested: left and right go through every item and wrap; up and down stay in the column, and land on the last item where the last row is short.
- An App registered without an icon shows its first letter in a frame.
- No animation: not asked for, and not measured.
## The website's colours, on every screen
Every screen takes its colours from `src/ui/theme.h`, so the palette changed there, to the website's (`site/static/css/site.css`, dark side). Each is a colour the RGB332 frame buffer holds exactly.
+7 -1
View File
@@ -36,7 +36,13 @@ While you type text (a note, an IRC line, a setting), `;` `.` `,` `/` type their
## The Launcher
The home screen lists the Apps. Move with the arrows, open one with <kbd>Enter</kbd>. Back inside an App returns here.
The home screen shows the Apps as a grid of icons, four to a row, with the name of the selected one written under the grid. Move with the four arrows, open an App with <kbd>Enter</kbd>. Back inside an App returns here.
Left and right go through every App and wrap around; up and down stay in the column.
**An orange dot on an icon** says something is going on in that App: unread messages in IRC, a Track being recorded in GNSS, a Capture running in the LoRa Scanner, a session open in SSH.
If you prefer the list of names the Launcher used to be, **Settings → Launcher** switches between **Grid** and **List**. In the list, a `*` on the right stands for the dot.
## The Status Bar
+1
View File
@@ -15,6 +15,7 @@ Move with the arrows. On a toggle, a choice or a slider, left and right change t
| **Timezone** | For the clock and the dates in file names |
| **Brightness** | A slider |
| **Dim after**, **Screen off after** | Screen timeouts. Dimming must come before the screen turns off |
| **Launcher** | The home screen as a **Grid** of icons or a **List** of names (see [The basics](/guide/basics/#the-launcher)) |
| **Sound & LED** | The beep and the flash on a [Toast](/guide/basics/) |
| **GNSS** | Switches the receiver on and off (see [GNSS](/guide/gnss/)) |
| **Pause GNSS for LoRa** | Puts the receiver on standby while the radio listens (see [LoRa Scanner](/guide/lora-scanner/)) |
+1 -1
View File
@@ -36,7 +36,7 @@ rows = [
id = "launcher"
title = "The Launcher"
rows = [
["; .", "up, down"],
["; . , /", "move"],
["Enter", "open the App"],
]
+1
View File
@@ -18,6 +18,7 @@ class GnssApp : public App {
void update(uint32_t nowMs) override;
void draw(Canvas& c) override;
void help(std::vector<KeyHelp>& out) const override;
bool badge() const override { return gnss_.tracking(); }
private:
void drawPosition(Canvas& c);
+1
View File
@@ -26,6 +26,7 @@ class IrcApp : public App {
void draw(Canvas& c) override;
void help(std::vector<KeyHelp>& out) const override;
const char* helpTitle() const override;
bool badge() const override { return irc_.totalUnread() > 0; }
private:
enum class Page { Chat, Settings };
+88 -5
View File
@@ -1,27 +1,110 @@
#include "launcher_app.h"
#include "ui/fonts.h"
#include "ui/icons.h"
#include "ui/widgets.h"
namespace roro {
void LauncherApp::onEnter() { list_.setCount(static_cast<int>(manager_->visibleApps().size())); }
void LauncherApp::onEnter() {
int count = static_cast<int>(manager_->visibleApps().size());
list_.setCount(count);
grid_.setCount(count);
badges_ = badges();
}
bool LauncherApp::onKey(const KeyEvent& e) {
bool list = asList();
switch (e.key) {
case Key::Up: list_.up(); break;
case Key::Down: list_.down(); break;
case Key::Up: list ? list_.up() : grid_.up(); break;
case Key::Down: list ? list_.down() : grid_.down(); break;
case Key::Left:
if (list) return false;
grid_.left();
break;
case Key::Right:
if (list) return false;
grid_.right();
break;
case Key::Select:
if (list_.selected() >= 0) manager_->open(manager_->visibleApps()[list_.selected()]->id);
return true;
default: return false;
}
// The view that didn't move follows.
if (list) grid_.select(list_.selected());
else list_.select(grid_.selected());
requestRedraw();
return true;
}
void LauncherApp::draw(Canvas& c) {
uint32_t LauncherApp::badges() const {
uint32_t bits = 0;
auto apps = manager_->visibleApps();
widgets::list(c, list_, theme::kContent, [&](int i) { return std::string(apps[i]->title); });
for (size_t i = 0; i < apps.size() && i < 32; ++i)
if (apps[i]->app->badge()) bits |= 1u << i;
return bits;
}
void LauncherApp::update(uint32_t) {
uint32_t now = badges();
if (now == badges_) return;
badges_ = now;
requestRedraw();
}
void LauncherApp::draw(Canvas& c) {
if (!asList()) return drawGrid(c);
auto apps = manager_->visibleApps();
widgets::list(c, list_, theme::kContent, [&](int i) { return std::string(apps[i]->title); },
[&](int i) { return std::string(badges_ & (1u << i) ? "*" : ""); });
}
void LauncherApp::drawGrid(Canvas& c) {
using namespace theme;
auto apps = manager_->visibleApps();
int selected = grid_.selected();
for (int row = 0; row < kRows; ++row) {
for (int col = 0; col < kCols; ++col) {
int i = (grid_.firstVisibleRow() + row) * kCols + col;
if (i >= static_cast<int>(apps.size())) break;
int x = kContent.x + col * kTileW, y = kContent.y + row * kTileH;
int ix = x + (kTileW - icons::kSize) / 2, iy = y + (kTileH - icons::kSize) / 2;
// As on the website: cyan on black, and the chosen one dark on a cyan tile with notched corners.
uint16_t ink = i == selected ? kOnAccent : kAccent;
if (i == selected) {
int tx = ix - 6, tw = icons::kSize + 12;
c.fillRect(tx + 4, y, tw - 8, kTileH, kAccent);
c.fillRect(tx + 2, y + 2, tw - 4, kTileH - 4, kAccent);
c.fillRect(tx, y + 4, tw, kTileH - 8, kAccent);
}
if (apps[i]->icon) {
c.drawBitmap(ix, iy, apps[i]->icon, icons::kSize, icons::kSize, ink);
} else { // an App without a picture: its first letter, framed
c.drawRect(ix + 2, iy + 2, icons::kSize - 4, icons::kSize - 4, ink);
c.setFont(&fonts::large);
c.setTextColor(ink);
c.setTextDatum(middle_center);
c.drawString(std::string(1, apps[i]->title[0]).c_str(), ix + icons::kSize / 2, iy + icons::kSize / 2);
}
if (badges_ & (1u << i)) { // a square dot on the icon's corner, with a margin to stand out from it
c.fillRect(ix + icons::kSize - 6, iy - 1, 10, 10, i == selected ? kAccent : kBackground);
c.fillRect(ix + icons::kSize - 4, iy + 1, 6, 6, kWarning);
}
}
}
// More rows than fit: where these are among them.
if (grid_.rows() > kRows) {
int h = kRows * kTileH, barH = h * kRows / grid_.rows();
c.fillRect(kContent.x + kContent.w - 2, kContent.y + (h - barH) * grid_.firstVisibleRow() / (grid_.rows() - kRows), 2, barH, kMuted);
}
if (selected >= 0) {
c.setFont(&fonts::bold);
c.setTextColor(kAccent);
c.setTextDatum(top_center);
c.drawString(apps[selected]->title, kContent.x + kContent.w / 2, kContent.y + kRows * kTileH + 1);
}
c.setTextDatum(top_left);
}
} // namespace roro
+18 -1
View File
@@ -3,23 +3,40 @@
#include "app.h"
#include "app_keys.h"
#include "app_manager.h"
#include "grid_model.h"
#include "list_model.h"
#include "settings.h"
#include "ui/theme.h"
namespace roro {
// Home screen: lists the visible Apps; Select opens one.
// Home screen: the visible Apps as a grid of icons, with the selected one's name under it, or as
// a list of names (Settings > Launcher). Select opens one. An App with something going on has a
// dot on its tile (App::badge, issue #9).
class LauncherApp : public App {
public:
void setManager(AppManager& manager) { manager_ = &manager; }
void setSettings(const Settings& settings) { settings_ = &settings; }
void onEnter() override;
bool onKey(const KeyEvent& e) override;
void update(uint32_t nowMs) override;
void draw(Canvas& c) override;
void help(std::vector<KeyHelp>& out) const override { keys::add(out, keys::kLauncher); }
private:
static constexpr int kCols = 4, kRows = 3;
static constexpr int kTileW = theme::kWidth / kCols, kTileH = 36;
bool asList() const { return settings_ && settings_->getBool(Setting::LauncherList); }
uint32_t badges() const; // one bit a visible App
void drawGrid(Canvas& c);
AppManager* manager_ = nullptr;
const Settings* settings_ = nullptr;
// One selection, kept in both: the view can change in the Settings.
ListModel list_{theme::kContent.h / theme::kLineHeight};
GridModel grid_{kCols, kRows};
uint32_t badges_ = 0;
};
} // namespace roro
+1
View File
@@ -28,6 +28,7 @@ class LoraScannerApp : public App {
void draw(Canvas& c) override;
void help(std::vector<KeyHelp>& out) const override;
const char* helpTitle() const override;
bool badge() const override { return capture_.capturing(); }
private:
enum class View { Packets, Details, Presets, Sweep };
+1
View File
@@ -28,6 +28,7 @@ class SshApp : public App {
bool textEntryActive() const override { return view_ == View::Entry || (view_ == View::Session && !dialog_); }
void help(std::vector<KeyHelp>& out) const override;
const char* helpTitle() const override;
bool badge() const override { return ssh_.active(); }
void update(uint32_t nowMs) override;
void draw(Canvas& c) override;
+13 -11
View File
@@ -62,6 +62,7 @@
#include "safe_mode.h"
#include "settings.h"
#include "storage_paths.h"
#include "ui/icons.h"
#include "ui/notifier.h"
#include "ui/screen.h"
#include "scp_args.h"
@@ -230,19 +231,20 @@ void setup() {
apps = new AppManager(launcher);
launcher.setManager(*apps);
apps->registerApp({"irc", "IRC", false, new IrcApp(*irc, *clockService, bus)});
apps->registerApp({"wifi-tools", "Wi-Fi Tools", false, new WifiToolsApp(*wifi, *storageService, *clockService)});
apps->registerApp({"gnss", "GNSS", false, new GnssApp(*gnssService, settings)});
apps->registerApp({"gemini", "Gemini", false, new GeminiApp(*geminiService)});
apps->registerApp({"lora", "LoRa Scanner", false, new LoraScannerApp(*radioService, *loraCapture, settings, *clockService)});
apps->registerApp({"storage", "Storage", false, new StorageApp(*fileOps, *storageService, *clockService, *update, *power, bus, *new WebShare(*storageService, *fileOps, *wifi))});
apps->registerApp({"notes", "Notes", false, new NotesApp(*fileOps, *storageService, *clockService, *power)});
launcher.setSettings(settings);
apps->registerApp({"irc", "IRC", false, new IrcApp(*irc, *clockService, bus), icons::irc});
apps->registerApp({"wifi-tools", "Wi-Fi Tools", false, new WifiToolsApp(*wifi, *storageService, *clockService), icons::wifi_tools});
apps->registerApp({"gnss", "GNSS", false, new GnssApp(*gnssService, settings), icons::gnss});
apps->registerApp({"gemini", "Gemini", false, new GeminiApp(*geminiService), icons::gemini});
apps->registerApp({"lora", "LoRa Scanner", false, new LoraScannerApp(*radioService, *loraCapture, settings, *clockService), icons::lora});
apps->registerApp({"storage", "Storage", false, new StorageApp(*fileOps, *storageService, *clockService, *update, *power, bus, *new WebShare(*storageService, *fileOps, *wifi)), icons::storage});
apps->registerApp({"notes", "Notes", false, new NotesApp(*fileOps, *storageService, *clockService, *power), icons::notes});
sshService = new SshService(settings);
sshApp = new SshApp(*sshService, settings, *storageService);
scpService = new ScpService(settings, *storageService, *fileOps);
apps->registerApp({"ssh", "SSH", false, sshApp});
apps->registerApp({"ssh", "SSH", false, sshApp, icons::ssh});
shellApp = new ShellApp(shellRun, shellProbe, shellList, shellCount, helpText(), *apps);
apps->registerApp({"shell", "Shell", false, shellApp});
apps->registerApp({"shell", "Shell", false, shellApp, icons::shell});
shellApp->asking = [] { return scpService->asking(); };
shellApp->answer = [](const std::string& password) { scpService->answer(password); };
shellApp->abort = [] { scpService->cancel(); };
@@ -252,9 +254,9 @@ void setup() {
// A long note being rewritten (issue #47): the editor waits for the card, so it draws from there.
NoteEditor::onProgress = [](const std::string& name, int percent) { screen.renderUpdate("Saving", name, percent); };
apps->registerApp({"system", "System", false,
new SystemApp(*wifi, *battery, *storageService, *radioService, *gnssService, nvs)});
new SystemApp(*wifi, *battery, *storageService, *radioService, *gnssService, nvs), icons::system});
apps->registerApp({"settings", "Settings", false,
new SettingsApp({settings, bus, *apps, *battery, *storageService, *clockService, *wifi, *savedNetworks, *update, *vpnService})});
new SettingsApp({settings, bus, *apps, *battery, *storageService, *clockService, *wifi, *savedNetworks, *update, *vpnService}), icons::settings});
apps->registerApp({"demo", "Widget demo", true, new DemoApp(bus)});
apps->registerApp({"setup", "Setup", true, new SetupApp(settings, *apps)});
+391
View File
@@ -0,0 +1,391 @@
// Made by scripts/make_icons.py from assets/icons/*.png: change the pictures, not this file.
#include "icons.h"
namespace roro::icons {
const uint8_t gemini[128] = {
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x3f, 0xff, 0xff, 0xfc, // ..############################..
0x3f, 0xff, 0xff, 0xfc, // ..############################..
0x33, 0x30, 0x00, 0x0c, // ..##..##..##................##..
0x33, 0x30, 0x00, 0x0c, // ..##..##..##................##..
0x30, 0x00, 0x00, 0x0c, // ..##........................##..
0x30, 0x00, 0x00, 0x0c, // ..##........................##..
0x3f, 0xff, 0xff, 0xfc, // ..############################..
0x3f, 0xff, 0xff, 0xfc, // ..############################..
0x30, 0x00, 0x00, 0x0c, // ..##........................##..
0x30, 0x00, 0x00, 0x0c, // ..##........................##..
0x30, 0xc0, 0xc0, 0x0c, // ..##....##......##..........##..
0x30, 0xc0, 0xc0, 0x0c, // ..##....##......##..........##..
0x30, 0x30, 0x30, 0x0c, // ..##......##......##........##..
0x30, 0x30, 0x30, 0x0c, // ..##......##......##........##..
0x30, 0x0c, 0x0c, 0x0c, // ..##........##......##......##..
0x30, 0x0c, 0x0c, 0x0c, // ..##........##......##......##..
0x30, 0x30, 0x30, 0x0c, // ..##......##......##........##..
0x30, 0x30, 0x30, 0x0c, // ..##......##......##........##..
0x30, 0xc0, 0xc0, 0x0c, // ..##....##......##..........##..
0x30, 0xc0, 0xc0, 0x0c, // ..##....##......##..........##..
0x30, 0x00, 0x00, 0x0c, // ..##........................##..
0x30, 0x00, 0x00, 0x0c, // ..##........................##..
0x3f, 0xff, 0xff, 0xfc, // ..############################..
0x3f, 0xff, 0xff, 0xfc, // ..############################..
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
};
const uint8_t gnss[128] = {
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x3f, 0xc0, 0x00, // ..........########..............
0x00, 0x3f, 0xc0, 0x00, // ..........########..............
0x03, 0xc0, 0x0f, 0x00, // ......####..........####........
0x03, 0xc0, 0x0f, 0x00, // ......####..........####........
0x0c, 0x00, 0x0f, 0x30, // ....##..............####..##....
0x0c, 0x00, 0x0f, 0x30, // ....##..............####..##....
0x30, 0x00, 0x00, 0x0c, // ..##........................##..
0x30, 0x00, 0x00, 0x0c, // ..##........................##..
0x30, 0x0f, 0xf0, 0x0c, // ..##........########........##..
0x30, 0x0f, 0xf0, 0x0c, // ..##........########........##..
0x00, 0x30, 0x0c, 0x0c, // ..........##........##......##..
0x00, 0x30, 0x0c, 0x0c, // ..........##........##......##..
0x3c, 0x30, 0x0c, 0x0c, // ..####....##........##......##..
0x3c, 0x30, 0x0c, 0x0c, // ..####....##........##......##..
0x3c, 0x30, 0x0c, 0x0c, // ..####....##........##......##..
0x3c, 0x30, 0x0c, 0x0c, // ..####....##........##......##..
0x00, 0x30, 0x0c, 0x0c, // ..........##........##......##..
0x00, 0x30, 0x0c, 0x0c, // ..........##........##......##..
0x30, 0x0f, 0xf0, 0x0c, // ..##........########........##..
0x30, 0x0f, 0xf0, 0x0c, // ..##........########........##..
0x30, 0x00, 0x00, 0x0c, // ..##........................##..
0x30, 0x00, 0x00, 0x0c, // ..##........................##..
0x0c, 0x00, 0x00, 0x30, // ....##....................##....
0x0c, 0x00, 0x00, 0x30, // ....##....................##....
0x03, 0xc0, 0x03, 0xc0, // ......####............####......
0x03, 0xc0, 0x03, 0xc0, // ......####............####......
0x00, 0x3f, 0xfc, 0x00, // ..........############..........
0x00, 0x3f, 0xfc, 0x00, // ..........############..........
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
};
const uint8_t irc[128] = {
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x30, 0x0c, 0x00, // ..........##........##..........
0x00, 0x30, 0x0c, 0x00, // ..........##........##..........
0x00, 0x30, 0x0c, 0x00, // ..........##........##..........
0x00, 0x30, 0x0c, 0x00, // ..........##........##..........
0x00, 0x30, 0x0c, 0x00, // ..........##........##..........
0x00, 0x30, 0x0c, 0x00, // ..........##........##..........
0x0f, 0xff, 0xff, 0xf0, // ....########################....
0x0f, 0xff, 0xff, 0xf0, // ....########################....
0x00, 0x30, 0x0c, 0x00, // ..........##........##..........
0x00, 0x30, 0x0c, 0x00, // ..........##........##..........
0x00, 0x30, 0x0c, 0x00, // ..........##........##..........
0x00, 0x30, 0x0c, 0x00, // ..........##........##..........
0x00, 0x30, 0x0c, 0x00, // ..........##........##..........
0x00, 0x30, 0x0c, 0x00, // ..........##........##..........
0x00, 0x30, 0x0c, 0x00, // ..........##........##..........
0x00, 0x30, 0x0c, 0x00, // ..........##........##..........
0x0f, 0xff, 0xff, 0xf0, // ....########################....
0x0f, 0xff, 0xff, 0xf0, // ....########################....
0x00, 0x30, 0x0c, 0x00, // ..........##........##..........
0x00, 0x30, 0x0c, 0x00, // ..........##........##..........
0x00, 0x30, 0x0c, 0x00, // ..........##........##..........
0x00, 0x30, 0x0c, 0x00, // ..........##........##..........
0x00, 0x30, 0x0c, 0x00, // ..........##........##..........
0x00, 0x30, 0x0c, 0x00, // ..........##........##..........
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
};
const uint8_t lora[128] = {
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x0c, 0x00, 0x00, // ............##..................
0x00, 0x0c, 0x00, 0x00, // ............##..................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x0c, 0x00, 0x00, // ............##..................
0x00, 0x0c, 0x00, 0x00, // ............##..................
0x00, 0x0c, 0x0c, 0x00, // ............##......##..........
0x00, 0x0c, 0x0c, 0x00, // ............##......##..........
0x00, 0x0c, 0x00, 0x00, // ............##..................
0x00, 0x0c, 0x00, 0x00, // ............##..................
0x00, 0x0c, 0x0c, 0x00, // ............##......##..........
0x00, 0x0c, 0x0c, 0x00, // ............##......##..........
0x00, 0x0c, 0x0c, 0x00, // ............##......##..........
0x00, 0x0c, 0x0c, 0x00, // ............##......##..........
0x00, 0xcc, 0x0c, 0xc0, // ........##..##......##..##......
0x00, 0xcc, 0x0c, 0xc0, // ........##..##......##..##......
0x00, 0xcc, 0xcc, 0xc0, // ........##..##..##..##..##......
0x00, 0xcc, 0xcc, 0xc0, // ........##..##..##..##..##......
0x0c, 0xcc, 0xcc, 0xc0, // ....##..##..##..##..##..##......
0x0c, 0xcc, 0xcc, 0xc0, // ....##..##..##..##..##..##......
0x0c, 0xcc, 0xcc, 0xc0, // ....##..##..##..##..##..##......
0x0c, 0xcc, 0xcc, 0xc0, // ....##..##..##..##..##..##......
0x0c, 0xcc, 0xcc, 0xc0, // ....##..##..##..##..##..##......
0x0c, 0xcc, 0xcc, 0xc0, // ....##..##..##..##..##..##......
0x3f, 0xff, 0xff, 0xfc, // ..############################..
0x3f, 0xff, 0xff, 0xfc, // ..############################..
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
};
const uint8_t notes[128] = {
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0xc3, 0x0c, 0x00, // ........##....##....##..........
0x00, 0xc3, 0x0c, 0x00, // ........##....##....##..........
0x00, 0xc3, 0x0c, 0x00, // ........##....##....##..........
0x00, 0xc3, 0x0c, 0x00, // ........##....##....##..........
0x0f, 0xff, 0xff, 0xf0, // ....########################....
0x0f, 0xff, 0xff, 0xf0, // ....########################....
0x0c, 0xc3, 0x0c, 0x30, // ....##..##....##....##....##....
0x0c, 0xc3, 0x0c, 0x30, // ....##..##....##....##....##....
0x0c, 0x00, 0x00, 0x30, // ....##....................##....
0x0c, 0x00, 0x00, 0x30, // ....##....................##....
0x0c, 0x00, 0x00, 0x30, // ....##....................##....
0x0c, 0x00, 0x00, 0x30, // ....##....................##....
0x0c, 0xff, 0xff, 0x30, // ....##..################..##....
0x0c, 0xff, 0xff, 0x30, // ....##..################..##....
0x0c, 0x00, 0x00, 0x30, // ....##....................##....
0x0c, 0x00, 0x00, 0x30, // ....##....................##....
0x0c, 0xff, 0xff, 0x30, // ....##..################..##....
0x0c, 0xff, 0xff, 0x30, // ....##..################..##....
0x0c, 0x00, 0x00, 0x30, // ....##....................##....
0x0c, 0x00, 0x00, 0x30, // ....##....................##....
0x0c, 0xff, 0xc0, 0x30, // ....##..##########........##....
0x0c, 0xff, 0xc0, 0x30, // ....##..##########........##....
0x0c, 0x00, 0x00, 0x30, // ....##....................##....
0x0c, 0x00, 0x00, 0x30, // ....##....................##....
0x0c, 0x00, 0x00, 0x30, // ....##....................##....
0x0c, 0x00, 0x00, 0x30, // ....##....................##....
0x0f, 0xff, 0xff, 0xf0, // ....########################....
0x0f, 0xff, 0xff, 0xf0, // ....########################....
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
};
const uint8_t settings[128] = {
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x0f, 0xf0, 0x00, // ............########............
0x00, 0x0f, 0xf0, 0x00, // ............########............
0x03, 0xcc, 0x33, 0xc0, // ......####..##....##..####......
0x03, 0xcc, 0x33, 0xc0, // ......####..##....##..####......
0x0c, 0x30, 0x0c, 0x30, // ....##....##........##....##....
0x0c, 0x30, 0x0c, 0x30, // ....##....##........##....##....
0x0c, 0x00, 0x00, 0x30, // ....##....................##....
0x0c, 0x00, 0x00, 0x30, // ....##....................##....
0x03, 0x0f, 0xf0, 0xc0, // ......##....########....##......
0x03, 0x0f, 0xf0, 0xc0, // ......##....########....##......
0x3c, 0x3c, 0x3c, 0x3c, // ..####....####....####....####..
0x3c, 0x3c, 0x3c, 0x3c, // ..####....####....####....####..
0x30, 0x30, 0x0c, 0x0c, // ..##......##........##......##..
0x30, 0x30, 0x0c, 0x0c, // ..##......##........##......##..
0x30, 0x30, 0x0c, 0x0c, // ..##......##........##......##..
0x30, 0x30, 0x0c, 0x0c, // ..##......##........##......##..
0x3c, 0x3c, 0x3c, 0x3c, // ..####....####....####....####..
0x3c, 0x3c, 0x3c, 0x3c, // ..####....####....####....####..
0x03, 0x0f, 0xf0, 0xc0, // ......##....########....##......
0x03, 0x0f, 0xf0, 0xc0, // ......##....########....##......
0x0c, 0x00, 0x00, 0x30, // ....##....................##....
0x0c, 0x00, 0x00, 0x30, // ....##....................##....
0x0c, 0x30, 0x0c, 0x30, // ....##....##........##....##....
0x0c, 0x30, 0x0c, 0x30, // ....##....##........##....##....
0x03, 0xcc, 0x33, 0xc0, // ......####..##....##..####......
0x03, 0xcc, 0x33, 0xc0, // ......####..##....##..####......
0x00, 0x0f, 0xf0, 0x00, // ............########............
0x00, 0x0f, 0xf0, 0x00, // ............########............
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
};
const uint8_t shell[128] = {
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x3c, 0x00, 0x00, 0x00, // ..####..........................
0x3c, 0x00, 0x00, 0x00, // ..####..........................
0x0f, 0x00, 0x00, 0x00, // ....####........................
0x0f, 0x00, 0x00, 0x00, // ....####........................
0x03, 0xc0, 0x00, 0x00, // ......####......................
0x03, 0xc0, 0x00, 0x00, // ......####......................
0x00, 0xf0, 0x00, 0x00, // ........####....................
0x00, 0xf0, 0x00, 0x00, // ........####....................
0x00, 0x3c, 0x00, 0x00, // ..........####..................
0x00, 0x3c, 0x00, 0x00, // ..........####..................
0x00, 0x0f, 0x00, 0x00, // ............####................
0x00, 0x0f, 0x00, 0x00, // ............####................
0x00, 0x3c, 0x00, 0x00, // ..........####..................
0x00, 0x3c, 0x00, 0x00, // ..........####..................
0x00, 0xf0, 0x00, 0x00, // ........####....................
0x00, 0xf0, 0x00, 0x00, // ........####....................
0x03, 0xc0, 0x00, 0x00, // ......####......................
0x03, 0xc0, 0x00, 0x00, // ......####......................
0x0f, 0x00, 0x00, 0x00, // ....####........................
0x0f, 0x00, 0x00, 0x00, // ....####........................
0x3c, 0x00, 0xff, 0xfc, // ..####..........##############..
0x3c, 0x00, 0xff, 0xfc, // ..####..........##############..
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
};
const uint8_t ssh[128] = {
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x0f, 0xf0, 0x00, 0x00, // ....########....................
0x0f, 0xf0, 0x00, 0x00, // ....########....................
0x30, 0x0c, 0x00, 0x00, // ..##........##..................
0x30, 0x0c, 0x00, 0x00, // ..##........##..................
0xc0, 0x03, 0x00, 0x00, // ##............##................
0xc0, 0x03, 0x00, 0x00, // ##............##................
0xc3, 0xc3, 0xff, 0xff, // ##....####....##################
0xc3, 0xc3, 0xff, 0xff, // ##....####....##################
0xc3, 0xc3, 0x00, 0xcc, // ##....####....##........##..##..
0xc3, 0xc3, 0x00, 0xcc, // ##....####....##........##..##..
0xc0, 0x03, 0x00, 0xcc, // ##............##........##..##..
0xc0, 0x03, 0x00, 0xcc, // ##............##........##..##..
0x30, 0x0c, 0x00, 0x00, // ..##........##..................
0x30, 0x0c, 0x00, 0x00, // ..##........##..................
0x0f, 0xf0, 0x00, 0x00, // ....########....................
0x0f, 0xf0, 0x00, 0x00, // ....########....................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
};
const uint8_t storage[128] = {
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x03, 0xff, 0xf0, 0x00, // ......##############............
0x03, 0xff, 0xf0, 0x00, // ......##############............
0x03, 0x00, 0x0c, 0x00, // ......##............##..........
0x03, 0x00, 0x0c, 0x00, // ......##............##..........
0x03, 0x33, 0x03, 0x00, // ......##..##..##......##........
0x03, 0x33, 0x03, 0x00, // ......##..##..##......##........
0x03, 0x33, 0x00, 0xc0, // ......##..##..##........##......
0x03, 0x33, 0x00, 0xc0, // ......##..##..##........##......
0x03, 0x33, 0x00, 0xc0, // ......##..##..##........##......
0x03, 0x33, 0x00, 0xc0, // ......##..##..##........##......
0x03, 0x33, 0x00, 0xc0, // ......##..##..##........##......
0x03, 0x33, 0x00, 0xc0, // ......##..##..##........##......
0x03, 0x00, 0x00, 0xc0, // ......##................##......
0x03, 0x00, 0x00, 0xc0, // ......##................##......
0x03, 0x00, 0x00, 0xc0, // ......##................##......
0x03, 0x00, 0x00, 0xc0, // ......##................##......
0x03, 0x3f, 0xfc, 0xc0, // ......##..############..##......
0x03, 0x3f, 0xfc, 0xc0, // ......##..############..##......
0x03, 0x30, 0x0c, 0xc0, // ......##..##........##..##......
0x03, 0x30, 0x0c, 0xc0, // ......##..##........##..##......
0x03, 0x30, 0x0c, 0xc0, // ......##..##........##..##......
0x03, 0x30, 0x0c, 0xc0, // ......##..##........##..##......
0x03, 0x3f, 0xfc, 0xc0, // ......##..############..##......
0x03, 0x3f, 0xfc, 0xc0, // ......##..############..##......
0x03, 0x00, 0x00, 0xc0, // ......##................##......
0x03, 0x00, 0x00, 0xc0, // ......##................##......
0x03, 0xff, 0xff, 0xc0, // ......####################......
0x03, 0xff, 0xff, 0xc0, // ......####################......
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
};
const uint8_t system[128] = {
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x30, 0x0c, 0x00, // ..........##........##..........
0x00, 0x30, 0x0c, 0x00, // ..........##........##..........
0x00, 0x30, 0x0c, 0x00, // ..........##........##..........
0x00, 0x30, 0x0c, 0x00, // ..........##........##..........
0x00, 0xff, 0xff, 0x00, // ........################........
0x00, 0xff, 0xff, 0x00, // ........################........
0x0f, 0xc0, 0x03, 0xf0, // ....######............######....
0x0f, 0xc0, 0x03, 0xf0, // ....######............######....
0x00, 0xcf, 0xf3, 0x00, // ........##..########..##........
0x00, 0xcf, 0xf3, 0x00, // ........##..########..##........
0x00, 0xcf, 0xf3, 0x00, // ........##..########..##........
0x00, 0xcf, 0xf3, 0x00, // ........##..########..##........
0x00, 0xcf, 0xf3, 0x00, // ........##..########..##........
0x00, 0xcf, 0xf3, 0x00, // ........##..########..##........
0x00, 0xcf, 0xf3, 0x00, // ........##..########..##........
0x00, 0xcf, 0xf3, 0x00, // ........##..########..##........
0x0f, 0xc0, 0x03, 0xf0, // ....######............######....
0x0f, 0xc0, 0x03, 0xf0, // ....######............######....
0x00, 0xff, 0xff, 0x00, // ........################........
0x00, 0xff, 0xff, 0x00, // ........################........
0x00, 0x30, 0x0c, 0x00, // ..........##........##..........
0x00, 0x30, 0x0c, 0x00, // ..........##........##..........
0x00, 0x30, 0x0c, 0x00, // ..........##........##..........
0x00, 0x30, 0x0c, 0x00, // ..........##........##..........
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
};
const uint8_t wifi_tools[128] = {
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x3f, 0xfc, 0x00, // ..........############..........
0x00, 0x3f, 0xfc, 0x00, // ..........############..........
0x03, 0xc0, 0x03, 0xc0, // ......####............####......
0x03, 0xc0, 0x03, 0xc0, // ......####............####......
0x0c, 0x00, 0x00, 0x30, // ....##....................##....
0x0c, 0x00, 0x00, 0x30, // ....##....................##....
0x30, 0x3f, 0xfc, 0x0c, // ..##......############......##..
0x30, 0x3f, 0xfc, 0x0c, // ..##......############......##..
0x03, 0xc0, 0x03, 0xc0, // ......####............####......
0x03, 0xc0, 0x03, 0xc0, // ......####............####......
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x0f, 0xf0, 0x00, // ............########............
0x00, 0x0f, 0xf0, 0x00, // ............########............
0x00, 0x30, 0x0c, 0x00, // ..........##........##..........
0x00, 0x30, 0x0c, 0x00, // ..........##........##..........
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x03, 0xc0, 0x00, // ..............####..............
0x00, 0x03, 0xc0, 0x00, // ..............####..............
0x00, 0x03, 0xc0, 0x00, // ..............####..............
0x00, 0x03, 0xc0, 0x00, // ..............####..............
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
0x00, 0x00, 0x00, 0x00, // ................................
};
} // namespace roro::icons
+23
View File
@@ -0,0 +1,23 @@
// Made by scripts/make_icons.py from assets/icons/*.png: change the pictures, not this file.
#pragma once
#include <cstdint>
namespace roro::icons {
// One bit a pixel, the leftmost first, a row after another: what Canvas::drawBitmap takes.
constexpr int kSize = 32;
extern const uint8_t gemini[128]; // gemini.png
extern const uint8_t gnss[128]; // gnss.png
extern const uint8_t irc[128]; // irc.png
extern const uint8_t lora[128]; // lora.png
extern const uint8_t notes[128]; // notes.png
extern const uint8_t settings[128]; // settings.png
extern const uint8_t shell[128]; // shell.png
extern const uint8_t ssh[128]; // ssh.png
extern const uint8_t storage[128]; // storage.png
extern const uint8_t system[128]; // system.png
extern const uint8_t wifi_tools[128]; // wifi-tools.png
} // namespace roro::icons
+97
View File
@@ -0,0 +1,97 @@
#include <unity.h>
#include "grid_model.h"
using namespace roro;
void setUp() {}
void tearDown() {}
void test_empty_grid_has_no_selection() {
GridModel g(4, 3);
TEST_ASSERT_EQUAL(-1, g.selected());
g.right();
g.down();
TEST_ASSERT_EQUAL(-1, g.selected());
TEST_ASSERT_EQUAL(0, g.rows());
}
void test_left_and_right_go_through_every_item() {
GridModel g(4, 3);
g.setCount(11);
TEST_ASSERT_EQUAL(0, g.selected());
g.right();
TEST_ASSERT_EQUAL(1, g.selected());
g.select(3);
g.right(); // onto the next row
TEST_ASSERT_EQUAL(4, g.selected());
g.left();
TEST_ASSERT_EQUAL(3, g.selected());
g.select(10);
g.right(); // around
TEST_ASSERT_EQUAL(0, g.selected());
g.left();
TEST_ASSERT_EQUAL(10, g.selected());
}
void test_up_and_down_stay_in_the_column() {
GridModel g(4, 3);
g.setCount(11); // rows of 4, 4 and 3
g.select(1);
g.down();
TEST_ASSERT_EQUAL(5, g.selected());
g.down();
TEST_ASSERT_EQUAL(9, g.selected());
g.down(); // around
TEST_ASSERT_EQUAL(1, g.selected());
g.up();
TEST_ASSERT_EQUAL(9, g.selected());
}
void test_a_short_last_row_gives_its_last_item() {
GridModel g(4, 3);
g.setCount(11);
g.select(7); // the fourth column, which the last row doesn't have
g.down();
TEST_ASSERT_EQUAL(10, g.selected());
g.select(3);
g.up();
TEST_ASSERT_EQUAL(10, g.selected());
}
void test_rows_scroll_to_keep_the_selection_in_view() {
GridModel g(4, 3);
g.setCount(18); // 5 rows
TEST_ASSERT_EQUAL(5, g.rows());
TEST_ASSERT_EQUAL(0, g.firstVisibleRow());
g.select(12); // the fourth row
TEST_ASSERT_EQUAL(1, g.firstVisibleRow());
g.select(17);
TEST_ASSERT_EQUAL(2, g.firstVisibleRow());
g.select(5);
TEST_ASSERT_EQUAL(1, g.firstVisibleRow());
g.select(0);
TEST_ASSERT_EQUAL(0, g.firstVisibleRow());
}
void test_a_smaller_count_keeps_the_selection_inside() {
GridModel g(4, 3);
g.setCount(18);
g.select(17);
g.setCount(6);
TEST_ASSERT_EQUAL(5, g.selected());
TEST_ASSERT_EQUAL(0, g.firstVisibleRow());
g.setCount(0);
TEST_ASSERT_EQUAL(-1, g.selected());
}
int main() {
UNITY_BEGIN();
RUN_TEST(test_empty_grid_has_no_selection);
RUN_TEST(test_left_and_right_go_through_every_item);
RUN_TEST(test_up_and_down_stay_in_the_column);
RUN_TEST(test_a_short_last_row_gives_its_last_item);
RUN_TEST(test_rows_scroll_to_keep_the_selection_in_view);
RUN_TEST(test_a_smaller_count_keeps_the_selection_inside);
return UNITY_END();
}
@@ -46,6 +46,15 @@ void test_toggle_rows_flip_on_select() {
TEST_ASSERT_FALSE(f.settings.getBool(Setting::Sound));
}
void test_the_launcher_is_a_grid_or_a_list() {
Fixture f;
int launcher = f.row(SettingsMenu::Row::Launcher);
TEST_ASSERT_EQUAL_STRING("Grid", f.menu.value(launcher).c_str());
f.menu.toggle(launcher);
TEST_ASSERT_TRUE(f.settings.getBool(Setting::LauncherList));
TEST_ASSERT_EQUAL_STRING("List", f.menu.value(launcher).c_str());
}
void test_choice_rows_list_options_and_mark_the_current_one() {
Fixture f;
int tz = f.row(SettingsMenu::Row::Timezone);
@@ -161,6 +170,7 @@ int main() {
RUN_TEST(test_every_row_has_a_label);
RUN_TEST(test_values_are_human_readable);
RUN_TEST(test_toggle_rows_flip_on_select);
RUN_TEST(test_the_launcher_is_a_grid_or_a_list);
RUN_TEST(test_choice_rows_list_options_and_mark_the_current_one);
RUN_TEST(test_choosing_applies_the_setting);
RUN_TEST(test_choice_that_breaks_a_rule_is_refused_with_a_reason);