13 Commits
Author SHA1 Message Date
NeilClack 9b0bd517c6 pad footer 2026-08-25 10:33:04 -05:00
NeilClack babb74c7ad Fix attic upload
build-dev / nix-build (push) Successful in 40s
2026-08-25 07:55:32 -05:00
NeilClack 47785cd4b9 Proclamations: sub-cats always expanded, stable list width
build-dev / nix-build (push) Successful in 43s
- Sub-categories start expanded and cannot be collapsed
- Only root categories toggle open/closed
- LEFT on a sub-category is ignored (navigates to parent for depth-2)
- [+]/[-] indicators only shown on root categories
- Bounding box width computed over all items to prevent
  horizontal shifting when groups expand or collapse
2026-08-25 07:15:59 -05:00
NeilClack 15583edef8 Re-order settings menu 2026-08-25 06:57:43 -05:00
NeilClack 63265cc22f Dynamically size the fullscreen window; present design space via LETTERBOX
build-dev / nix-build (push) Successful in 42s
SDL_CreateWindow hardcoded a 1920x1080 backbuffer while SDL_WINDOW_FULLSCREEN
let compositors run a different output mode; the mismatched buffer reached
the panel unscaled — quarter-size in the top-left on 4K sets, cropped ("4x
too large") on smaller non-1080p panels under cage/Wayland. Logical
presentation never got to map the fixed design space because its target
(the backbuffer) was pinned to stale dimensions.

Size the fullscreen window from SDL_GetCurrentDisplayMode so the first
commit already matches scanout 1:1, switch logical presentation to LETTERBOX
(aspect-safe bars on non-16:9 panels), and route both through new
kDesignWidth/kDesignHeight constants. Harden getLayoutSize() to always write
its out params and null-check the renderer/render-window before use.

Log requested/window/pixel-buffer/render-output sizes at startup and on
every resize event so field units self-diagnose this bug class into
~/.local/radiantwave/log.txt. SDL_WINDOW_HIGH_PIXEL_DENSITY stays off on
purpose: kiosk panels are not HiDPI, and keeping the pixel buffer equal to
the display mode keeps compositor scanout unscaled.
2026-08-23 17:06:05 -05:00
NeilClack 9e73c82641 Fix attic push
build-dev / nix-build (push) Successful in 43s
2026-08-23 15:12:35 -05:00
NeilClack f2a40cd019 Remove attic-client install step
build-dev / nix-build (push) Successful in 39s
2026-08-23 12:46:08 -05:00
NeilClack 5caf1d6ef8 Move to checksums.md5
build-dev / nix-build (push) Failing after 13s
Server is scheduled to generate this file nightly
2026-08-23 12:39:05 -05:00
NeilClack e17e881c00 Split builds 2026-08-23 12:38:44 -05:00
NeilClack b96631c374 Add first Attic push 2026-08-21 14:52:14 -05:00
NeilClack 39eb6d7bf7 Update to new_checkshums.md5
new_checksums.md5 enables audio file naming and other audio related features.
2026-08-21 10:03:31 -05:00
NeilClack 3c9de0bee2 Fix double-scaling by locking page layout to logical presentation
Pages computed layout from SDL_GetWindowSize() (real window
coordinates) while SDL_SetRenderLogicalPresentation() independently
remapped every draw call into a 1920x1080 space. The two coordinate
systems only agreed when the window happened to be exactly 1920x1080;
at any other size, layout coordinates were transformed a second time,
so content shrank/grew quadratically toward the top-left on resize or
on non-1080p monitors. Commenting the call out masked the bug by
making rendering 1:1 with the window, but then nothing scaled: fonts
and margins stayed fixed-pixel while backgrounds cover-fit, and HiDPI
displays drew at point resolution and relied on blurry compositor
upsampling.

Restore SDL_SetRenderLogicalPresentation(1920x1080, LETTERBOX) as the
single source of scaling, and route all twelve layout sites through a
new radiantwave::getLayoutSize() helper (src/logical_size.h) that
returns the renderer's logical size, so pages always lay out against
the design resolution regardless of window or monitor geometry. Add
SDL_WINDOW_HIGH_PIXEL_DENSITY so HiDPI displays render at native pixel
density instead of being upscaled by the OS. Cached per-page textures
become resolution-independent since layout no longer depends on window
size.
2026-08-21 09:29:23 -05:00
NeilClack 8e930ef8d9 Remove WiFi gate 2026-08-18 23:37:44 -04:00
20 changed files with 580 additions and 372 deletions
@@ -1,19 +1,15 @@
name: build name: build-dev
on: on:
# Manual only for now. Select the branch to build when dispatching: # Every push to dev: build, run the offline test suite, publish to the
# main -> APP_VERSION is the current git tag # dev Attic cache.
# dev -> APP_VERSION is the current commit hash push:
workflow_dispatch: branches: [dev]
jobs: jobs:
nix-build: nix-build:
runs-on: build-server runs-on: build-server
steps: steps:
# actions/checkout@v4 is unreliable on host-mode runners (its files are # actions/checkout@v4 is unreliable on host-mode runners (its files are
# copied into the workspace instead of the repo), so clone explicitly. # copied into the workspace instead of the repo), so clone explicitly.
# A full clone (no --depth) is required so `git describe --tags` on main
# resolves the real nearest tag instead of falling back to a hash.
- name: Checkout - name: Checkout
run: | run: |
rm -rf ./* ./.[!.]* 2>/dev/null || true rm -rf ./* ./.[!.]* 2>/dev/null || true
@@ -22,7 +18,6 @@ jobs:
"${{ gitea.server_url }}/${{ gitea.repository }}.git" . "${{ gitea.server_url }}/${{ gitea.repository }}.git" .
test -f flake.nix test -f flake.nix
git log --oneline -1 git log --oneline -1
- name: Inspect workspace - name: Inspect workspace
run: | run: |
pwd pwd
@@ -31,28 +26,26 @@ jobs:
git remote -v 2>&1 || true git remote -v 2>&1 || true
git describe --tags --always 2>&1 || true git describe --tags --always 2>&1 || true
test -f flake.nix && echo "flake.nix present" || echo "NO flake.nix" test -f flake.nix && echo "flake.nix present" || echo "NO flake.nix"
# Dev builds fingerprint as the short commit hash.
# Compute the version string baked into the binary's Home footer: # Persisted via GITHUB_ENV so the impure nix eval below reads it via
# main reports the git tag, dev reports the commit hash fingerprint. # builtins.getEnv.
# The key stays in the build step's environment (never echoed).
- name: Compute version - name: Compute version
id: version
run: | run: |
if [ "${{ gitea.ref_name }}" = "main" ]; then echo "APP_VERSION=$(git rev-parse --short HEAD)" >> "$GITHUB_ENV"
APP_VERSION="$(git describe --tags --always)"
else
APP_VERSION="$(git rev-parse --short HEAD)"
fi
echo "Branch: ${{ gitea.ref_name }} -> APP_VERSION=$APP_VERSION"
# checks is keyed per-system (checks.<system>.default); nix build's # checks is keyed per-system (checks.<system>.default); nix build's
# #attr shorthand can't resolve it, so resolve the current system inline. # #attr shorthand can't resolve it, so resolve the current system inline.
# --impure lets the flake read APP_VERSION via builtins.getEnv # --impure lets the flake read APP_VERSION via builtins.getEnv
# (pure evals always get the package defaults). The offline test suite # (pure evals always get the package defaults).
# runs in the package's checkPhase. - name: Build package
run: |
nix build -L ".#packages.$(nix eval --impure --raw --expr 'builtins.currentSystem').default" \
--impure --out-link result-pkg
# The offline test suite runs in the check derivation's checkPhase.
# This is a separate build/output from the package itself.
- name: Build + offline test suite - name: Build + offline test suite
run: nix build -L ".#checks.$(nix eval --impure --raw --expr 'builtins.currentSystem').default" --impure run: |
nix build -L ".#checks.$(nix eval --impure --raw --expr 'builtins.currentSystem').default" \
--impure --out-link result-checks
- name: Live portal tests - name: Live portal tests
env: env:
RWT_LIVE_ACCOUNT_EMAIL: ${{ secrets.RWT_LIVE_ACCOUNT_EMAIL }} RWT_LIVE_ACCOUNT_EMAIL: ${{ secrets.RWT_LIVE_ACCOUNT_EMAIL }}
@@ -65,3 +58,11 @@ jobs:
./build/test_api_client ./build/test_api_client
./build/test_license_service ./build/test_license_service
' '
# Dev closure (the actual package, not the check output) lands in the
# dev cache.
- name: Push to Attic dev cache
env:
ATTIC_TOKEN: ${{ secrets.ATTIC_TOKEN_DEV }}
run: |
attic login radiantwave https://attic.radiantwavetech.com "$ATTIC_TOKEN"
attic push radiantwave:radiantwave-dev ./result-pkg
+76
View File
@@ -0,0 +1,76 @@
name: build-main
on:
# Manual only. Dispatch against main: build, run the offline test suite,
# publish to the release Attic cache.
workflow_dispatch:
jobs:
nix-build:
runs-on: build-server
steps:
# actions/checkout@v4 is unreliable on host-mode runners (its files are
# copied into the workspace instead of the repo), so clone explicitly.
# A full clone (no --depth) is required so `git describe --tags` resolves
# the real nearest tag instead of falling back to a hash.
- name: Checkout
run: |
rm -rf ./* ./.[!.]* 2>/dev/null || true
git config --global http.extraheader "Authorization: token ${{ github.token }}"
git clone --branch "${{ gitea.ref_name }}" \
"${{ gitea.server_url }}/${{ gitea.repository }}.git" .
test -f flake.nix
git log --oneline -1
- name: Inspect workspace
run: |
pwd
ls -la
git rev-parse --show-toplevel 2>&1 || true
git remote -v 2>&1 || true
git describe --tags --always 2>&1 || true
test -f flake.nix && echo "flake.nix present" || echo "NO flake.nix"
# Release builds report the current git tag.
# Persisted via GITHUB_ENV so the impure nix eval below reads it via
# builtins.getEnv.
- name: Compute version
run: |
echo "APP_VERSION=$(git describe --tags --always)" >> "$GITHUB_ENV"
# checks is keyed per-system (checks.<system>.default); nix build's
# #attr shorthand can't resolve it, so resolve the current system inline.
# --impure lets the flake read APP_VERSION via builtins.getEnv
# (pure evals always get the package defaults).
- name: Build package
run: |
nix build -L ".#packages.$(nix eval --impure --raw --expr 'builtins.currentSystem').default" \
--impure --out-link result-pkg
# The offline test suite runs in the check derivation's checkPhase.
# This is a separate build/output from the package itself.
- name: Build + offline test suite
run: |
nix build -L ".#checks.$(nix eval --impure --raw --expr 'builtins.currentSystem').default" \
--impure --out-link result-checks
- name: Live portal tests
env:
RWT_LIVE_ACCOUNT_EMAIL: ${{ secrets.RWT_LIVE_ACCOUNT_EMAIL }}
RWT_LIVE_LICENSE_EMAIL: ${{ secrets.RWT_LIVE_LICENSE_EMAIL }}
RWT_LIVE_LICENSE_KEY: ${{ secrets.RWT_LIVE_LICENSE_KEY }}
run: |
nix develop --command bash -c '
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug
cmake --build build --target test_api_client test_license_service
./build/test_api_client
./build/test_license_service
'
# Release closure (the actual package, not the check output) lands in the
# release cache.
- name: Push to Attic release cache
env:
ATTIC_TOKEN: ${{ secrets.ATTIC_TOKEN_PROD }}
run: |
attic login radiantwave https://attic.radiantwavetech.com "$ATTIC_TOKEN"
attic push radiantwave:radiantwave ./result-pkg
+35 -31
View File
@@ -27,10 +27,10 @@ namespace radiantwave::audio {
// honored, anything else falls back to the Hz parsed from the filename, so // honored, anything else falls back to the Hz parsed from the filename, so
// the file's own name is always the safety net. // the file's own name is always the safety net.
struct AudioManifestEntry { struct AudioManifestEntry {
std::string filename; std::string filename;
std::string display_name; std::string display_name;
int solfeggio_frequency_hz = 0; int solfeggio_frequency_hz = 0;
std::string md5; std::string md5;
}; };
// Downloads audio assets from files.radiantwavetech.com in the background // Downloads audio assets from files.radiantwavetech.com in the background
@@ -50,45 +50,49 @@ struct AudioManifestEntry {
// checksums are picked up while running. // checksums are picked up while running.
class AudioDownloader { class AudioDownloader {
public: public:
AudioDownloader() = default; AudioDownloader() = default;
~AudioDownloader(); ~AudioDownloader();
AudioDownloader(const AudioDownloader&) = delete; AudioDownloader(const AudioDownloader &) = delete;
AudioDownloader& operator=(const AudioDownloader&) = delete; AudioDownloader &operator=(const AudioDownloader &) = delete;
// Spawns the background thread. Safe to call repeatedly; only one worker // Spawns the background thread. Safe to call repeatedly; only one worker
// runs at a time. The manifest is reconciled against `db` on every pass. // runs at a time. The manifest is reconciled against `db` on every pass.
void start(db::DatabaseManager& db); void start(db::DatabaseManager &db);
// Signals the worker to stop and joins the thread. // Signals the worker to stop and joins the thread.
void shutdown(); void shutdown();
// Returns the current human-readable status for display (e.g. footer). // Returns the current human-readable status for display (e.g. footer).
std::string statusString() const; std::string statusString() const;
static constexpr const char* AUDIO_BASE_URL = "https://files.radiantwavetech.com/audio/"; static constexpr const char *AUDIO_BASE_URL =
static constexpr const char* MANIFEST_FILENAME = "checksums.md5"; "https://files.radiantwavetech.com/audio/";
static constexpr const char *MANIFEST_FILENAME = "checksums.md5";
private: private:
void run(); void run();
bool fetchManifest(std::vector<AudioManifestEntry>& out); bool fetchManifest(std::vector<AudioManifestEntry> &out);
bool downloadAndVerify(const std::string& filename, const std::string& expectedMd5, const std::string& displayName); bool downloadAndVerify(const std::string &filename,
const std::string &expectedMd5,
const std::string &displayName);
void setStatus(const std::string& msg); void setStatus(const std::string &msg);
void wait(std::chrono::seconds seconds); void wait(std::chrono::seconds seconds);
mutable std::mutex mutex_; mutable std::mutex mutex_;
std::condition_variable cv_; std::condition_variable cv_;
std::thread worker_; std::thread worker_;
std::atomic<bool> stop_{false}; std::atomic<bool> stop_{false};
std::string status_; std::string status_;
db::DatabaseManager* db_ = nullptr; db::DatabaseManager *db_ = nullptr;
}; };
// Parses checksums.md5 manifest text into entries. Blank lines and lines // Parses checksums.md5 manifest text into entries. Blank lines and lines
// starting with '#' are ignored. Lines that don't match a known format are // starting with '#' are ignored. Lines that don't match a known format are
// skipped. Returns false if no valid entries were found. // skipped. Returns false if no valid entries were found.
bool parseManifest(const std::string& text, std::vector<AudioManifestEntry>& out); bool parseManifest(const std::string &text,
std::vector<AudioManifestEntry> &out);
// Maps a frequency value in Hz to its internal solfeggio frequency id using // Maps a frequency value in Hz to its internal solfeggio frequency id using
// the same table the color palettes are seeded from (174/285->1, 396->2, // the same table the color palettes are seeded from (174/285->1, 396->2,
@@ -100,9 +104,9 @@ int frequencyIdForHz(int hz);
// "396Hz.wav" -> 396. Requires the digits to be followed by "Hz" // "396Hz.wav" -> 396. Requires the digits to be followed by "Hz"
// (case-insensitive); returns 0 when the name has no frequency (e.g. // (case-insensitive); returns 0 when the name has no frequency (e.g.
// "AlphaWaves.wav", "OriginalAudio.wav"). // "AlphaWaves.wav", "OriginalAudio.wav").
int hzFromFilename(const std::string& filename); int hzFromFilename(const std::string &filename);
// Computes the lowercase MD5 hex of a file, or an empty string on failure. // Computes the lowercase MD5 hex of a file, or an empty string on failure.
std::string computeFileMd5(const std::string& path); std::string computeFileMd5(const std::string &path);
} // namespace radiantwave::audio } // namespace radiantwave::audio
+2 -1
View File
@@ -1,5 +1,6 @@
#include "audio_settings_page.h" #include "audio_settings_page.h"
#include "app_state.h" #include "app_state.h"
#include "logical_size.h"
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include <algorithm> #include <algorithm>
#include <cctype> #include <cctype>
@@ -477,7 +478,7 @@ void AudioSettingsPage::renderModal(SDL_Renderer* renderer, float fw, float fh,
void AudioSettingsPage::render(SDL_Renderer* renderer) { void AudioSettingsPage::render(SDL_Renderer* renderer) {
int winW, winH; int winW, winH;
SDL_GetWindowSize(SDL_GetRenderWindow(renderer), &winW, &winH); radiantwave::getLayoutSize(renderer, &winW, &winH);
float fw = static_cast<float>(winW); float fw = static_cast<float>(winW);
float fh = static_cast<float>(winH); float fh = static_cast<float>(winH);
+2 -1
View File
@@ -1,6 +1,7 @@
#include "date_format_page.h" #include "date_format_page.h"
#include "app_state.h" #include "app_state.h"
#include "date_format.h" #include "date_format.h"
#include "logical_size.h"
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include <algorithm> #include <algorithm>
#include <ctime> #include <ctime>
@@ -131,7 +132,7 @@ void DateFormatPage::update(float dt)
void DateFormatPage::render(SDL_Renderer* renderer) void DateFormatPage::render(SDL_Renderer* renderer)
{ {
int winW, winH; int winW, winH;
SDL_GetWindowSize(SDL_GetRenderWindow(renderer), &winW, &winH); radiantwave::getLayoutSize(renderer, &winW, &winH);
float fw = static_cast<float>(winW); float fw = static_cast<float>(winW);
float fh = static_cast<float>(winH); float fh = static_cast<float>(winH);
+2 -1
View File
@@ -3,6 +3,7 @@
#include "db/db_keys.h" #include "db/db_keys.h"
#include "provisioning.h" #include "provisioning.h"
#include "validation.h" #include "validation.h"
#include "logical_size.h"
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
namespace { namespace {
@@ -164,7 +165,7 @@ void EmailAddressPage::exitPage(AppState& state)
void EmailAddressPage::render(SDL_Renderer* renderer) void EmailAddressPage::render(SDL_Renderer* renderer)
{ {
int winW, winH; int winW, winH;
SDL_GetWindowSize(SDL_GetRenderWindow(renderer), &winW, &winH); radiantwave::getLayoutSize(renderer, &winW, &winH);
float fw = static_cast<float>(winW); float fw = static_cast<float>(winW);
float fh = static_cast<float>(winH); float fh = static_cast<float>(winH);
+4 -3
View File
@@ -8,6 +8,7 @@
#include "audio/audio_downloader.h" #include "audio/audio_downloader.h"
#include "db/db_keys.h" #include "db/db_keys.h"
#include "network/proclamation_sync.h" #include "network/proclamation_sync.h"
#include "logical_size.h"
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include <SDL3_image/SDL_image.h> #include <SDL3_image/SDL_image.h>
#include <ctime> #include <ctime>
@@ -114,7 +115,7 @@ void Home::update(float dt)
void Home::render(SDL_Renderer *renderer) void Home::render(SDL_Renderer *renderer)
{ {
int winW, winH; int winW, winH;
SDL_GetWindowSize(SDL_GetRenderWindow(renderer), &winW, &winH); radiantwave::getLayoutSize(renderer, &winW, &winH);
float fw = static_cast<float>(winW); float fw = static_cast<float>(winW);
float fh = static_cast<float>(winH); float fh = static_cast<float>(winH);
@@ -280,10 +281,10 @@ void Home::render(SDL_Renderer *renderer)
fourSpaces.destroy(); fourSpaces.destroy();
const float margin = 20.0f; const float margin = 20.0f;
float rectRight = fw - margin; float rectRight = fw - margin - 50.0f;
float rectLeft = rectRight - (maxNameW + spaceW4 + maxValueW); float rectLeft = rectRight - (maxNameW + spaceW4 + maxValueW);
float lineY = fh - margin; float lineY = fh - margin - 50.0f;
const float lineGap = 6.0f; const float lineGap = 6.0f;
for (size_t i = footerNames_.size(); i-- > 0;) for (size_t i = footerNames_.size(); i-- > 0;)
{ {
+2 -1
View File
@@ -1,6 +1,7 @@
#include "license_blocked_page.h" #include "license_blocked_page.h"
#include "app_state.h" #include "app_state.h"
#include "home.h" #include "home.h"
#include "logical_size.h"
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
void LicenseBlockedPage::onPush(AppState& state) void LicenseBlockedPage::onPush(AppState& state)
@@ -71,7 +72,7 @@ void LicenseBlockedPage::update(float dt)
void LicenseBlockedPage::render(SDL_Renderer* renderer) void LicenseBlockedPage::render(SDL_Renderer* renderer)
{ {
int winW, winH; int winW, winH;
SDL_GetWindowSize(SDL_GetRenderWindow(renderer), &winW, &winH); radiantwave::getLayoutSize(renderer, &winW, &winH);
float fw = static_cast<float>(winW); float fw = static_cast<float>(winW);
float fh = static_cast<float>(winH); float fh = static_cast<float>(winH);
+2 -1
View File
@@ -4,6 +4,7 @@
#include "network/license_service.h" #include "network/license_service.h"
#include "provisioning.h" #include "provisioning.h"
#include "validation.h" #include "validation.h"
#include "logical_size.h"
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
void LicenseKeyPage::onPush(AppState& state) void LicenseKeyPage::onPush(AppState& state)
@@ -172,7 +173,7 @@ void LicenseKeyPage::exitPage(AppState& state)
void LicenseKeyPage::render(SDL_Renderer* renderer) void LicenseKeyPage::render(SDL_Renderer* renderer)
{ {
int winW, winH; int winW, winH;
SDL_GetWindowSize(SDL_GetRenderWindow(renderer), &winW, &winH); radiantwave::getLayoutSize(renderer, &winW, &winH);
float fw = static_cast<float>(winW); float fw = static_cast<float>(winW);
float fh = static_cast<float>(winH); float fh = static_cast<float>(winH);
+2 -1
View File
@@ -1,6 +1,7 @@
#include "log_viewer_page.h" #include "log_viewer_page.h"
#include "app_state.h" #include "app_state.h"
#include "timezone_helper.h" #include "timezone_helper.h"
#include "logical_size.h"
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include <algorithm> #include <algorithm>
#include <sstream> #include <sstream>
@@ -162,7 +163,7 @@ void LogViewerPage::update(float dt) {
void LogViewerPage::render(SDL_Renderer* renderer) { void LogViewerPage::render(SDL_Renderer* renderer) {
int winW, winH; int winW, winH;
SDL_GetWindowSize(SDL_GetRenderWindow(renderer), &winW, &winH); radiantwave::getLayoutSize(renderer, &winW, &winH);
float fw = static_cast<float>(winW); float fw = static_cast<float>(winW);
float fh = static_cast<float>(winH); float fh = static_cast<float>(winH);
+51
View File
@@ -0,0 +1,51 @@
#pragma once
#include <SDL3/SDL.h>
namespace radiantwave {
// Design resolution every page lays out against. The renderer's logical
// presentation (LETTERBOX) scales this fixed space onto whatever
// window/backbuffer the display actually provides.
inline constexpr int kDesignWidth = 1920;
inline constexpr int kDesignHeight = 1080;
// Returns the size pages should lay out against: the renderer's logical size
// when presentation is active, otherwise the real window size. The output
// params are always written — design constants are the last-resort fallback.
inline void getLayoutSize(SDL_Renderer *renderer, int *w, int *h)
{
if (!w || !h)
return;
*w = kDesignWidth;
*h = kDesignHeight;
if (!renderer)
return;
SDL_RendererLogicalPresentation mode = SDL_LOGICAL_PRESENTATION_DISABLED;
int logicalW = 0;
int logicalH = 0;
if (SDL_GetRenderLogicalPresentation(renderer, &logicalW, &logicalH,
&mode) &&
mode != SDL_LOGICAL_PRESENTATION_DISABLED && logicalW > 0 &&
logicalH > 0) {
*w = logicalW;
*h = logicalH;
return;
}
SDL_Window *window = SDL_GetRenderWindow(renderer);
if (!window)
return;
int windowW = 0;
int windowH = 0;
SDL_GetWindowSize(window, &windowW, &windowH);
if (windowW > 0 && windowH > 0) {
*w = windowW;
*h = windowH;
}
}
} // namespace radiantwave
+228 -165
View File
@@ -4,206 +4,269 @@
#include "app_state.h" #include "app_state.h"
#include "home.h" #include "home.h"
#include "provisioning.h" #include "logical_size.h"
#include "logger/logger.h" #include "logger/logger.h"
#include "provisioning.h"
namespace namespace {
{ // How long audio fades out before the app tears down on quit. Only applied
// How long audio fades out before the app tears down on quit. Only applied // when audio is actually playing; otherwise quitting is immediate.
// when audio is actually playing; otherwise quitting is immediate. constexpr float kQuitFadeSeconds = 5.0f;
constexpr float kQuitFadeSeconds = 5.0f;
} // anonymous namespace } // anonymous namespace
SDL_AppResult SDL_AppInit(void **appstate, int argc, char *argv[]) SDL_AppResult SDL_AppInit(void **appstate, int argc, char *argv[]) {
{ // Initialize text logger
// Initialize text logger radiantwave::logger::Logger::instance().initialize();
radiantwave::logger::Logger::instance().initialize(); radiantwave::logger::Logger::instance().info(
radiantwave::logger::Logger::instance().info("System", "RadiantWave Kiosk starting up"); "System", "RadiantWave Kiosk starting up");
if (!SDL_Init(SDL_INIT_VIDEO)) if (!SDL_Init(SDL_INIT_VIDEO)) {
{ std::string err = SDL_GetError();
std::string err = SDL_GetError(); radiantwave::logger::Logger::instance().fatal(
radiantwave::logger::Logger::instance().fatal("Graphics", "Graphics",
"Screen display initialization failed. Please verify graphics hardware and display connections (SDL_Init error: " + err + ")"); "Screen display initialization failed. Please verify graphics "
SDL_Log("Failed to initialize SDL: %s", err.c_str()); "hardware and display connections (SDL_Init error: " +
return SDL_APP_FAILURE; err + ")");
} SDL_Log("Failed to initialize SDL: %s", err.c_str());
return SDL_APP_FAILURE;
}
auto *state = new AppState{}; auto *state = new AppState{};
// SDL_WINDOW_FULLSCREEN fills the desktop's native resolution, so the // Size the fullscreen window from the display's actual mode so the very
// compositor never has to scale the moving scroller (scaling moving text // first buffer commit already matches what the compositor scanouts 1:1.
// causes flicker). // Requesting a hardcoded size left the backbuffer at that size on
state->window = SDL_CreateWindow("RadiantWave", 1920, 1080, SDL_WINDOW_FULLSCREEN); // displays running a different mode, and frames reached the panel
if (!state->window) // unscaled (quarter-size on 4K, cropped on smaller panels).
{ const SDL_DisplayMode *displayMode =
SDL_Log("Failed to create window: %s", SDL_GetError()); SDL_GetCurrentDisplayMode(SDL_GetPrimaryDisplay());
delete state; int windowWidth =
return SDL_APP_FAILURE; (displayMode && displayMode->w > 0) ? displayMode->w
} : radiantwave::kDesignWidth;
int windowHeight =
(displayMode && displayMode->h > 0) ? displayMode->h
: radiantwave::kDesignHeight;
state->renderer = SDL_CreateRenderer(state->window, nullptr); // Borderless desktop fullscreen: the compositor never has to scale the
if (!state->renderer) // moving scroller (scaling moving text causes flicker). Deliberately no
{ // SDL_WINDOW_HIGH_PIXEL_DENSITY — kiosk panels are not HiDPI, so keeping
SDL_Log("Failed to create renderer: %s", SDL_GetError()); // the pixel buffer equal to the display mode keeps scanout 1:1.
delete state; state->window = SDL_CreateWindow("RadiantWave", windowWidth, windowHeight,
return SDL_APP_FAILURE; SDL_WINDOW_FULLSCREEN);
} if (!state->window) {
SDL_Log("Failed to create window: %s", SDL_GetError());
delete state;
return SDL_APP_FAILURE;
}
// Present on the display's refresh. Without VSync the kiosk panel tears state->renderer = SDL_CreateRenderer(state->window, nullptr);
// fast-scrolling text into horizontal bands that flicker as the text moves. if (!state->renderer) {
if (!SDL_SetRenderVSync(state->renderer, 1)) { SDL_Log("Failed to create renderer: %s", SDL_GetError());
radiantwave::logger::Logger::instance().warning("Graphics", delete state;
"VSync could not be enabled; scrolling text may tear (VSync error: " + std::string(SDL_GetError()) + ")"); return SDL_APP_FAILURE;
} }
// Lock the renderer to a 1920x1080 logical coordinate space. All page // Present on the display's refresh. Without VSync the kiosk panel tears
// layout code already targets this resolution, so everything renders at // fast-scrolling text into horizontal bands that flicker as the text moves.
// the correct size on any display — the GPU scales to native pixels. if (!SDL_SetRenderVSync(state->renderer, 1)) {
if (!SDL_SetRenderLogicalPresentation(state->renderer, 1920, 1080, SDL_LOGICAL_PRESENTATION_STRETCH)) { radiantwave::logger::Logger::instance().warning(
radiantwave::logger::Logger::instance().warning("Graphics", "Graphics", "VSync could not be enabled; scrolling text may tear "
"Could not set logical render size; layout may be wrong on HiDPI displays"); "(VSync error: " +
} std::string(SDL_GetError()) + ")");
}
SDL_HideCursor(); // All page layout targets the fixed design resolution; LETTERBOX maps it
// onto the real window with aspect preserved (bars on non-16:9 panels).
if (!SDL_SetRenderLogicalPresentation(state->renderer,
radiantwave::kDesignWidth,
radiantwave::kDesignHeight,
SDL_LOGICAL_PRESENTATION_LETTERBOX)) {
radiantwave::logger::Logger::instance().warning(
"Graphics", "Could not set logical render size; layout may be "
"wrong on HiDPI displays");
}
// Initialize configuration, database, and network manager {
if (!state->config.initialize()) { // Record how the requested mode, window, pixel buffer, and render
radiantwave::logger::Logger::instance().warning("Config", // output actually compare — this is the evidence trail for
"Settings file could not be loaded. Default settings will be used (Config initialization failed at ~/.local/radiantwave/config.yaml)"); // display-scaling problems on field units.
} else { int windowW = 0, windowH = 0;
radiantwave::logger::Logger::instance().info("Config", "Configuration initialized successfully"); SDL_GetWindowSize(state->window, &windowW, &windowH);
} int pixelW = 0, pixelH = 0;
SDL_GetWindowSizeInPixels(state->window, &pixelW, &pixelH);
int outputW = 0, outputH = 0;
SDL_GetRenderOutputSize(state->renderer, &outputW, &outputH);
radiantwave::logger::Logger::instance().info(
"Graphics",
"Requested " + std::to_string(windowWidth) + "x" +
std::to_string(windowHeight) + ", window " +
std::to_string(windowW) + "x" + std::to_string(windowH) +
", pixels " + std::to_string(pixelW) + "x" +
std::to_string(pixelH) + ", render output " +
std::to_string(outputW) + "x" + std::to_string(outputH) +
", presentation LETTERBOX");
}
if (!state->db.initialize()) { SDL_HideCursor();
radiantwave::logger::Logger::instance().error("Database",
"Database storage unavailable. App features may be limited (Database initialization failed at ~/.local/radiantwave/radiantwave.db)");
} else {
radiantwave::logger::Logger::instance().info("Database", "Database initialized and schema migrated");
}
if (!state->network.initialize()) { // Initialize configuration, database, and network manager
radiantwave::logger::Logger::instance().warning("Network", if (!state->config.initialize()) {
"WiFi network management is unavailable. Check if NetworkManager is running (D-Bus connection to org.freedesktop.NetworkManager failed)"); radiantwave::logger::Logger::instance().warning(
} else { "Config", "Settings file could not be loaded. Default settings "
radiantwave::logger::Logger::instance().info("Network", "NetworkManager service connected"); "will be used (Config initialization failed at "
} "~/.local/radiantwave/config.yaml)");
} else {
radiantwave::logger::Logger::instance().info(
"Config", "Configuration initialized successfully");
}
if (!state->audio.initialize(state->config)) { if (!state->db.initialize()) {
radiantwave::logger::Logger::instance().warning("Audio", radiantwave::logger::Logger::instance().error(
"Audio playback is unavailable. Check sound hardware connections (SDL3_mixer initialization failed)"); "Database", "Database storage unavailable. App features may be "
} else { "limited (Database initialization failed at "
radiantwave::logger::Logger::instance().info("Audio", "Audio subsystem initialized"); "~/.local/radiantwave/radiantwave.db)");
} } else {
// Let playback logs report user-facing display names from the database. radiantwave::logger::Logger::instance().info(
state->audio.attachDatabase(state->db); "Database", "Database initialized and schema migrated");
}
// Background download of audio assets. The worker keeps retrying until if (!state->network.initialize()) {
// internet is available, so downloads start automatically on connect. radiantwave::logger::Logger::instance().warning(
// The downloaded files are reconciled into the database's audio_files table. "Network", "WiFi network management is unavailable. Check if "
state->audioDownloader.start(state->db); "NetworkManager is running (D-Bus connection to "
"org.freedesktop.NetworkManager failed)");
} else {
radiantwave::logger::Logger::instance().info(
"Network", "NetworkManager service connected");
}
// Background license validation. Home triggers a refresh whenever it is if (!state->audio.initialize(state->config)) {
// displayed; the result gates access to Play and the Proclamation options. radiantwave::logger::Logger::instance().warning(
state->licenseTracker.start(state->db); "Audio", "Audio playback is unavailable. Check sound hardware "
"connections (SDL3_mixer initialization failed)");
} else {
radiantwave::logger::Logger::instance().info(
"Audio", "Audio subsystem initialized");
}
// Let playback logs report user-facing display names from the database.
state->audio.attachDatabase(state->db);
state->config.onChanged([state](const radiantwave::config::AppConfig& new_config) { // Background download of audio assets. The worker keeps retrying until
SDL_Log("Live config update received: volume=%d, dynamic_audio=%d", // internet is available, so downloads start automatically on connect.
new_config.audio.volume, new_config.audio.dynamic_audio ? 1 : 0); // The downloaded files are reconciled into the database's audio_files
state->db.logEvent("INFO", "Config", "Live configuration hot-reloaded"); // table.
}); state->audioDownloader.start(state->db);
state->keybinds.bind(SDLK_UP, "menu_up", [state]() { // Background license validation. Home triggers a refresh whenever it is
state->currentMenuItem = (state->currentMenuItem + 1) % 2; // displayed; the result gates access to Play and the Proclamation options.
}); state->licenseTracker.start(state->db);
state->keybinds.bind(SDLK_DOWN, "menu_down", [state]() {
state->currentMenuItem = (state->currentMenuItem + 1) % 2;
});
// Drive the First-Run walkthrough: shows the WiFi page when the internet state->config.onChanged(
// is unreachable, then the email and license pages when those are missing, [state](const radiantwave::config::AppConfig &new_config) {
// finishing on Home. The internet check is a real connectivity probe SDL_Log("Live config update received: volume=%d, dynamic_audio=%d",
// (Ethernet, WiFi, or macOS all work), so it never traps a connected new_config.audio.volume,
// device on the WiFi page. new_config.audio.dynamic_audio ? 1 : 0);
radiantwave::provisioning::begin(*state); state->db.logEvent("INFO", "Config",
"Live configuration hot-reloaded");
});
*appstate = state; state->keybinds.bind(SDLK_UP, "menu_up", [state]() {
return SDL_APP_CONTINUE; state->currentMenuItem = (state->currentMenuItem + 1) % 2;
});
state->keybinds.bind(SDLK_DOWN, "menu_down", [state]() {
state->currentMenuItem = (state->currentMenuItem + 1) % 2;
});
// Drive the First-Run walkthrough: shows the WiFi page when the internet
// is unreachable <DEPRECATED>, then the email and license pages when those
// are missing, finishing on Home. The internet check is a real connectivity
// probe (Ethernet, WiFi, or macOS all work), so it never traps a connected
// device on the WiFi page.
radiantwave::provisioning::begin(*state);
*appstate = state;
return SDL_APP_CONTINUE;
} }
SDL_AppResult SDL_AppEvent(void *appstate, SDL_Event *event) SDL_AppResult SDL_AppEvent(void *appstate, SDL_Event *event) {
{ auto &state = *static_cast<AppState *>(appstate);
auto &state = *static_cast<AppState *>(appstate);
if (event->type == SDL_EVENT_QUIT) if (event->type == SDL_EVENT_QUIT) {
{ state.quitRequested = true;
state.quitRequested = true; }
}
if (!state.keybinds.handleEvent(*event)) { if (event->type == SDL_EVENT_WINDOW_RESIZED) {
state.pages.handleEvent(*event); radiantwave::logger::Logger::instance().info(
} "Graphics", "Window resized to " +
std::to_string(event->window.data1) + "x" +
std::to_string(event->window.data2));
// Logical presentation persists across resizes; re-apply defensively
// in case backend state was reset underneath us.
SDL_SetRenderLogicalPresentation(state.renderer,
radiantwave::kDesignWidth,
radiantwave::kDesignHeight,
SDL_LOGICAL_PRESENTATION_LETTERBOX);
}
if (state.quitRequested && state.quitFadeRemaining <= 0.0f) if (!state.keybinds.handleEvent(*event)) {
{ state.pages.handleEvent(*event);
// Graceful shutdown: start the audio fade-out and keep the loop }
// running until it completes, then let SDL_AppQuit tear everything
// down. When nothing is playing there is nothing to fade and quitting
// is immediate.
if (state.audio.stopTrackFadeOut(kQuitFadeSeconds))
{
state.quitFadeRemaining = kQuitFadeSeconds;
return SDL_APP_CONTINUE;
}
return SDL_APP_SUCCESS;
}
return SDL_APP_CONTINUE; if (state.quitRequested && state.quitFadeRemaining <= 0.0f) {
// Graceful shutdown: start the audio fade-out and keep the loop
// running until it completes, then let SDL_AppQuit tear everything
// down. When nothing is playing there is nothing to fade and quitting
// is immediate.
if (state.audio.stopTrackFadeOut(kQuitFadeSeconds)) {
state.quitFadeRemaining = kQuitFadeSeconds;
return SDL_APP_CONTINUE;
}
return SDL_APP_SUCCESS;
}
return SDL_APP_CONTINUE;
} }
SDL_AppResult SDL_AppIterate(void *appstate) SDL_AppResult SDL_AppIterate(void *appstate) {
{ auto &state = *static_cast<AppState *>(appstate);
auto &state = *static_cast<AppState *>(appstate);
Uint64 nowTicks = SDL_GetTicks(); Uint64 nowTicks = SDL_GetTicks();
static Uint64 lastTicks = nowTicks; static Uint64 lastTicks = nowTicks;
float dt = static_cast<float>(nowTicks - lastTicks) / 1000.0f; float dt = static_cast<float>(nowTicks - lastTicks) / 1000.0f;
if (dt > 0.1f) dt = 0.1f; if (dt > 0.1f)
lastTicks = nowTicks; dt = 0.1f;
lastTicks = nowTicks;
state.pages.update(dt); state.pages.update(dt);
if (state.quitFadeRemaining > 0.0f) if (state.quitFadeRemaining > 0.0f) {
{ state.quitFadeRemaining -= dt;
state.quitFadeRemaining -= dt; if (state.quitFadeRemaining <= 0.0f) {
if (state.quitFadeRemaining <= 0.0f) return SDL_APP_SUCCESS;
{ }
return SDL_APP_SUCCESS; }
}
}
SDL_SetRenderDrawColor(state.renderer, 30, 30, 46, 255); SDL_SetRenderDrawColor(state.renderer, 30, 30, 46, 255);
SDL_RenderClear(state.renderer); SDL_RenderClear(state.renderer);
state.pages.render(state.renderer); state.pages.render(state.renderer);
SDL_RenderPresent(state.renderer); SDL_RenderPresent(state.renderer);
return SDL_APP_CONTINUE; return SDL_APP_CONTINUE;
} }
void SDL_AppQuit(void *appstate, SDL_AppResult result) void SDL_AppQuit(void *appstate, SDL_AppResult result) {
{ auto *state = static_cast<AppState *>(appstate);
auto *state = static_cast<AppState *>(appstate); if (state) {
if (state) { state->audioDownloader.shutdown();
state->audioDownloader.shutdown(); state->licenseTracker.shutdown();
state->licenseTracker.shutdown(); state->audio.shutdown();
state->audio.shutdown(); state->network.shutdown();
state->network.shutdown(); state->config.shutdown();
state->config.shutdown(); state->db.close();
state->db.close(); SDL_DestroyRenderer(state->renderer);
SDL_DestroyRenderer(state->renderer); SDL_DestroyWindow(state->window);
SDL_DestroyWindow(state->window); delete state;
delete state; }
} SDL_Quit();
SDL_Quit();
} }
+32 -18
View File
@@ -1,6 +1,7 @@
#include "proclamation_options_page.h" #include "proclamation_options_page.h"
#include "app_state.h" #include "app_state.h"
#include "network/proclamation_sync.h" #include "network/proclamation_sync.h"
#include "logical_size.h"
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include <algorithm> #include <algorithm>
#include <cctype> #include <cctype>
@@ -197,7 +198,7 @@ void ProclamationOptionsPage::rebuildItems()
FlatItem::Type::SubCategory, FlatItem::Type::SubCategory,
std::to_string(sub.id), std::to_string(sub.id),
sub.name, sub.name,
1, false, false, false, 1, true, false, false,
}); });
for (const auto& p : proclamations_) { for (const auto& p : proclamations_) {
@@ -267,7 +268,7 @@ void ProclamationOptionsPage::rebuildVisible()
parentVisible = true; // the always-on top-level proclamation parentVisible = true; // the always-on top-level proclamation
} else if (currentRoot >= 0 && items_[currentRoot].expanded) { } else if (currentRoot >= 0 && items_[currentRoot].expanded) {
parentVisible = (item.depth == 1) || parentVisible = (item.depth == 1) ||
(item.depth == 2 && currentSub >= 0 && items_[currentSub].expanded); (item.depth == 2 && currentSub >= 0);
} }
if (parentVisible) visible_.push_back(i); if (parentVisible) visible_.push_back(i);
} }
@@ -282,7 +283,7 @@ void ProclamationOptionsPage::toggleItem()
if (visible_.empty()) return; if (visible_.empty()) return;
FlatItem& item = items_[visible_[highlightIndex_]]; FlatItem& item = items_[visible_[highlightIndex_]];
if (item.type == FlatItem::Type::RootCategory || item.type == FlatItem::Type::SubCategory) { if (item.type == FlatItem::Type::RootCategory) {
item.expanded = !item.expanded; item.expanded = !item.expanded;
rebuildVisible(); rebuildVisible();
return; return;
@@ -387,7 +388,7 @@ void ProclamationOptionsPage::collapseCurrent()
{ {
if (visible_.empty()) return; if (visible_.empty()) return;
FlatItem& item = items_[visible_[highlightIndex_]]; FlatItem& item = items_[visible_[highlightIndex_]];
if (item.type == FlatItem::Type::RootCategory || item.type == FlatItem::Type::SubCategory) { if (item.type == FlatItem::Type::RootCategory) {
if (item.expanded) { if (item.expanded) {
item.expanded = false; item.expanded = false;
rebuildVisible(); rebuildVisible();
@@ -399,7 +400,7 @@ void ProclamationOptionsPage::expandCurrent()
{ {
if (visible_.empty()) return; if (visible_.empty()) return;
FlatItem& item = items_[visible_[highlightIndex_]]; FlatItem& item = items_[visible_[highlightIndex_]];
if (item.type == FlatItem::Type::RootCategory || item.type == FlatItem::Type::SubCategory) { if (item.type == FlatItem::Type::RootCategory) {
if (!item.expanded) { if (!item.expanded) {
item.expanded = true; item.expanded = true;
rebuildVisible(); rebuildVisible();
@@ -525,8 +526,7 @@ void ProclamationOptionsPage::handleEvent(const SDL_Event& event)
if (searchActive_) return; if (searchActive_) return;
if (!visible_.empty()) { if (!visible_.empty()) {
const FlatItem& item = items_[visible_[highlightIndex_]]; const FlatItem& item = items_[visible_[highlightIndex_]];
if ((item.type == FlatItem::Type::RootCategory || if (item.type == FlatItem::Type::RootCategory && item.expanded) {
item.type == FlatItem::Type::SubCategory) && item.expanded) {
collapseCurrent(); collapseCurrent();
} else { } else {
moveToParent(); moveToParent();
@@ -552,7 +552,7 @@ void ProclamationOptionsPage::update(float dt)
void ProclamationOptionsPage::render(SDL_Renderer* renderer) void ProclamationOptionsPage::render(SDL_Renderer* renderer)
{ {
int winW, winH; int winW, winH;
SDL_GetWindowSize(SDL_GetRenderWindow(renderer), &winW, &winH); radiantwave::getLayoutSize(renderer, &winW, &winH);
float fw = static_cast<float>(winW); float fw = static_cast<float>(winW);
float fh = static_cast<float>(winH); float fh = static_cast<float>(winH);
@@ -654,14 +654,18 @@ void ProclamationOptionsPage::render(SDL_Renderer* renderer)
SDL_RenderTexture(renderer, emptyHintText_.texture, nullptr, &dst); SDL_RenderTexture(renderer, emptyHintText_.texture, nullptr, &dst);
} }
} else { } else {
// Compute the widest row so the block can be centered. // Stable width: compute from every result so the list doesn't shift.
float maxTotalWidth = 0.0f; float maxTotalWidth = 0.0f;
for (const auto& r : searchResults_) {
int w = 0, h = 0;
TTF_GetStringSize(fontItem_, proclamations_[r].title.c_str(), 0, &w, &h);
maxTotalWidth = std::max(maxTotalWidth, static_cast<float>(w));
}
std::vector<float> textWidths(static_cast<size_t>(listSize)); std::vector<float> textWidths(static_cast<size_t>(listSize));
for (int i = scrollOffset_; i < scrollOffset_ + visibleCount_ && i < listSize; ++i) { for (int i = scrollOffset_; i < scrollOffset_ + visibleCount_ && i < listSize; ++i) {
const std::string& label = proclamations_[searchResults_[i]].title;
int w = 0, h = 0; int w = 0, h = 0;
TTF_GetStringSize(fontItem_, label.c_str(), 0, &w, &h); TTF_GetStringSize(fontItem_, proclamations_[searchResults_[i]].title.c_str(), 0, &w, &h);
maxTotalWidth = std::max(maxTotalWidth, static_cast<float>(w));
textWidths[i] = static_cast<float>(w); textWidths[i] = static_cast<float>(w);
} }
float sharedX = (fw - maxTotalWidth) / 2.0f; float sharedX = (fw - maxTotalWidth) / 2.0f;
@@ -694,19 +698,29 @@ void ProclamationOptionsPage::render(SDL_Renderer* renderer)
} }
} else { } else {
// --- Browse tree --- // --- Browse tree ---
// Compute the widest row so the block can be centered. // Stable width: compute from every item so the list doesn't
// shift left/right when groups expand or collapse.
float maxTotalWidth = 0.0f; float maxTotalWidth = 0.0f;
std::vector<float> textWidths(visible_.size()); for (const auto& item : items_) {
for (int i = scrollOffset_; i < scrollOffset_ + visibleCount_ && i < static_cast<int>(visible_.size()); ++i) {
const FlatItem& item = items_[visible_[i]];
std::string label = item.label; std::string label = item.label;
if (item.type == FlatItem::Type::RootCategory || item.type == FlatItem::Type::SubCategory) { if (item.type == FlatItem::Type::RootCategory) {
label = (item.expanded ? "[-] " : "[+] ") + label; label = (item.expanded ? "[-] " : "[+] ") + label;
} }
int w = 0, h = 0; int w = 0, h = 0;
TTF_GetStringSize(fontItem_, label.c_str(), 0, &w, &h); TTF_GetStringSize(fontItem_, label.c_str(), 0, &w, &h);
float total = item.depth * indent + static_cast<float>(w); float total = item.depth * indent + static_cast<float>(w);
maxTotalWidth = std::max(maxTotalWidth, total); maxTotalWidth = std::max(maxTotalWidth, total);
}
std::vector<float> textWidths(visible_.size());
for (int i = scrollOffset_; i < scrollOffset_ + visibleCount_ && i < static_cast<int>(visible_.size()); ++i) {
const FlatItem& item = items_[visible_[i]];
std::string label = item.label;
if (item.type == FlatItem::Type::RootCategory) {
label = (item.expanded ? "[-] " : "[+] ") + label;
}
int w = 0, h = 0;
TTF_GetStringSize(fontItem_, label.c_str(), 0, &w, &h);
textWidths[i] = static_cast<float>(w); textWidths[i] = static_cast<float>(w);
} }
@@ -720,7 +734,7 @@ void ProclamationOptionsPage::render(SDL_Renderer* renderer)
float y = listTop + rowIndex * rowPitch; float y = listTop + rowIndex * rowPitch;
std::string label = item.label; std::string label = item.label;
if (item.type == FlatItem::Type::RootCategory || item.type == FlatItem::Type::SubCategory) { if (item.type == FlatItem::Type::RootCategory) {
label = (item.expanded ? "[-] " : "[+] ") + label; label = (item.expanded ? "[-] " : "[+] ") + label;
} }
+61 -59
View File
@@ -1,80 +1,82 @@
#include "provisioning.h" #include "provisioning.h"
#include "wifi_setup_page.h"
#include "email_address_page.h" #include "email_address_page.h"
#include "license_key_page.h"
#include "home.h" #include "home.h"
#include "license_key_page.h"
#include "logger/logger.h" #include "logger/logger.h"
#include "wifi_setup_page.h"
namespace radiantwave::provisioning { namespace radiantwave::provisioning {
// Push the page for a walkthrough step, or Home when the walkthrough is done. // Push the page for a walkthrough step, or Home when the walkthrough is done.
static void pushStep(AppState& state, Step step) static void pushStep(AppState &state, Step step) {
{ switch (step) {
switch (step) { // case Step::Internet:
case Step::Internet: // state.pages.push(state, std::make_unique<WiFiSetupPage>());
state.pages.push(state, std::make_unique<WiFiSetupPage>()); // break;
break; case Step::Email:
case Step::Email: state.pages.push(state, std::make_unique<EmailAddressPage>());
state.pages.push(state, std::make_unique<EmailAddressPage>()); break;
break; case Step::License:
case Step::License: state.pages.push(state, std::make_unique<LicenseKeyPage>());
state.pages.push(state, std::make_unique<LicenseKeyPage>()); break;
break; case Step::Done:
case Step::Done: state.provisioningMode = false;
state.provisioningMode = false; state.pages.push(state, std::make_unique<Home>());
state.pages.push(state, std::make_unique<Home>()); break;
break; }
}
} }
void begin(AppState& state) void begin(AppState &state) {
{ // Without the database there is nowhere to persist email/license, so the
// Without the database there is nowhere to persist email/license, so the // walkthrough would trap the user on pages that can never be completed.
// walkthrough would trap the user on pages that can never be completed. if (!state.db.isOpen()) {
if (!state.db.isOpen()) { state.provisioningMode = false;
state.provisioningMode = false; state.pages.push(state, std::make_unique<Home>());
state.pages.push(state, std::make_unique<Home>()); return;
return; }
}
const bool internetUp = state.network.hasInternet(); const bool internetUp = state.network.hasInternet();
const bool hasEmail = !state.db.getKV(radiantwave::db::KV_EMAIL_ADDRESS).empty(); const bool hasEmail =
const bool hasLicense = !state.db.getKV(radiantwave::db::KV_LICENSE_KEY).empty(); !state.db.getKV(radiantwave::db::KV_EMAIL_ADDRESS).empty();
const bool hasLicense =
!state.db.getKV(radiantwave::db::KV_LICENSE_KEY).empty();
if (indicatesFirstRun(internetUp, hasEmail, hasLicense)) { if (indicatesFirstRun(internetUp, hasEmail, hasLicense)) {
// Placeholder: a brand-new device. Later this flag will launch the // Placeholder: a brand-new device. Later this flag will launch the
// first-run welcome walkthrough (TOS, quick user guide, ...). // first-run welcome walkthrough (TOS, quick user guide, ...).
state.firstRunWalkthrough = true; state.firstRunWalkthrough = true;
radiantwave::logger::Logger::instance().info("Provisioning", radiantwave::logger::Logger::instance().info(
"First-Run detected (no internet, no email, no license); " "Provisioning",
"first-run welcome walkthrough is not yet implemented"); "First-Run detected (no internet, no email, no license); "
} "first-run welcome walkthrough is not yet implemented");
}
state.walkthroughInternetUp = internetUp; state.walkthroughInternetUp = internetUp;
state.provisioningMode = true; state.provisioningMode = true;
pushStep(state, firstMissingStep(internetUp, hasEmail, hasLicense)); pushStep(state, firstMissingStep(internetUp, hasEmail, hasLicense));
} }
void advance(AppState& state) void advance(AppState &state) {
{ if (state.pages.depth() > 0) {
if (state.pages.depth() > 0) { state.pages.pop(state);
state.pages.pop(state); }
}
// Internet is the first prerequisite and never gets "un-reached" once // Internet is the first prerequisite and never gets "un-reached" once
// satisfied, so it is only re-probed when it was still missing (i.e. the // satisfied, so it is only re-probed when it was still missing (i.e. the
// walkthrough is coming from the WiFi page). // walkthrough is coming from the WiFi page).
bool internetUp = state.walkthroughInternetUp; bool internetUp = state.walkthroughInternetUp;
if (!internetUp) { if (!internetUp) {
internetUp = state.network.hasInternet(); internetUp = state.network.hasInternet();
state.walkthroughInternetUp = internetUp; state.walkthroughInternetUp = internetUp;
} }
const bool hasEmail = !state.db.getKV(radiantwave::db::KV_EMAIL_ADDRESS).empty(); const bool hasEmail =
const bool hasLicense = !state.db.getKV(radiantwave::db::KV_LICENSE_KEY).empty(); !state.db.getKV(radiantwave::db::KV_EMAIL_ADDRESS).empty();
const bool hasLicense =
!state.db.getKV(radiantwave::db::KV_LICENSE_KEY).empty();
state.provisioningMode = true; state.provisioningMode = true;
pushStep(state, firstMissingStep(internetUp, hasEmail, hasLicense)); pushStep(state, firstMissingStep(internetUp, hasEmail, hasLicense));
} }
} // namespace radiantwave::provisioning } // namespace radiantwave::provisioning
+12 -12
View File
@@ -24,33 +24,33 @@
namespace radiantwave::provisioning { namespace radiantwave::provisioning {
// Prerequisite steps in walkthrough priority order. // Prerequisite steps in walkthrough priority order.
enum class Step { Done, Internet, Email, License }; enum class Step { Done, Email, License };
// First unsatisfied step, given the current conditions. // First unsatisfied step, given the current conditions.
inline Step firstMissingStep(bool internetUp, bool hasEmail, bool hasLicense) inline Step firstMissingStep(bool internetUp, bool hasEmail, bool hasLicense) {
{ // if (!internetUp) return Step::Internet;
if (!internetUp) return Step::Internet; if (!hasEmail)
if (!hasEmail) return Step::Email; return Step::Email;
if (!hasLicense) return Step::License; if (!hasLicense)
return Step::Done; return Step::License;
return Step::Done;
} }
// True when nothing is configured (no internet, no email, no license). // True when nothing is configured (no internet, no email, no license).
// Signals a brand-new, never-provisioned device. // Signals a brand-new, never-provisioned device.
inline bool indicatesFirstRun(bool internetUp, bool hasEmail, bool hasLicense) inline bool indicatesFirstRun(bool internetUp, bool hasEmail, bool hasLicense) {
{ return !internetUp && !hasEmail && !hasLicense;
return !internetUp && !hasEmail && !hasLicense;
} }
// Startup walkthrough. Evaluates the prerequisites and pushes the first // Startup walkthrough. Evaluates the prerequisites and pushes the first
// missing step's page (with provisioningMode set), or Home when everything // missing step's page (with provisioningMode set), or Home when everything
// is satisfied. Home is always the end of the walkthrough. // is satisfied. Home is always the end of the walkthrough.
void begin(AppState& state); void begin(AppState &state);
// Called by a walkthrough page when the user finishes or exits it. Pops the // Called by a walkthrough page when the user finishes or exits it. Pops the
// current page, re-evaluates the prerequisites, and pushes the next missing // current page, re-evaluates the prerequisites, and pushes the next missing
// step's page (or Home). Internet is only re-probed when it was still // step's page (or Home). Internet is only re-probed when it was still
// missing, so completing a step never blocks on a redundant check. // missing, so completing a step never blocks on a redundant check.
void advance(AppState& state); void advance(AppState &state);
} // namespace radiantwave::provisioning } // namespace radiantwave::provisioning
+2 -1
View File
@@ -5,6 +5,7 @@
#include "proclamation_options_page.h" #include "proclamation_options_page.h"
#include "scramble/fibonacci_scrambler.h" #include "scramble/fibonacci_scrambler.h"
#include "utf8_utils.h" #include "utf8_utils.h"
#include "logical_size.h"
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include <SDL3_ttf/SDL_ttf.h> #include <SDL3_ttf/SDL_ttf.h>
@@ -35,7 +36,7 @@ void ScrollerPage::onPush(AppState &state)
int winW = 0; int winW = 0;
int winH = 0; int winH = 0;
SDL_GetWindowSize(state.window, &winW, &winH); radiantwave::getLayoutSize(state.renderer, &winW, &winH);
screenW_ = static_cast<float>(winW); screenW_ = static_cast<float>(winW);
screenH_ = static_cast<float>(winH); screenH_ = static_cast<float>(winH);
// Lines are capped at the full screen width; the widest Fibonacci lines are // Lines are capped at the full screen width; the widest Fibonacci lines are
+20 -19
View File
@@ -10,6 +10,7 @@
#include "email_address_page.h" #include "email_address_page.h"
#include "license_key_page.h" #include "license_key_page.h"
#include "date_format_page.h" #include "date_format_page.h"
#include "logical_size.h"
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include <SDL3_image/SDL_image.h> #include <SDL3_image/SDL_image.h>
#include <algorithm> #include <algorithm>
@@ -18,18 +19,18 @@ namespace
{ {
constexpr int MENU_ITEM_COUNT = 9; constexpr int MENU_ITEM_COUNT = 9;
// 0 = WiFi Setup, 1 = Audio Options, 2 = Time Zone Setup, 3 = Proclamations, // 0 = Proclamations, 1 = Audio Options, 2 = Email Address, 3 = License Key,
// 4 = Email Address, 5 = License Key, 6 = Date Format, // 4 = WiFi Setup, 5 = Time Zone Setup, 6 = Date Format,
// 7 = View System Logs, 8 = Back // 7 = System Logs, 8 = Back
constexpr const char *MENU_LABELS[MENU_ITEM_COUNT] = { constexpr const char *MENU_LABELS[MENU_ITEM_COUNT] = {
"WiFi Setup",
"Audio Options",
"Time Zone Setup",
"Proclamations", "Proclamations",
"Audio Options",
"Email Address", "Email Address",
"License Key", "License Key",
"WiFi Setup",
"Time Zone Setup",
"Date Format", "Date Format",
"View System Logs", "System Logs",
"Back to Home", "Back to Home",
}; };
@@ -77,15 +78,6 @@ void Settings::handleEvent(const SDL_Event &event)
switch (selectedItem_) switch (selectedItem_)
{ {
case 0: case 0:
state_->pages.push(*state_, std::make_unique<WiFiSetupPage>());
break;
case 1:
state_->pages.push(*state_, std::make_unique<AudioSettingsPage>());
break;
case 2:
state_->pages.push(*state_, std::make_unique<TimeZonePage>());
break;
case 3:
// The Proclamation list is license-gated like Play. // The Proclamation list is license-gated like Play.
if (state_->licenseTracker.isValid()) if (state_->licenseTracker.isValid())
{ {
@@ -96,12 +88,21 @@ void Settings::handleEvent(const SDL_Event &event)
state_->pages.push(*state_, std::make_unique<LicenseBlockedPage>()); state_->pages.push(*state_, std::make_unique<LicenseBlockedPage>());
} }
break; break;
case 4: case 1:
state_->pages.push(*state_, std::make_unique<AudioSettingsPage>());
break;
case 2:
state_->pages.push(*state_, std::make_unique<EmailAddressPage>()); state_->pages.push(*state_, std::make_unique<EmailAddressPage>());
break; break;
case 5: case 3:
state_->pages.push(*state_, std::make_unique<LicenseKeyPage>()); state_->pages.push(*state_, std::make_unique<LicenseKeyPage>());
break; break;
case 4:
state_->pages.push(*state_, std::make_unique<WiFiSetupPage>());
break;
case 5:
state_->pages.push(*state_, std::make_unique<TimeZonePage>());
break;
case 6: case 6:
state_->pages.push(*state_, std::make_unique<DateFormatPage>()); state_->pages.push(*state_, std::make_unique<DateFormatPage>());
break; break;
@@ -128,7 +129,7 @@ void Settings::update(float dt)
void Settings::render(SDL_Renderer *renderer) void Settings::render(SDL_Renderer *renderer)
{ {
int winW, winH; int winW, winH;
SDL_GetWindowSize(SDL_GetRenderWindow(renderer), &winW, &winH); radiantwave::getLayoutSize(renderer, &winW, &winH);
float fw = static_cast<float>(winW); float fw = static_cast<float>(winW);
float fh = static_cast<float>(winH); float fh = static_cast<float>(winH);
+2 -1
View File
@@ -1,5 +1,6 @@
#include "timezone_page.h" #include "timezone_page.h"
#include "app_state.h" #include "app_state.h"
#include "logical_size.h"
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include <algorithm> #include <algorithm>
#include <ctime> #include <ctime>
@@ -199,7 +200,7 @@ void TimeZonePage::update(float dt) {
void TimeZonePage::render(SDL_Renderer* renderer) { void TimeZonePage::render(SDL_Renderer* renderer) {
int winW, winH; int winW, winH;
SDL_GetWindowSize(SDL_GetRenderWindow(renderer), &winW, &winH); radiantwave::getLayoutSize(renderer, &winW, &winH);
float fw = static_cast<float>(winW); float fw = static_cast<float>(winW);
float fh = static_cast<float>(winH); float fh = static_cast<float>(winH);
+2 -1
View File
@@ -1,6 +1,7 @@
#include "wifi_setup_page.h" #include "wifi_setup_page.h"
#include "app_state.h" #include "app_state.h"
#include "provisioning.h" #include "provisioning.h"
#include "logical_size.h"
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include <algorithm> #include <algorithm>
#include <exception> #include <exception>
@@ -301,7 +302,7 @@ void WiFiSetupPage::update(float dt) {
void WiFiSetupPage::render(SDL_Renderer* renderer) { void WiFiSetupPage::render(SDL_Renderer* renderer) {
int winW, winH; int winW, winH;
SDL_GetWindowSize(SDL_GetRenderWindow(renderer), &winW, &winH); radiantwave::getLayoutSize(renderer, &winW, &winH);
float fw = static_cast<float>(winW); float fw = static_cast<float>(winW);
float fh = static_cast<float>(winH); float fh = static_cast<float>(winH);
+16 -30
View File
@@ -1,51 +1,37 @@
#include "test_harness.h"
#include "../src/provisioning.h" #include "../src/provisioning.h"
#include "test_harness.h"
using radiantwave::provisioning::firstMissingStep; using radiantwave::provisioning::firstMissingStep;
using radiantwave::provisioning::indicatesFirstRun; using radiantwave::provisioning::indicatesFirstRun;
using radiantwave::provisioning::Step; using radiantwave::provisioning::Step;
TEST(first_missing_step_all_satisfied) TEST(first_missing_step_all_satisfied) {
{ ASSERT_EQ(firstMissingStep(true, true, true), Step::Done);
ASSERT_EQ(firstMissingStep(true, true, true), Step::Done);
} }
TEST(first_missing_step_no_internet) TEST(first_missing_step_no_email) {
{ ASSERT_EQ(firstMissingStep(true, false, false), Step::Email);
// Internet is checked before email/license regardless of the others. ASSERT_EQ(firstMissingStep(true, false, true), Step::Email);
ASSERT_EQ(firstMissingStep(false, false, false), Step::Internet);
ASSERT_EQ(firstMissingStep(false, true, true), Step::Internet);
} }
TEST(first_missing_step_no_email) TEST(first_missing_step_no_license) {
{ ASSERT_EQ(firstMissingStep(true, true, false), Step::License);
ASSERT_EQ(firstMissingStep(true, false, false), Step::Email);
ASSERT_EQ(firstMissingStep(true, false, true), Step::Email);
} }
TEST(first_missing_step_no_license) TEST(first_run_all_missing) {
{ ASSERT_TRUE(indicatesFirstRun(false, false, false));
ASSERT_EQ(firstMissingStep(true, true, false), Step::License);
} }
TEST(first_run_all_missing) TEST(first_run_has_internet) {
{ ASSERT_FALSE(indicatesFirstRun(true, false, false));
ASSERT_TRUE(indicatesFirstRun(false, false, false));
} }
TEST(first_run_has_internet) TEST(first_run_has_email) {
{ ASSERT_FALSE(indicatesFirstRun(false, true, false));
ASSERT_FALSE(indicatesFirstRun(true, false, false));
} }
TEST(first_run_has_email) TEST(first_run_has_license) {
{ ASSERT_FALSE(indicatesFirstRun(false, false, true));
ASSERT_FALSE(indicatesFirstRun(false, true, false));
}
TEST(first_run_has_license)
{
ASSERT_FALSE(indicatesFirstRun(false, false, true));
} }
RUN_ALL_TESTS() RUN_ALL_TESTS()