Kiosk: add a data-driven System Settings menu

Settings now shows Proclamations, Audio Options, System Settings, System
Logs, and Back. The new SystemSettingsPage groups Email Address, License
Key, WiFi Setup, Time Zone Setup, and Date Format, driven by the pure
settings::systemSettingEntries() list so future entries need one row, not
new menu plumbing. Adds unit tests for the entry list.
This commit is contained in:
NeilClack
2026-09-29 08:45:16 -05:00
parent 59b499037b
commit 003a90d810
7 changed files with 433 additions and 28 deletions
+272
View File
@@ -0,0 +1,272 @@
#include "system_settings_page.h"
#include "app_state.h"
#include "date_format_page.h"
#include "email_address_page.h"
#include "license_key_page.h"
#include "logical_size.h"
#include "timezone_page.h"
#include "wifi_setup_page.h"
#include <SDL3/SDL.h>
#include <SDL3_image/SDL_image.h>
#include <algorithm>
namespace
{
constexpr const char *TITLE = "System Settings";
constexpr const char *BACK_LABEL = "Back to Settings";
} // anonymous namespace
void SystemSettingsPage::onPush(AppState &state)
{
state_ = &state;
TTF_Init();
createTextures(state);
bindNavigation(state);
}
void SystemSettingsPage::onResume(AppState &state)
{
state_ = &state;
createTextures(state);
bindNavigation(state);
}
void SystemSettingsPage::onPause(AppState &state)
{
state.keybinds.unbindAll();
destroyTextures();
}
void SystemSettingsPage::onPop(AppState &state)
{
destroyTextures();
TTF_Quit();
}
void SystemSettingsPage::bindNavigation(AppState &state)
{
state.keybinds.bind(SDLK_UP, "system_settings_up", [this]()
{
int count = static_cast<int>(items_.size());
if (count > 0)
selectedItem_ = (selectedItem_ - 1 + count) % count;
});
state.keybinds.bind(SDLK_DOWN, "system_settings_down", [this]()
{
int count = static_cast<int>(items_.size());
if (count > 0)
selectedItem_ = (selectedItem_ + 1) % count;
});
}
void SystemSettingsPage::activateSelected(AppState &state)
{
// The trailing row (index == number of setting entries) goes back.
if (selectedItem_ < 0 || selectedItem_ >= static_cast<int>(entries_.size()))
{
state.pages.pop(state);
return;
}
switch (entries_[selectedItem_].id)
{
case radiantwave::settings::SystemSettingId::EmailAddress:
state.pages.push(state, std::make_unique<EmailAddressPage>());
break;
case radiantwave::settings::SystemSettingId::LicenseKey:
state.pages.push(state, std::make_unique<LicenseKeyPage>());
break;
case radiantwave::settings::SystemSettingId::WiFiSetup:
state.pages.push(state, std::make_unique<WiFiSetupPage>());
break;
case radiantwave::settings::SystemSettingId::Timezone:
state.pages.push(state, std::make_unique<TimeZonePage>());
break;
case radiantwave::settings::SystemSettingId::DateFormat:
state.pages.push(state, std::make_unique<DateFormatPage>());
break;
}
}
void SystemSettingsPage::handleEvent(const SDL_Event &event)
{
if (event.type == SDL_EVENT_KEY_DOWN && event.key.key == SDLK_RETURN)
{
activateSelected(*state_);
}
else if (event.type == SDL_EVENT_KEY_DOWN && event.key.key == SDLK_ESCAPE)
{
state_->pages.pop(*state_);
}
}
void SystemSettingsPage::update(float dt)
{
}
void SystemSettingsPage::render(SDL_Renderer *renderer)
{
int winW, winH;
radiantwave::getLayoutSize(renderer, &winW, &winH);
float fw = static_cast<float>(winW);
float fh = static_cast<float>(winH);
// 1. Logo Background Image
if (bgTexture_)
{
float scaleX = fw / bgWidth_;
float scaleY = fh / bgHeight_;
float scale = std::min(scaleX, scaleY);
float dw = bgWidth_ * scale;
float dh = bgHeight_ * scale;
float dx = (fw - dw) / 2.0f;
float dy = (fh - dh) / 2.0f;
SDL_FRect bgDst{dx, dy, dw, dh};
SDL_RenderTexture(renderer, bgTexture_, nullptr, &bgDst);
}
// 2. Dark Overlay
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 160);
SDL_FRect overlayRect{0, 0, fw, fh};
SDL_RenderFillRect(renderer, &overlayRect);
// 3. Title Text
float titleY = 60.0f;
if (titleTexture_)
{
float x = (fw - titleWidth_) / 2.0f;
SDL_FRect dst{x, titleY, titleWidth_, titleHeight_};
SDL_RenderTexture(renderer, titleTexture_, nullptr, &dst);
}
// Compute bounding box for all menu items
float maxWidth = 0.0f;
for (const auto &item : items_)
{
maxWidth = std::max(maxWidth, item.width);
}
float margin = 12.0f; // 12px vertical margin rule
// Shared left edge for left-aligned items with centered bounding box
float sharedX = (fw - maxWidth) / 2.0f;
// Start menu list exactly 24 pixels below title text
float currentY = titleY + titleHeight_ + 24.0f;
for (auto &item : items_)
{
item.y = currentY;
if (item.texture)
{
SDL_FRect dst{sharedX, item.y, item.width, item.height};
SDL_RenderTexture(renderer, item.texture, nullptr, &dst);
}
currentY += item.height + margin;
}
// 6px Underline for selected item occupying half of the 12px margin space
if (selectedItem_ >= 0 && selectedItem_ < static_cast<int>(items_.size()))
{
const auto &item = items_[selectedItem_];
if (item.texture)
{
SDL_SetRenderDrawColor(renderer, 255, 255, 255, 255);
SDL_FRect line{sharedX, item.y + item.height + 2.0f, item.width, 6.0f};
SDL_RenderFillRect(renderer, &line);
}
}
}
void SystemSettingsPage::createTextures(AppState &state)
{
destroyTextures();
entries_ = radiantwave::settings::systemSettingEntries();
if (selectedItem_ >= static_cast<int>(entries_.size()) + 1)
{
selectedItem_ = 0;
}
std::vector<std::string> labels;
labels.reserve(entries_.size() + 1);
for (const auto &entry : entries_)
{
labels.push_back(entry.label);
}
labels.push_back(BACK_LABEL);
// Background Image
SDL_Surface *bgSurface = IMG_Load("assets/images/RWT-Circular-Color-ForOnDark-NoText.png");
if (bgSurface)
{
bgTexture_ = SDL_CreateTextureFromSurface(state.renderer, bgSurface);
bgWidth_ = static_cast<float>(bgSurface->w);
bgHeight_ = static_cast<float>(bgSurface->h);
SDL_DestroySurface(bgSurface);
SDL_SetTextureAlphaMod(bgTexture_, 204);
}
SDL_Color white = {255, 255, 255, 255};
// Title Font
TTF_Font *fontLarge = TTF_OpenFont("assets/fonts/Roboto-Regular.ttf", 64);
if (fontLarge)
{
SDL_Surface *surface = TTF_RenderText_Blended(fontLarge, TITLE, 0, white);
if (surface)
{
titleTexture_ = SDL_CreateTextureFromSurface(state.renderer, surface);
titleWidth_ = static_cast<float>(surface->w);
titleHeight_ = static_cast<float>(surface->h);
SDL_DestroySurface(surface);
}
TTF_CloseFont(fontLarge);
}
// Item Font
TTF_Font *fontItem = TTF_OpenFont("assets/fonts/Roboto-Regular.ttf", 36);
if (!fontItem)
return;
items_.reserve(labels.size());
for (const auto &label : labels)
{
MenuItem item;
item.label = label;
SDL_Surface *surface = TTF_RenderText_Blended(fontItem, label.c_str(), 0, white);
if (surface)
{
item.texture = SDL_CreateTextureFromSurface(state.renderer, surface);
item.width = static_cast<float>(surface->w);
item.height = static_cast<float>(surface->h);
SDL_DestroySurface(surface);
}
items_.push_back(std::move(item));
}
TTF_CloseFont(fontItem);
}
void SystemSettingsPage::destroyTextures()
{
if (bgTexture_)
{
SDL_DestroyTexture(bgTexture_);
bgTexture_ = nullptr;
}
if (titleTexture_)
{
SDL_DestroyTexture(titleTexture_);
titleTexture_ = nullptr;
}
for (auto &item : items_)
{
if (item.texture)
{
SDL_DestroyTexture(item.texture);
item.texture = nullptr;
}
}
items_.clear();
}