Public Access
UI: seven themes, each dark and light, chosen in the Settings
Settings > Theme and Settings > Light or dark pick the colours of every screen (issue #10): roro9stack (the website's, the default), Catppuccin, Dracula, Nord, ANSI terminal, Gruvbox and Solarized. - A palette is 14 roles (lib/ui/src/palette.h). theme::kText and the others are now references into the one in use, so the Apps are unchanged; the main loop applies it when a Setting changes. - Every colour is one the RGB332 frame buffer holds exactly: the themes' own were rounded, and those that fell together or lost contrast were picked by hand. test_palette checks exactness and 18 contrast ratios for each palette. - The Sky view's constellation colours come from the theme. - `theme [0-6] [light|dark]` on the consoles. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0162FokPdvY2KsS4NBfwyWPk
This commit is contained in:
@@ -205,6 +205,7 @@ The Shell App (docs/milestones/S1.md) runs the commands below on the device's ow
|
||||
| `burst` | Publishes 5 Notifications at once |
|
||||
| `key up\|down\|left\|right\|select\|back\|home\|del\|tab\|space\|help\|shot`, or `key <char>` | Injects a key press (`help` is Fn+h: the keys of the screen that is showing; `shot` is Fn+p: a screenshot). `ctrl-`, `alt-` and `shift-` before it hold that key: `key ctrl-down`, `key alt-up`, `key ctrl-b` |
|
||||
| `sound on` / `sound off` | Toggles the Sound setting (beep + LED) |
|
||||
| `theme` / `theme <0-6> [light\|dark]` | Lists the colour themes, or picks one and its side |
|
||||
| `short` / `normal` | Screen timeouts 5 s / 10 s, or 30 s / 60 s |
|
||||
| `wifi add <ssid><TAB><password>` | Adds a Saved Network (so credentials stay out of the repo) |
|
||||
| `wifi ip <ssid> dhcp` / `wifi ip <ssid> <address>/<prefix> [gateway]` | A Saved Network's IP setting: Automatic, or Fixed. Add `try <seconds>` to go back to the previous setting unless `wifi ip keep` follows |
|
||||
|
||||
+26
-1
@@ -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**). The Launcher as a grid of icons (#9) is done, not released yet. Not started: screen recording (#17), 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) and themes (#10) are done, not released yet. Not started: screen recording (#17).
|
||||
|
||||
**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.
|
||||
|
||||
@@ -94,3 +94,28 @@ Every screen takes its colours from `src/ui/theme.h`, so the palette changed the
|
||||
| Text on a Toast | black | dark blue |
|
||||
|
||||
Looked at on the device, by screenshot: the Launcher, IRC, Wi-Fi Tools (menu, channel occupancy), GNSS (position, sky), Gemini, the LoRa Scanner (Sniffer, Sweep, presets), Storage, Notes, SSH, the Shell, System, Settings and the help panel. Not looked at: dialogs, a warning or a message Toast, the SSH terminal (which keeps its own 16 ANSI colours), the Setup screens.
|
||||
|
||||
## Themes (issue #10)
|
||||
|
||||
Seven themes, each with a dark and a light side, chosen in **Settings > Theme** and **Settings > Light or dark**: roro9stack (the website's, the default), Catppuccin (Mocha and Latte), Dracula (and Alucard), Nord, ANSI terminal, Gruvbox, Solarized.
|
||||
|
||||
| Question in the issue | Decided |
|
||||
|---|---|
|
||||
| Which themes | The seven above. Not the high-contrast and phosphor ones the issue suggested: ANSI terminal dark (grey and green on black) and light (black on white) come close |
|
||||
| Fonts and spacing too | No: colours only |
|
||||
| Themes from the SD card | No: built in |
|
||||
| A preview while picking | The Setting applies at once, so the Settings screen is the preview |
|
||||
| Constellation colours | From the theme (its accent, its "good", and three roles named red, yellow and violet), so they read on every background |
|
||||
| Light by day, dark by night | No |
|
||||
|
||||
- **A palette is 14 roles** (`lib/ui/src/palette.h`): background, text, muted, faint, the bar and its idle marks, accent, what is drawn on an accent, warning, message, good, and red, yellow and violet.
|
||||
- **`theme::kText` and the others are now references** into the palette in use (`src/ui/theme.h`), so no App changed: they still read as constants. The main loop applies the palette when either Setting changes, and draws everything again.
|
||||
- **RGB332.** Every colour in the table is one the frame buffer holds exactly. The themes' own colours were rounded to those; where two roles fell together or lost contrast, one was picked by hand (Nord's accent and muted text, Gruvbox's greys, Solarized's surfaces). `test_palette` checks that every colour is exact, and 18 contrast ratios for each of the 14 palettes (text on background at 4.5 or more, the rest at 2 to 3).
|
||||
- **What it costs in fidelity:** blue has four levels, so Dracula and Nord share a dark background (#242455), which was Catppuccin's too: Catppuccin dark is on black instead (its "crust"), with that blue for its bar and its brighter accents (peach, pink, green, sky), to tell it from Dracula; Solarized light's cream becomes white, and Gruvbox's dark brown becomes a dark olive (#242400).
|
||||
- `theme [0-6] [light|dark]` on the consoles lists them or picks one.
|
||||
|
||||
**Looked at on the device**, by screenshot: the Launcher in all 14; and in Gruvbox light, Settings, GNSS (position and sky), the Sweep, Storage, the help panel, Gemini and Wi-Fi Tools' channel occupancy. Nothing was drawn for black only.
|
||||
|
||||
**Kept as they are:** the SSH terminal's 16 ANSI colours and its black background, the Sweep's waterfall scale, pictures, and the screen shown while the device powers off.
|
||||
|
||||
**Not looked at:** the other 12 palettes beyond the Launcher, dialogs and Toasts in any theme but the default, the Setup screens. In Gruvbox light, the Sky view's GPS, GLONASS and BeiDou colours are three close shades of brown and red.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "settings_menu.h"
|
||||
|
||||
#include "choices.h"
|
||||
#include "palette.h"
|
||||
|
||||
namespace roro {
|
||||
|
||||
@@ -19,7 +20,8 @@ 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::Launcher, Kind::Toggle, "Launcher"}, {Row::Sound, Kind::Toggle, "Sound & LED"},
|
||||
{Row::OffTimeout, Kind::Choice, "Screen off after"}, {Row::Launcher, Kind::Toggle, "Launcher"},
|
||||
{Row::Theme, Kind::Choice, "Theme"}, {Row::ThemeLight, Kind::Toggle, "Light or dark"}, {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"},
|
||||
@@ -81,6 +83,8 @@ std::string SettingsMenu::value(int i) const {
|
||||
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::Theme: return ui::themeName(settings_.getInt(Setting::Theme));
|
||||
case Row::ThemeLight: return settings_.getBool(Setting::ThemeLight) ? "Light" : "Dark";
|
||||
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";
|
||||
@@ -98,6 +102,11 @@ std::vector<std::string> SettingsMenu::choices(int i) const {
|
||||
switch (row(i)) {
|
||||
case Row::Region: return labels(kRegions);
|
||||
case Row::Timezone: return labels(kTimezones);
|
||||
case Row::Theme: {
|
||||
std::vector<std::string> names;
|
||||
for (int t = 0; t < ui::kThemeCount; ++t) names.push_back(ui::themeName(t));
|
||||
return names;
|
||||
}
|
||||
case Row::DimTimeout: return durations(kDimSeconds);
|
||||
case Row::OffTimeout: return durations(kOffSeconds);
|
||||
default: return {};
|
||||
@@ -108,6 +117,7 @@ int SettingsMenu::currentChoice(int i) const {
|
||||
switch (row(i)) {
|
||||
case Row::Region: return indexOf(kRegions, settings_.getString(Setting::Region));
|
||||
case Row::Timezone: return indexOf(kTimezones, settings_.getString(Setting::Timezone));
|
||||
case Row::Theme: return settings_.getInt(Setting::Theme);
|
||||
case Row::DimTimeout: return indexOf(kDimSeconds, settings_.getInt(Setting::DimTimeoutS));
|
||||
case Row::OffTimeout: return indexOf(kOffSeconds, settings_.getInt(Setting::OffTimeoutS));
|
||||
default: return -1;
|
||||
@@ -121,6 +131,7 @@ std::string SettingsMenu::choose(int i, int c) {
|
||||
settings_.setBool(Setting::RegionConfirmed, true);
|
||||
return "";
|
||||
case Row::Timezone: settings_.setString(Setting::Timezone, kTimezones[c].value); return "";
|
||||
case Row::Theme: settings_.setInt(Setting::Theme, c); return "";
|
||||
case Row::DimTimeout:
|
||||
return settings_.setInt(Setting::DimTimeoutS, kDimSeconds[c]) ? "" : "Dimming must happen before screen off";
|
||||
case Row::OffTimeout:
|
||||
@@ -130,6 +141,7 @@ std::string SettingsMenu::choose(int i, int c) {
|
||||
}
|
||||
|
||||
void SettingsMenu::toggle(int i) {
|
||||
if (row(i) == Row::ThemeLight) settings_.setBool(Setting::ThemeLight, !settings_.getBool(Setting::ThemeLight));
|
||||
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));
|
||||
|
||||
@@ -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, Launcher, Sound, Gnss, GnssQuiet, Coordinates, ProbeMacs, Wifi, CheckUpdates, Firmware, Vpn, DebugConsole, About };
|
||||
enum class Row { LongName, ShortName, Region, Timezone, Brightness, DimTimeout, OffTimeout, Launcher, Theme, ThemeLight, 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) {}
|
||||
|
||||
@@ -54,6 +54,8 @@ const Definition kDefinitions[] = {
|
||||
{"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
|
||||
{"theme", Kind::Int, 0, nullptr, 0, 6}, // roro9stack's own; ui::kThemeCount of them
|
||||
{"theme_light", Kind::Bool, 0, nullptr, 0, 1},
|
||||
};
|
||||
static_assert(sizeof(kDefinitions) / sizeof(kDefinitions[0]) == static_cast<size_t>(Setting::Count),
|
||||
"every Setting needs a definition");
|
||||
|
||||
@@ -42,6 +42,8 @@ enum class Setting : uint8_t {
|
||||
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)
|
||||
Theme, // int: the colour theme, an index into ui::palette (issue #10)
|
||||
ThemeLight, // bool: its light side instead of its dark one
|
||||
Count
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
#include "palette.h"
|
||||
|
||||
namespace roro::ui {
|
||||
|
||||
namespace {
|
||||
// 0xRRGGBB to RGB565. Every colour below is one the RGB332 frame buffer holds exactly: three bits
|
||||
// of red and of green (00 24 49 6d 92 b6 db ff) and two of blue (00 55 aa ff). The themes' own
|
||||
// colours were rounded to those, then the ones that fell together or lost their contrast were
|
||||
// chosen by hand (test_palette checks both).
|
||||
constexpr uint16_t c(uint32_t rgb) {
|
||||
return static_cast<uint16_t>(((rgb >> 19) & 0x1F) << 11 | ((rgb >> 10) & 0x3F) << 5 | ((rgb >> 3) & 0x1F));
|
||||
}
|
||||
|
||||
const char* const kNames[kThemeCount] = {"roro9stack", "Catppuccin", "Dracula", "Nord", "ANSI terminal", "Gruvbox", "Solarized"};
|
||||
|
||||
// background, text, muted, faint, bar, barMuted, accent, onAccent, warning, message, good, red, yellow, violet
|
||||
constexpr Palette kPalettes[kThemeCount][2] = {
|
||||
{
|
||||
{c(0x000000), c(0xffffff), c(0x9292aa), c(0x494955), c(0x494955), c(0xb6b6aa), c(0x00dbff), c(0x000055), c(0xff9200), c(0xff92ff), c(0x49db55), c(0xff2455), c(0xffdb00), c(0xb692ff)}, // roro9stack, dark
|
||||
{c(0xffffff), c(0x000055), c(0x4949aa), c(0xb6dbff), c(0xb6dbff), c(0x4949aa), c(0x0092aa), c(0xffffff), c(0xb64900), c(0xb600aa), c(0x006d00), c(0xb60000), c(0x926d00), c(0x6d49ff)}, // roro9stack, light
|
||||
},
|
||||
{
|
||||
{c(0x000000), c(0xdbdbff), c(0x9292ff), c(0x494955), c(0x242455), c(0xb6b6ff), c(0xdbb6ff), c(0x000000), c(0xffb655), c(0xff92ff), c(0x92ffaa), c(0xff6daa), c(0xffdb55), c(0x6db6ff)}, // Catppuccin, dark
|
||||
{c(0xffffff), c(0x242455), c(0x6d6daa), c(0xb6b6aa), c(0xb6b6aa), c(0x494955), c(0x9249ff), c(0xffffff), c(0xdb6d00), c(0xdb6daa), c(0x499255), c(0xdb0055), c(0xdb9200), c(0x246dff)}, // Catppuccin, light
|
||||
},
|
||||
{
|
||||
{c(0x242455), c(0xffffff), c(0x6d6daa), c(0x494955), c(0x494955), c(0xb6b6aa), c(0xb692ff), c(0x242455), c(0xffb655), c(0xff6daa), c(0x49ff55), c(0xff4955), c(0xffffaa), c(0x92dbff)}, // Dracula, dark
|
||||
{c(0xffffff), c(0x242400), c(0x6d6d55), c(0xdbdbff), c(0xdbdbff), c(0x6d6d55), c(0x6d49aa), c(0xffffff), c(0x924900), c(0x922455), c(0x246d00), c(0xdb4900), c(0x926d00), c(0x006daa)}, // Dracula, light
|
||||
},
|
||||
{
|
||||
{c(0x242455), c(0xdbffff), c(0x9292aa), c(0x494955), c(0x494955), c(0xdbdbff), c(0x92dbff), c(0x242455), c(0xdb9255), c(0xb692aa), c(0x92b6aa), c(0xb66d55), c(0xdbdbaa), c(0x9292aa)}, // Nord, dark
|
||||
{c(0xdbffff), c(0x242455), c(0x494955), c(0xdbdbff), c(0xdbdbff), c(0x494955), c(0x496daa), c(0xdbffff), c(0xb64900), c(0x9249aa), c(0x496d00), c(0xb66d55), c(0xb66d00), c(0x9292aa)}, // Nord, light
|
||||
},
|
||||
{
|
||||
{c(0x000000), c(0xb6b6aa), c(0x6d6d55), c(0x494955), c(0x0000aa), c(0xb6b6aa), c(0x49ff55), c(0x000000), c(0xffff55), c(0xff49ff), c(0x49ffff), c(0xff4955), c(0xffff55), c(0x4949ff)}, // ANSI terminal, dark
|
||||
{c(0xffffff), c(0x000000), c(0x494955), c(0xb6b6aa), c(0xb6b6aa), c(0x494955), c(0x0000aa), c(0xffffff), c(0xb64900), c(0xb600aa), c(0x006d00), c(0xb60000), c(0xb64900), c(0x00b6aa)}, // ANSI terminal, light
|
||||
},
|
||||
{
|
||||
{c(0x242400), c(0xdbdbaa), c(0x929255), c(0x492400), c(0x492400), c(0xdbb6aa), c(0xffb655), c(0x242400), c(0xff9200), c(0xdb92aa), c(0xb6b600), c(0xff4955), c(0xffb655), c(0x92b6aa)}, // Gruvbox, dark
|
||||
{c(0xffffaa), c(0x494955), c(0x6d6d55), c(0xdbdbaa), c(0xdbdbaa), c(0x494955), c(0xb64900), c(0xffffaa), c(0xb66d00), c(0x924955), c(0x6d6d00), c(0x920000), c(0xb66d00), c(0x006d55)}, // Gruvbox, light
|
||||
},
|
||||
{
|
||||
{c(0x002455), c(0xb6b6aa), c(0x6d92aa), c(0x004955), c(0x004955), c(0x9292aa), c(0x2492aa), c(0xffffff), c(0xdb4900), c(0xdb24aa), c(0x929200), c(0xdb2455), c(0xb69200), c(0x6d6daa)}, // Solarized, dark
|
||||
{c(0xffffff), c(0x244955), c(0x9292aa), c(0xdbdbaa), c(0xdbdbaa), c(0x6d6daa), c(0x2492aa), c(0xffffff), c(0xdb4900), c(0xdb24aa), c(0x929200), c(0xdb2455), c(0xb69200), c(0x6d6daa)}, // Solarized, light
|
||||
},
|
||||
};
|
||||
} // namespace
|
||||
|
||||
const char* themeName(int theme) { return kNames[theme < 0 || theme >= kThemeCount ? 0 : theme]; }
|
||||
|
||||
const Palette& palette(int theme, bool light) { return kPalettes[theme < 0 || theme >= kThemeCount ? 0 : theme][light ? 1 : 0]; }
|
||||
|
||||
uint32_t rgb888(uint16_t c) {
|
||||
// As the display shows an RGB332 pixel: the bits kept, spread over 8.
|
||||
uint32_t r = ((c >> 13) & 7) * 0x49 >> 1, g = ((c >> 8) & 7) * 0x49 >> 1, b = ((c >> 3) & 3) * 0x55;
|
||||
return r << 16 | g << 8 | b;
|
||||
}
|
||||
|
||||
} // namespace roro::ui
|
||||
@@ -0,0 +1,29 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
// The colour themes (issue #10): each a dark and a light set of the same roles, as RGB565.
|
||||
namespace roro::ui {
|
||||
|
||||
struct Palette {
|
||||
uint16_t background, text;
|
||||
uint16_t muted; // secondary text
|
||||
uint16_t faint; // grid lines, what stays behind
|
||||
uint16_t bar; // the Status Bar's background
|
||||
uint16_t barMuted; // what is idle, on the bar
|
||||
uint16_t accent; // what is chosen, what is live: also the fill behind a selected row
|
||||
uint16_t onAccent; // text on an accent, warning or message fill
|
||||
uint16_t warning;
|
||||
uint16_t message; // someone wrote
|
||||
uint16_t good; // a Fix, the best of a set
|
||||
uint16_t red, yellow, violet; // to tell things apart (the constellations of the Sky view)
|
||||
};
|
||||
|
||||
constexpr int kThemeCount = 7;
|
||||
const char* themeName(int theme); // "roro9stack", "Catppuccin", "Dracula", ...
|
||||
const Palette& palette(int theme, bool light); // out of range: the first
|
||||
|
||||
// What the 8-bit frame buffer shows for a colour, as 0xRRGGBB.
|
||||
uint32_t rgb888(uint16_t rgb565);
|
||||
|
||||
} // namespace roro::ui
|
||||
@@ -37,7 +37,7 @@ gemini get <url> fetch a Gemini page and report header, size, certificate, heap
|
||||
irc start | irc stop | irc dump | irc say <buffer> <text>
|
||||
install <path.ota> Update from SD
|
||||
update check | update list | update status | update install <tag> the project's releases on Gitea
|
||||
sd card | sd list | cat <path> | log <text> | burst | sound on|off | short | normal
|
||||
sd card | sd list | cat <path> | log <text> | burst | sound on|off | theme [0-6] [light|dark] | short | normal
|
||||
Irc | Wifi | Gnss | Gemini | Lora | Storage | Notes | Shell | System | Settings open that App: a capital letter is an App, not a command
|
||||
ping <host> [count] [size] | nslookup <name> [server] | port <host> <port> | traceroute <host> | cancel is it there, does its name resolve, is its port open, which way; one at a time
|
||||
tls <host> [port] | ntp [server] a TLS handshake: who the certificate is for, by whom, until when, and whether this device trusts it; a time server's clock against this one
|
||||
@@ -71,6 +71,7 @@ In **Safe Mode** (see [Crashes and Safe Mode](/dev/debug/crashes/)) only a few r
|
||||
| `burst` | Publishes 5 Notifications at once |
|
||||
| `key up\|down\|left\|right\|select\|back\|home\|del\|tab\|space\|help\|shot`, or `key <char>` | Injects a key press (`help` is Fn+h: the keys of the screen that is showing; `shot` is Fn+p: a screenshot). `ctrl-`, `alt-` and `shift-` before it hold that key: `key ctrl-down`, `key alt-up`, `key ctrl-b` |
|
||||
| `sound on` / `sound off` | Toggles the Sound setting (beep + LED) |
|
||||
| `theme` / `theme <0-6> [light\|dark]` | Lists the colour themes, or picks one and its side |
|
||||
| `short` / `normal` | Screen timeouts 5 s / 10 s, or 30 s / 60 s |
|
||||
| `wifi add <ssid><TAB><password>` | Adds a Saved Network (so credentials stay out of the repo) |
|
||||
| `wifi ip <ssid> dhcp` / `wifi ip <ssid> <address>/<prefix> [gateway]` | A Saved Network's IP setting: Automatic, or Fixed. Add `try <seconds>` to go back to the previous setting unless `wifi ip keep` follows |
|
||||
|
||||
@@ -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**). The Launcher as a grid of icons (#9) is done, not released yet. Not started: screen recording (#17), 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) and themes (#10) are done, not released yet. Not started: screen recording (#17).
|
||||
|
||||
**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.
|
||||
|
||||
@@ -102,3 +102,28 @@ Every screen takes its colours from `src/ui/theme.h`, so the palette changed the
|
||||
| Text on a Toast | black | dark blue |
|
||||
|
||||
Looked at on the device, by screenshot: the Launcher, IRC, Wi-Fi Tools (menu, channel occupancy), GNSS (position, sky), Gemini, the LoRa Scanner (Sniffer, Sweep, presets), Storage, Notes, SSH, the Shell, System, Settings and the help panel. Not looked at: dialogs, a warning or a message Toast, the SSH terminal (which keeps its own 16 ANSI colours), the Setup screens.
|
||||
|
||||
## Themes (issue #10)
|
||||
|
||||
Seven themes, each with a dark and a light side, chosen in **Settings > Theme** and **Settings > Light or dark**: roro9stack (the website's, the default), Catppuccin (Mocha and Latte), Dracula (and Alucard), Nord, ANSI terminal, Gruvbox, Solarized.
|
||||
|
||||
| Question in the issue | Decided |
|
||||
|---|---|
|
||||
| Which themes | The seven above. Not the high-contrast and phosphor ones the issue suggested: ANSI terminal dark (grey and green on black) and light (black on white) come close |
|
||||
| Fonts and spacing too | No: colours only |
|
||||
| Themes from the SD card | No: built in |
|
||||
| A preview while picking | The Setting applies at once, so the Settings screen is the preview |
|
||||
| Constellation colours | From the theme (its accent, its "good", and three roles named red, yellow and violet), so they read on every background |
|
||||
| Light by day, dark by night | No |
|
||||
|
||||
- **A palette is 14 roles** (`lib/ui/src/palette.h`): background, text, muted, faint, the bar and its idle marks, accent, what is drawn on an accent, warning, message, good, and red, yellow and violet.
|
||||
- **`theme::kText` and the others are now references** into the palette in use (`src/ui/theme.h`), so no App changed: they still read as constants. The main loop applies the palette when either Setting changes, and draws everything again.
|
||||
- **RGB332.** Every colour in the table is one the frame buffer holds exactly. The themes' own colours were rounded to those; where two roles fell together or lost contrast, one was picked by hand (Nord's accent and muted text, Gruvbox's greys, Solarized's surfaces). `test_palette` checks that every colour is exact, and 18 contrast ratios for each of the 14 palettes (text on background at 4.5 or more, the rest at 2 to 3).
|
||||
- **What it costs in fidelity:** blue has four levels, so Dracula and Nord share a dark background (#242455), which was Catppuccin's too: Catppuccin dark is on black instead (its "crust"), with that blue for its bar and its brighter accents (peach, pink, green, sky), to tell it from Dracula; Solarized light's cream becomes white, and Gruvbox's dark brown becomes a dark olive (#242400).
|
||||
- `theme [0-6] [light|dark]` on the consoles lists them or picks one.
|
||||
|
||||
**Looked at on the device**, by screenshot: the Launcher in all 14; and in Gruvbox light, Settings, GNSS (position and sky), the Sweep, Storage, the help panel, Gemini and Wi-Fi Tools' channel occupancy. Nothing was drawn for black only.
|
||||
|
||||
**Kept as they are:** the SSH terminal's 16 ANSI colours and its black background, the Sweep's waterfall scale, pictures, and the screen shown while the device powers off.
|
||||
|
||||
**Not looked at:** the other 12 palettes beyond the Launcher, dialogs and Toasts in any theme but the default, the Setup screens. In Gruvbox light, the Sky view's GPS, GLONASS and BeiDou colours are three close shades of brown and red.
|
||||
|
||||
@@ -16,6 +16,8 @@ Move with the arrows. On a toggle, a choice or a slider, left and right change t
|
||||
| **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)) |
|
||||
| **Theme** | The colours of every screen: **roro9stack** (the website's), **Catppuccin**, **Dracula**, **Nord**, **ANSI terminal**, **Gruvbox** or **Solarized** |
|
||||
| **Light or dark** | Which side of the theme: every one has both |
|
||||
| **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/)) |
|
||||
@@ -49,3 +51,11 @@ Two DNS servers (9.9.9.9 and 1.1.1.1 by default), used on Fixed networks, or on
|
||||
What <kbd>Fn</kbd> + <kbd>h</kbd> shows on these screens. These tables are generated from the firmware's own lists, so they are always the current ones.
|
||||
|
||||
{{ keys(scopes=["settings", "settings-choice", "wifi", "wifi-servers", "wifi-network", "wifi-status", "wifi-scan", "wifi-name", "vpn", "debug-console"]) }}
|
||||
|
||||
## Themes
|
||||
|
||||
A theme changes the colours and nothing else: the fonts and the layout stay. It applies at once, to every App, and is kept across restarts.
|
||||
|
||||
The screen shows **256 colours**, so a theme here is the nearest the screen can do to the palette it is named after, not an exact copy: pale backgrounds turn white or cream, and dark ones turn a deep blue or olive. Each theme is checked for contrast after that rounding.
|
||||
|
||||
Three things keep their own colours whatever the theme: the SSH terminal (the 16 colours a remote program asks for), the Sweep's waterfall (its colours are a scale of signal strength), and pictures.
|
||||
|
||||
@@ -17,14 +17,14 @@ using namespace gnss;
|
||||
|
||||
namespace {
|
||||
|
||||
// One colour per constellation, readable in the 8-bit frame buffer.
|
||||
// One colour per constellation, from the theme: readable on its background.
|
||||
uint16_t colorOf(Constellation c) {
|
||||
switch (c) {
|
||||
case Constellation::Gps: return theme::kAccent;
|
||||
case Constellation::Glonass: return 0xF8C8; // red
|
||||
case Constellation::Glonass: return theme::kRed;
|
||||
case Constellation::Galileo: return theme::kGood;
|
||||
case Constellation::BeiDou: return 0xFEA0; // yellow
|
||||
case Constellation::Qzss: return 0xC99F; // violet
|
||||
case Constellation::BeiDou: return theme::kYellow;
|
||||
case Constellation::Qzss: return theme::kViolet;
|
||||
default: return theme::kText;
|
||||
}
|
||||
}
|
||||
|
||||
+19
-1
@@ -193,6 +193,7 @@ void setup() {
|
||||
Serial.begin(115200);
|
||||
|
||||
settings.load();
|
||||
theme::apply(ui::palette(settings.getInt(Setting::Theme), settings.getBool(Setting::ThemeLight)));
|
||||
if (safeMode) return setupSafeMode(crashes);
|
||||
|
||||
battery = new BatteryService(bus);
|
||||
@@ -694,7 +695,7 @@ static const char* const kHelp =
|
||||
"irc start | irc stop | irc dump | irc say <buffer> <text>\n"
|
||||
"install <path.ota> Update from SD\n"
|
||||
"update check | update list | update status | update install <tag> the project's releases on Gitea\n"
|
||||
"sd card | sd list | cat <path> | log <text> | burst | sound on|off | short | normal\n"
|
||||
"sd card | sd list | cat <path> | log <text> | burst | sound on|off | theme [0-6] [light|dark] | short | normal\n"
|
||||
"Irc | Wifi | Gnss | Gemini | Lora | Storage | Notes | Shell | System | Settings open that App: a capital letter is an App, not a command\n"
|
||||
"ping <host> [count] [size] | nslookup <name> [server] | port <host> <port> | traceroute <host> | cancel is it there, does its name resolve, is its port open, which way; one at a time\n"
|
||||
"tls <host> [port] | ntp [server] a TLS handshake: who the certificate is for, by whom, until when, and whether this device trusts it; a time server's clock against this one\n"
|
||||
@@ -1328,6 +1329,14 @@ static void runCommand(String line, bool fromSerial = false) {
|
||||
settings.setBool(Setting::DnsAlways, line.endsWith("on"));
|
||||
wifi->serversChanged();
|
||||
}
|
||||
if (line == "theme" || line.startsWith("theme ")) { // theme [0-6] [light|dark]
|
||||
std::string rest = line.length() > 6 ? line.substring(6).c_str() : "";
|
||||
if (!rest.empty() && isdigit(static_cast<unsigned char>(rest[0]))) settings.setInt(Setting::Theme, rest[0] - '0');
|
||||
if (rest.find("light") != std::string::npos) settings.setBool(Setting::ThemeLight, true);
|
||||
if (rest.find("dark") != std::string::npos) settings.setBool(Setting::ThemeLight, false);
|
||||
for (int t = 0; t < ui::kThemeCount; ++t)
|
||||
console.printf("theme: %d %s%s\n", t, ui::themeName(t), t == settings.getInt(Setting::Theme) ? (settings.getBool(Setting::ThemeLight) ? " (light)" : " (dark)") : "");
|
||||
}
|
||||
if (line == "sound off") settings.setBool(Setting::Sound, false);
|
||||
if (line == "sound on") settings.setBool(Setting::Sound, true);
|
||||
if (line == "short") {
|
||||
@@ -1567,6 +1576,15 @@ static void loopPass() {
|
||||
StatusInfo status = currentStatus();
|
||||
const Toast* toast = notifier->toasts().current(now);
|
||||
bool toastChanged = notifier->toasts().takeChanged();
|
||||
// The theme, when the Settings (or the `theme` command) changed it: every colour is drawn again.
|
||||
static int shownTheme = settings.getInt(Setting::Theme) * 2 + settings.getBool(Setting::ThemeLight);
|
||||
int wantedTheme = settings.getInt(Setting::Theme) * 2 + settings.getBool(Setting::ThemeLight);
|
||||
if (wantedTheme != shownTheme) {
|
||||
shownTheme = wantedTheme;
|
||||
theme::apply(ui::palette(wantedTheme / 2, wantedTheme % 2));
|
||||
screen.invalidate();
|
||||
redrawAfterUpdate = true;
|
||||
}
|
||||
if (apps->takeRedraw() || toastChanged || status != lastStatus || redrawAfterUpdate) {
|
||||
lastStatus = status;
|
||||
redrawAfterUpdate = false;
|
||||
|
||||
@@ -14,6 +14,8 @@ class Screen {
|
||||
// Takes over the screen while a Firmware Update is received or about to restart.
|
||||
void renderUpdate(const std::string& title, const std::string& detail, int percent);
|
||||
Canvas& canvas() { return canvas_; }
|
||||
// The colours changed: an App that keeps what it drew has to draw it all again.
|
||||
void invalidate() { lost_ = true; }
|
||||
|
||||
private:
|
||||
Canvas canvas_;
|
||||
|
||||
+23
-14
@@ -2,22 +2,31 @@
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
#include "palette.h"
|
||||
|
||||
namespace roro::theme {
|
||||
|
||||
// The website's palette (site/static/css/site.css, its dark side), as RGB565. Every one is a colour
|
||||
// the 8-bit frame buffer (RGB332) holds exactly, so what is asked for is what is shown.
|
||||
constexpr uint16_t kBackground = 0x0000;
|
||||
constexpr uint16_t kText = 0xFFFF;
|
||||
constexpr uint16_t kAccent = 0x06DF; // cyan, #00dbff: what is chosen, what is live
|
||||
constexpr uint16_t kOnAccent = 0x000A; // dark blue, #000055: text and pictures on a cyan fill
|
||||
constexpr uint16_t kSelection = kAccent; // behind a selected row, whose text is kOnAccent
|
||||
constexpr uint16_t kMuted = 0x9495; // grey, #9292aa: between the website's two
|
||||
constexpr uint16_t kFaint = 0x4A4A; // slate, #494955: grid lines, what stays behind
|
||||
constexpr uint16_t kBar = kFaint; // Status Bar background
|
||||
constexpr uint16_t kBarMuted = 0xB5B5; // light grey, #b6b6aa: what is idle, on the bar
|
||||
constexpr uint16_t kWarning = 0xFC80; // orange, #ff9200
|
||||
constexpr uint16_t kMessage = 0xFC9F; // pink, #ff92ff: someone wrote
|
||||
constexpr uint16_t kGood = 0x4ECA; // green, #49db55: a Fix, the best of a set (not on the website)
|
||||
// The colours every screen draws with: the theme chosen in the Settings (issue #10, lib/ui's
|
||||
// palette.h), set at boot and whenever it changes. They read as constants, and are references into
|
||||
// the palette in use, so a draw after apply() is in the new colours.
|
||||
inline ui::Palette current = ui::palette(0, false);
|
||||
inline void apply(const ui::Palette& p) { current = p; }
|
||||
|
||||
inline const uint16_t& kBackground = current.background;
|
||||
inline const uint16_t& kText = current.text;
|
||||
inline const uint16_t& kAccent = current.accent; // what is chosen, what is live
|
||||
inline const uint16_t& kOnAccent = current.onAccent; // text and pictures on an accent fill
|
||||
inline const uint16_t& kSelection = current.accent; // behind a selected row, whose text is kOnAccent
|
||||
inline const uint16_t& kMuted = current.muted;
|
||||
inline const uint16_t& kFaint = current.faint; // grid lines, what stays behind
|
||||
inline const uint16_t& kBar = current.bar; // Status Bar background
|
||||
inline const uint16_t& kBarMuted = current.barMuted; // what is idle, on the bar
|
||||
inline const uint16_t& kWarning = current.warning;
|
||||
inline const uint16_t& kMessage = current.message; // someone wrote
|
||||
inline const uint16_t& kGood = current.good; // a Fix, the best of a set
|
||||
inline const uint16_t& kRed = current.red; // these three: to tell things apart
|
||||
inline const uint16_t& kYellow = current.yellow;
|
||||
inline const uint16_t& kViolet = current.violet;
|
||||
|
||||
constexpr int kWidth = 240;
|
||||
constexpr int kHeight = 135;
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
#include <unity.h>
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
|
||||
#include "palette.h"
|
||||
|
||||
using namespace roro::ui;
|
||||
|
||||
void setUp() {}
|
||||
void tearDown() {}
|
||||
|
||||
namespace {
|
||||
double channel(uint32_t v) {
|
||||
double c = v / 255.0;
|
||||
return c <= 0.03928 ? c / 12.92 : std::pow((c + 0.055) / 1.055, 2.4);
|
||||
}
|
||||
double luminance(uint16_t c) {
|
||||
uint32_t rgb = rgb888(c);
|
||||
return 0.2126 * channel(rgb >> 16) + 0.7152 * channel((rgb >> 8) & 255) + 0.0722 * channel(rgb & 255);
|
||||
}
|
||||
// The WCAG contrast ratio of two colours as the frame buffer shows them: 1 (the same) to 21.
|
||||
double contrast(uint16_t a, uint16_t b) {
|
||||
double la = luminance(a), lb = luminance(b);
|
||||
return (std::fmax(la, lb) + 0.05) / (std::fmin(la, lb) + 0.05);
|
||||
}
|
||||
|
||||
void atLeast(double wanted, uint16_t a, uint16_t b, const char* what, int theme, bool light) {
|
||||
char msg[96];
|
||||
std::snprintf(msg, sizeof msg, "%s %s: %s is %.1f, under %.1f", themeName(theme), light ? "light" : "dark", what, contrast(a, b), wanted);
|
||||
TEST_ASSERT_TRUE_MESSAGE(contrast(a, b) >= wanted, msg);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void test_seven_themes_with_names() {
|
||||
TEST_ASSERT_EQUAL(7, kThemeCount);
|
||||
TEST_ASSERT_EQUAL_STRING("roro9stack", themeName(0));
|
||||
for (int t = 0; t < kThemeCount; ++t) TEST_ASSERT_TRUE(std::string(themeName(t)).size() > 0 && std::string(themeName(t)).size() <= 14);
|
||||
// Out of range: the first, rather than whatever is next in memory.
|
||||
TEST_ASSERT_EQUAL_STRING("roro9stack", themeName(7));
|
||||
TEST_ASSERT_EQUAL_STRING("roro9stack", themeName(-1));
|
||||
TEST_ASSERT_EQUAL_PTR(&palette(0, true), &palette(99, true));
|
||||
}
|
||||
|
||||
// The frame buffer keeps 3 bits of red and green and 2 of blue: a colour it would round is a
|
||||
// colour that isn't the one shown.
|
||||
void test_every_colour_is_one_the_frame_buffer_holds() {
|
||||
for (int t = 0; t < kThemeCount; ++t)
|
||||
for (int light = 0; light < 2; ++light) {
|
||||
const Palette& p = palette(t, light);
|
||||
const uint16_t* c = &p.background;
|
||||
for (size_t i = 0; i < sizeof(Palette) / sizeof(uint16_t); ++i) {
|
||||
uint32_t rgb = rgb888(c[i]);
|
||||
uint16_t back = static_cast<uint16_t>(((rgb >> 19) & 0x1F) << 11 | ((rgb >> 10) & 0x3F) << 5 | ((rgb >> 3) & 0x1F));
|
||||
TEST_ASSERT_EQUAL_HEX16(back, c[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void test_every_theme_reads() {
|
||||
for (int t = 0; t < kThemeCount; ++t)
|
||||
for (int light = 0; light < 2; ++light) {
|
||||
const Palette& p = palette(t, light);
|
||||
atLeast(4.5, p.text, p.background, "text", t, light);
|
||||
atLeast(3, p.muted, p.background, "muted text", t, light);
|
||||
atLeast(1.3, p.muted, p.text, "muted against text", t, light);
|
||||
atLeast(3, p.accent, p.background, "the accent", t, light);
|
||||
atLeast(3, p.onAccent, p.accent, "text on the accent", t, light);
|
||||
atLeast(4.5, p.text, p.bar, "text on the bar", t, light);
|
||||
atLeast(2.5, p.barMuted, p.bar, "idle marks on the bar", t, light);
|
||||
atLeast(2, p.accent, p.bar, "live marks on the bar", t, light);
|
||||
atLeast(3, p.warning, p.background, "a warning", t, light);
|
||||
atLeast(3, p.message, p.background, "a message", t, light);
|
||||
atLeast(3, p.good, p.background, "good", t, light);
|
||||
atLeast(3, p.onAccent, p.warning, "text on a warning Toast", t, light);
|
||||
atLeast(3, p.onAccent, p.message, "text on a message Toast", t, light);
|
||||
atLeast(2, p.faint, p.accent, "the Launcher's selected icon", t, light);
|
||||
atLeast(1.15, p.faint, p.background, "grid lines", t, light);
|
||||
atLeast(2.5, p.red, p.background, "red", t, light);
|
||||
atLeast(2.5, p.yellow, p.background, "yellow", t, light);
|
||||
atLeast(2.5, p.violet, p.background, "violet", t, light);
|
||||
}
|
||||
}
|
||||
|
||||
void test_the_first_is_the_websites() {
|
||||
const Palette& p = palette(0, false);
|
||||
TEST_ASSERT_EQUAL_HEX32(0x000000, rgb888(p.background));
|
||||
TEST_ASSERT_EQUAL_HEX32(0x00dbff, rgb888(p.accent));
|
||||
TEST_ASSERT_EQUAL_HEX32(0x494955, rgb888(p.bar));
|
||||
TEST_ASSERT_EQUAL_HEX32(0xffffff, rgb888(palette(0, true).background));
|
||||
}
|
||||
|
||||
int main() {
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_seven_themes_with_names);
|
||||
RUN_TEST(test_every_colour_is_one_the_frame_buffer_holds);
|
||||
RUN_TEST(test_every_theme_reads);
|
||||
RUN_TEST(test_the_first_is_the_websites);
|
||||
return UNITY_END();
|
||||
}
|
||||
@@ -46,6 +46,21 @@ void test_toggle_rows_flip_on_select() {
|
||||
TEST_ASSERT_FALSE(f.settings.getBool(Setting::Sound));
|
||||
}
|
||||
|
||||
void test_the_theme_and_its_side() {
|
||||
Fixture f;
|
||||
int theme = f.row(SettingsMenu::Row::Theme), side = f.row(SettingsMenu::Row::ThemeLight);
|
||||
TEST_ASSERT_EQUAL_STRING("roro9stack", f.menu.value(theme).c_str());
|
||||
TEST_ASSERT_EQUAL(7, static_cast<int>(f.menu.choices(theme).size()));
|
||||
TEST_ASSERT_EQUAL(0, f.menu.currentChoice(theme));
|
||||
TEST_ASSERT_EQUAL_STRING("", f.menu.choose(theme, 2).c_str());
|
||||
TEST_ASSERT_EQUAL_INT32(2, f.settings.getInt(Setting::Theme));
|
||||
TEST_ASSERT_EQUAL_STRING("Dracula", f.menu.value(theme).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("Dark", f.menu.value(side).c_str());
|
||||
f.menu.toggle(side);
|
||||
TEST_ASSERT_TRUE(f.settings.getBool(Setting::ThemeLight));
|
||||
TEST_ASSERT_EQUAL_STRING("Light", f.menu.value(side).c_str());
|
||||
}
|
||||
|
||||
void test_the_launcher_is_a_grid_or_a_list() {
|
||||
Fixture f;
|
||||
int launcher = f.row(SettingsMenu::Row::Launcher);
|
||||
@@ -171,6 +186,7 @@ int main() {
|
||||
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_the_theme_and_its_side);
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user