diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index f007221..4991b78 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -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: | diff --git a/README.md b/README.md index e950790..671e9e3 100644 --- a/README.md +++ b/README.md @@ -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/.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). diff --git a/assets/icons/gemini.png b/assets/icons/gemini.png new file mode 100644 index 0000000..2734eaf Binary files /dev/null and b/assets/icons/gemini.png differ diff --git a/assets/icons/gnss.png b/assets/icons/gnss.png new file mode 100644 index 0000000..91f6242 Binary files /dev/null and b/assets/icons/gnss.png differ diff --git a/assets/icons/irc.png b/assets/icons/irc.png new file mode 100644 index 0000000..a315b19 Binary files /dev/null and b/assets/icons/irc.png differ diff --git a/assets/icons/lora.png b/assets/icons/lora.png new file mode 100644 index 0000000..e472eef Binary files /dev/null and b/assets/icons/lora.png differ diff --git a/assets/icons/notes.png b/assets/icons/notes.png new file mode 100644 index 0000000..db11f3c Binary files /dev/null and b/assets/icons/notes.png differ diff --git a/assets/icons/settings.png b/assets/icons/settings.png new file mode 100644 index 0000000..4a31161 Binary files /dev/null and b/assets/icons/settings.png differ diff --git a/assets/icons/shell.png b/assets/icons/shell.png new file mode 100644 index 0000000..d4c61cb Binary files /dev/null and b/assets/icons/shell.png differ diff --git a/assets/icons/ssh.png b/assets/icons/ssh.png new file mode 100644 index 0000000..ff21013 Binary files /dev/null and b/assets/icons/ssh.png differ diff --git a/assets/icons/storage.png b/assets/icons/storage.png new file mode 100644 index 0000000..e2261f3 Binary files /dev/null and b/assets/icons/storage.png differ diff --git a/assets/icons/system.png b/assets/icons/system.png new file mode 100644 index 0000000..e6e391c Binary files /dev/null and b/assets/icons/system.png differ diff --git a/assets/icons/wifi-tools.png b/assets/icons/wifi-tools.png new file mode 100644 index 0000000..784b46a Binary files /dev/null and b/assets/icons/wifi-tools.png differ diff --git a/docs/milestones/U1.md b/docs/milestones/U1.md index e6638ca..571ef5d 100644 --- a/docs/milestones/U1.md +++ b/docs/milestones/U1.md @@ -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. diff --git a/lib/apps_model/src/settings_menu.cpp b/lib/apps_model/src/settings_menu.cpp index 26f7f9b..bec139c 100644 --- a/lib/apps_model/src/settings_menu.cpp +++ b/lib/apps_model/src/settings_menu.cpp @@ -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)); diff --git a/lib/apps_model/src/settings_menu.h b/lib/apps_model/src/settings_menu.h index 41bf052..661d1e5 100644 --- a/lib/apps_model/src/settings_menu.h +++ b/lib/apps_model/src/settings_menu.h @@ -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) {} diff --git a/lib/core/src/app.h b/lib/core/src/app.h index ca8bee1..a880588 100644 --- a/lib/core/src/app.h +++ b/lib/core/src/app.h @@ -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() { diff --git a/lib/core/src/app_keys.h b/lib/core/src/app_keys.h index 73067b7..935319b 100644 --- a/lib/core/src/app_keys.h +++ b/lib/core/src/app_keys.h @@ -48,7 +48,7 @@ inline constexpr KeyHelp kText[] = { // launcher: The Launcher inline constexpr KeyHelp kLauncher[] = { - {"; .", "up, down"}, + {"; . , /", "move"}, {"Enter", "open the App"}, }; diff --git a/lib/core/src/app_manager.h b/lib/core/src/app_manager.h index 6ab6467..c97fc14 100644 --- a/lib/core/src/app_manager.h +++ b/lib/core/src/app_manager.h @@ -1,5 +1,7 @@ #pragma once +#include + #include #include @@ -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; diff --git a/lib/services/src/settings.cpp b/lib/services/src/settings.cpp index 018c51e..d8fd9f5 100644 --- a/lib/services/src/settings.cpp +++ b/lib/services/src/settings.cpp @@ -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(Setting::Count), "every Setting needs a definition"); diff --git a/lib/services/src/settings.h b/lib/services/src/settings.h index 60ab16e..a857b04 100644 --- a/lib/services/src/settings.h +++ b/lib/services/src/settings.h @@ -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 }; diff --git a/lib/ui/src/grid_model.cpp b/lib/ui/src/grid_model.cpp new file mode 100644 index 0000000..94082ae --- /dev/null +++ b/lib/ui/src/grid_model.cpp @@ -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 diff --git a/lib/ui/src/grid_model.h b/lib/ui/src/grid_model.h new file mode 100644 index 0000000..e235e25 --- /dev/null +++ b/lib/ui/src/grid_model.h @@ -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 diff --git a/scripts/make_icons.py b/scripts/make_icons.py new file mode 100755 index 0000000..bb1e9d4 --- /dev/null +++ b/scripts/make_icons.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""Turns the Launcher's icons (assets/icons/.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 ", "", "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() diff --git a/site/content/dev/build/build-and-test.md b/site/content/dev/build/build-and-test.md index 58c0252..6ef5f98 100644 --- a/site/content/dev/build/build-and-test.md +++ b/site/content/dev/build/build-and-test.md @@ -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/.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). diff --git a/site/content/dev/milestones/u1.md b/site/content/dev/milestones/u1.md index af94d64..a3c6397 100644 --- a/site/content/dev/milestones/u1.md +++ b/site/content/dev/milestones/u1.md @@ -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. diff --git a/site/content/guide/basics.md b/site/content/guide/basics.md index b175408..82cb41a 100644 --- a/site/content/guide/basics.md +++ b/site/content/guide/basics.md @@ -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 Enter. 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 Enter. 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 diff --git a/site/content/guide/settings.md b/site/content/guide/settings.md index 88605ed..c7cf21a 100644 --- a/site/content/guide/settings.md +++ b/site/content/guide/settings.md @@ -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/)) | diff --git a/site/data/keys.toml b/site/data/keys.toml index 97549d8..3c8084a 100644 --- a/site/data/keys.toml +++ b/site/data/keys.toml @@ -36,7 +36,7 @@ rows = [ id = "launcher" title = "The Launcher" rows = [ - ["; .", "up, down"], + ["; . , /", "move"], ["Enter", "open the App"], ] diff --git a/src/apps/gnss_app.h b/src/apps/gnss_app.h index c32ce6f..bc0aa19 100644 --- a/src/apps/gnss_app.h +++ b/src/apps/gnss_app.h @@ -18,6 +18,7 @@ class GnssApp : public App { void update(uint32_t nowMs) override; void draw(Canvas& c) override; void help(std::vector& out) const override; + bool badge() const override { return gnss_.tracking(); } private: void drawPosition(Canvas& c); diff --git a/src/apps/irc_app.h b/src/apps/irc_app.h index c7f3457..5f900c8 100644 --- a/src/apps/irc_app.h +++ b/src/apps/irc_app.h @@ -26,6 +26,7 @@ class IrcApp : public App { void draw(Canvas& c) override; void help(std::vector& out) const override; const char* helpTitle() const override; + bool badge() const override { return irc_.totalUnread() > 0; } private: enum class Page { Chat, Settings }; diff --git a/src/apps/launcher_app.cpp b/src/apps/launcher_app.cpp index 10af6da..a8d5ae7 100644 --- a/src/apps/launcher_app.cpp +++ b/src/apps/launcher_app.cpp @@ -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(manager_->visibleApps().size())); } +void LauncherApp::onEnter() { + int count = static_cast(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(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 diff --git a/src/apps/launcher_app.h b/src/apps/launcher_app.h index 2c05b70..21cd77a 100644 --- a/src/apps/launcher_app.h +++ b/src/apps/launcher_app.h @@ -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& 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 diff --git a/src/apps/lora_scanner_app.h b/src/apps/lora_scanner_app.h index c42e3c3..cbc5ee1 100644 --- a/src/apps/lora_scanner_app.h +++ b/src/apps/lora_scanner_app.h @@ -28,6 +28,7 @@ class LoraScannerApp : public App { void draw(Canvas& c) override; void help(std::vector& out) const override; const char* helpTitle() const override; + bool badge() const override { return capture_.capturing(); } private: enum class View { Packets, Details, Presets, Sweep }; diff --git a/src/apps/ssh_app.h b/src/apps/ssh_app.h index d21271f..e76ecc6 100644 --- a/src/apps/ssh_app.h +++ b/src/apps/ssh_app.h @@ -28,6 +28,7 @@ class SshApp : public App { bool textEntryActive() const override { return view_ == View::Entry || (view_ == View::Session && !dialog_); } void help(std::vector& 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; diff --git a/src/main.cpp b/src/main.cpp index f08fd80..63d9926 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -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)}); diff --git a/src/ui/icons.cpp b/src/ui/icons.cpp new file mode 100644 index 0000000..f34f0c5 --- /dev/null +++ b/src/ui/icons.cpp @@ -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 diff --git a/src/ui/icons.h b/src/ui/icons.h new file mode 100644 index 0000000..0e1e610 --- /dev/null +++ b/src/ui/icons.h @@ -0,0 +1,23 @@ +// Made by scripts/make_icons.py from assets/icons/*.png: change the pictures, not this file. +#pragma once + +#include + +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 diff --git a/test/test_grid_model/test_grid_model.cpp b/test/test_grid_model/test_grid_model.cpp new file mode 100644 index 0000000..6e7d110 --- /dev/null +++ b/test/test_grid_model/test_grid_model.cpp @@ -0,0 +1,97 @@ +#include + +#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(); +} diff --git a/test/test_settings_menu/test_settings_menu.cpp b/test/test_settings_menu/test_settings_menu.cpp index acc1a26..293c36c 100644 --- a/test/test_settings_menu/test_settings_menu.cpp +++ b/test/test_settings_menu/test_settings_menu.cpp @@ -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);