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.
460 lines
15 KiB
C++
460 lines
15 KiB
C++
#include "home.h"
|
|
#include "app_state.h"
|
|
#include "settings.h"
|
|
#include "scroller_page.h"
|
|
#include "license_blocked_page.h"
|
|
#include "proclamation_options_page.h"
|
|
#include "date_format.h"
|
|
#include "audio/audio_downloader.h"
|
|
#include "db/db_keys.h"
|
|
#include "network/proclamation_sync.h"
|
|
#include "logical_size.h"
|
|
#include <SDL3/SDL.h>
|
|
#include <SDL3_image/SDL_image.h>
|
|
#include <ctime>
|
|
#include <vector>
|
|
#include <algorithm>
|
|
|
|
#ifndef APP_VERSION
|
|
#define APP_VERSION "dev"
|
|
#endif
|
|
|
|
static TextLine renderText(SDL_Renderer *renderer, TTF_Font *font, const char *text, SDL_Color color)
|
|
{
|
|
TextLine line;
|
|
SDL_Surface *surface = TTF_RenderText_Blended(font, text, 0, color);
|
|
if (!surface)
|
|
{
|
|
SDL_Log("Failed to render text: %s", SDL_GetError());
|
|
return line;
|
|
}
|
|
line.texture = SDL_CreateTextureFromSurface(renderer, surface);
|
|
line.width = static_cast<float>(surface->w);
|
|
line.height = static_cast<float>(surface->h);
|
|
SDL_DestroySurface(surface);
|
|
return line;
|
|
}
|
|
|
|
void Home::onPush(AppState &state)
|
|
{
|
|
state_ = &state;
|
|
// Home is the end of the First-Run walkthrough. Reaching it (via the
|
|
// walkthrough or Settings) means no walkthrough step is active.
|
|
state.provisioningMode = false;
|
|
syncProclamationsIfEmpty(state);
|
|
TTF_Init();
|
|
createTextures(state);
|
|
refreshStatus();
|
|
state.licenseTracker.refresh();
|
|
|
|
state.keybinds.bind(SDLK_UP, "menu_up", [this]()
|
|
{ state_->currentMenuItem = (state_->currentMenuItem + 1) % 2; });
|
|
state.keybinds.bind(SDLK_DOWN, "menu_down", [this]()
|
|
{ state_->currentMenuItem = (state_->currentMenuItem + 1) % 2; });
|
|
}
|
|
|
|
void Home::onResume(AppState &state)
|
|
{
|
|
state_ = &state;
|
|
syncProclamationsIfEmpty(state);
|
|
createTextures(state);
|
|
refreshStatus();
|
|
state.licenseTracker.refresh();
|
|
|
|
state.keybinds.bind(SDLK_UP, "menu_up", [this]()
|
|
{ state_->currentMenuItem = (state_->currentMenuItem + 1) % 2; });
|
|
state.keybinds.bind(SDLK_DOWN, "menu_down", [this]()
|
|
{ state_->currentMenuItem = (state_->currentMenuItem + 1) % 2; });
|
|
}
|
|
|
|
void Home::onPause(AppState &state)
|
|
{
|
|
state.keybinds.unbindAll();
|
|
destroyTextures();
|
|
}
|
|
|
|
void Home::onPop(AppState &state)
|
|
{
|
|
destroyTextures();
|
|
TTF_Quit();
|
|
}
|
|
|
|
void Home::handleEvent(const SDL_Event &event)
|
|
{
|
|
if (event.type == SDL_EVENT_KEY_DOWN && event.key.key == SDLK_RETURN)
|
|
{
|
|
if (state_->currentMenuItem == 0)
|
|
{
|
|
// Only a confirmed-valid license unlocks the scroller.
|
|
if (state_->licenseTracker.isValid())
|
|
{
|
|
state_->pages.push(*state_, std::make_unique<ScrollerPage>());
|
|
}
|
|
else
|
|
{
|
|
state_->pages.push(*state_, std::make_unique<LicenseBlockedPage>());
|
|
}
|
|
}
|
|
else if (state_->currentMenuItem == 1)
|
|
{
|
|
state_->pages.replace(*state_, std::make_unique<Settings>());
|
|
}
|
|
}
|
|
}
|
|
|
|
void Home::update(float dt)
|
|
{
|
|
internetRefreshTimer_ -= dt;
|
|
if (internetRefreshTimer_ <= 0.0f)
|
|
{
|
|
internetRefreshTimer_ = 5.0f;
|
|
refreshStatus();
|
|
}
|
|
}
|
|
|
|
void Home::render(SDL_Renderer *renderer)
|
|
{
|
|
int winW, winH;
|
|
radiantwave::getLayoutSize(renderer, &winW, &winH);
|
|
float fw = static_cast<float>(winW);
|
|
float fh = static_cast<float>(winH);
|
|
|
|
if (bg_.texture)
|
|
{
|
|
float scaleX = fw / bg_.width;
|
|
float scaleY = fh / bg_.height;
|
|
float scale = (scaleX < scaleY) ? scaleX : scaleY;
|
|
float dw = bg_.width * scale;
|
|
float dh = bg_.height * scale;
|
|
float dx = (fw - dw) / 2.0f;
|
|
float dy = (fh - dh) / 2.0f;
|
|
SDL_FRect dst{dx, dy, dw, dh};
|
|
SDL_RenderTexture(renderer, bg_.texture, nullptr, &dst);
|
|
}
|
|
|
|
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
|
|
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 160);
|
|
SDL_FRect overlay{0, 0, fw, fh};
|
|
SDL_RenderFillRect(renderer, &overlay);
|
|
|
|
if (!title_.texture)
|
|
return;
|
|
|
|
float titleX = (fw - title_.width) / 2.0f;
|
|
float titleY = fh / 3.0f - 60.0f;
|
|
|
|
SDL_FRect titleDst{titleX, titleY, title_.width, title_.height};
|
|
SDL_RenderTexture(renderer, title_.texture, nullptr, &titleDst);
|
|
|
|
float menuY = titleY + title_.height + 48.0f;
|
|
|
|
if (play_.texture)
|
|
{
|
|
float playX = (fw - play_.width) / 2.0f;
|
|
SDL_FRect playDst{playX, menuY, play_.width, play_.height};
|
|
SDL_RenderTexture(renderer, play_.texture, nullptr, &playDst);
|
|
playY_ = menuY;
|
|
menuY += play_.height + 12.0f;
|
|
}
|
|
|
|
if (settings_.texture)
|
|
{
|
|
float settingsX = (fw - settings_.width) / 2.0f;
|
|
SDL_FRect settingsDst{settingsX, menuY, settings_.width, settings_.height};
|
|
SDL_RenderTexture(renderer, settings_.texture, nullptr, &settingsDst);
|
|
settingsY_ = menuY;
|
|
}
|
|
|
|
if (state_ && state_->currentMenuItem == 0 && play_.texture)
|
|
{
|
|
float playX = (fw - play_.width) / 2.0f;
|
|
SDL_SetRenderDrawColor(renderer, 255, 255, 255, 255);
|
|
SDL_FRect line{playX, playY_ + play_.height + 2.0f, play_.width, 6.0f};
|
|
SDL_RenderFillRect(renderer, &line);
|
|
}
|
|
if (state_ && state_->currentMenuItem == 1 && settings_.texture)
|
|
{
|
|
float settingsX = (fw - settings_.width) / 2.0f;
|
|
SDL_SetRenderDrawColor(renderer, 255, 255, 255, 255);
|
|
SDL_FRect line{settingsX, settingsY_ + settings_.height + 2.0f, settings_.width, 6.0f};
|
|
SDL_RenderFillRect(renderer, &line);
|
|
}
|
|
|
|
// Bottom-right status footer (20pt, aligned to a virtual rectangle:
|
|
// names left-aligned at the rect's left edge, values right-aligned at
|
|
// the rect's right edge; the longest name's colon sits 4 spaces from its
|
|
// value, and the rect is sized from that pair)
|
|
if (footerFont_)
|
|
{
|
|
SDL_Color footerColor = {200, 200, 200, 255};
|
|
SDL_Color okGreen = {110, 220, 130, 255};
|
|
SDL_Color badRed = {240, 80, 80, 255};
|
|
|
|
// The version string sits at the very top of the footer; it is baked
|
|
// in at build time via -DAPP_VERSION (defaults to the project version).
|
|
// License validity lives right below it: OK (green) or Invalid (red,
|
|
// bold). Checking/Unknown render gray until a check lands.
|
|
const radiantwave::license::Status licenseStatus =
|
|
state_ ? state_->licenseTracker.status() : radiantwave::license::Status::Unknown;
|
|
std::string licenseValue;
|
|
SDL_Color licenseColor = footerColor;
|
|
bool licenseBold = false;
|
|
switch (licenseStatus)
|
|
{
|
|
case radiantwave::license::Status::Valid:
|
|
licenseValue = "OK";
|
|
licenseColor = okGreen;
|
|
break;
|
|
case radiantwave::license::Status::Invalid:
|
|
licenseValue = "Invalid";
|
|
licenseColor = badRed;
|
|
licenseBold = true;
|
|
break;
|
|
case radiantwave::license::Status::Checking:
|
|
licenseValue = "Checking...";
|
|
break;
|
|
case radiantwave::license::Status::Unknown:
|
|
licenseValue = "Unknown";
|
|
break;
|
|
}
|
|
|
|
time_t now = std::time(nullptr);
|
|
std::vector<std::string> names = {
|
|
"Version:",
|
|
"License:",
|
|
"Key:",
|
|
"Email:",
|
|
"WiFi:",
|
|
"Internet:",
|
|
"Audio:",
|
|
"Date:",
|
|
"Time:",
|
|
};
|
|
std::vector<std::string> values = {
|
|
APP_VERSION,
|
|
licenseValue,
|
|
license_.empty() ? std::string("Not set") : license_,
|
|
email_.empty() ? std::string("Not set") : email_,
|
|
ssid_.empty() ? std::string("Not connected") : ssid_,
|
|
internet_ ? std::string("Connected") : std::string("Offline"),
|
|
audioStatus(),
|
|
radiantwave::datetime::formatDate(now, dateFormat_),
|
|
radiantwave::datetime::formatTime(now),
|
|
};
|
|
std::vector<SDL_Color> valueColors(values.size(), footerColor);
|
|
std::vector<bool> valueBold(values.size(), false);
|
|
// License sits at index 1, below the version row.
|
|
valueColors[1] = licenseColor;
|
|
valueBold[1] = licenseBold;
|
|
|
|
if (footerNames_.size() != names.size())
|
|
footerNames_.resize(names.size());
|
|
if (footerValues_.size() != values.size())
|
|
footerValues_.resize(values.size());
|
|
for (size_t i = 0; i < names.size(); ++i)
|
|
{
|
|
footerNames_[i].ensure(renderer, footerFont_, names[i], footerColor);
|
|
}
|
|
for (size_t i = 0; i < values.size(); ++i)
|
|
{
|
|
if (valueBold[i])
|
|
TTF_SetFontStyle(footerFont_, TTF_STYLE_BOLD);
|
|
footerValues_[i].ensure(renderer, footerFont_, values[i], valueColors[i],
|
|
valueBold[i] ? TTF_STYLE_BOLD : TTF_STYLE_NORMAL);
|
|
if (valueBold[i])
|
|
TTF_SetFontStyle(footerFont_, TTF_STYLE_NORMAL);
|
|
}
|
|
|
|
float maxNameW = 0.0f;
|
|
float maxValueW = 0.0f;
|
|
for (size_t i = 0; i < footerNames_.size(); ++i)
|
|
{
|
|
if (footerNames_[i].width > maxNameW)
|
|
maxNameW = footerNames_[i].width;
|
|
if (footerValues_[i].width > maxValueW)
|
|
maxValueW = footerValues_[i].width;
|
|
}
|
|
|
|
CachedText fourSpaces;
|
|
fourSpaces.ensure(renderer, footerFont_, " ", footerColor);
|
|
float spaceW4 = fourSpaces.width;
|
|
fourSpaces.destroy();
|
|
|
|
const float margin = 20.0f;
|
|
float rectRight = fw - margin;
|
|
float rectLeft = rectRight - (maxNameW + spaceW4 + maxValueW);
|
|
|
|
float lineY = fh - margin;
|
|
const float lineGap = 6.0f;
|
|
for (size_t i = footerNames_.size(); i-- > 0;)
|
|
{
|
|
float h = std::max(footerNames_[i].height, footerValues_[i].height);
|
|
if (footerNames_[i].texture)
|
|
{
|
|
SDL_FRect dst{rectLeft, lineY - h, footerNames_[i].width, footerNames_[i].height};
|
|
SDL_RenderTexture(renderer, footerNames_[i].texture, nullptr, &dst);
|
|
}
|
|
if (footerValues_[i].texture)
|
|
{
|
|
SDL_FRect dst{rectRight - footerValues_[i].width, lineY - h, footerValues_[i].width, footerValues_[i].height};
|
|
SDL_RenderTexture(renderer, footerValues_[i].texture, nullptr, &dst);
|
|
}
|
|
lineY -= h + lineGap;
|
|
}
|
|
}
|
|
}
|
|
|
|
void Home::createTextures(AppState &state)
|
|
{
|
|
TTF_Font *font = TTF_OpenFont("assets/fonts/Roboto-Regular.ttf", 64);
|
|
if (!font)
|
|
{
|
|
SDL_Log("Failed to load font: %s", SDL_GetError());
|
|
return;
|
|
}
|
|
|
|
SDL_Color white = {255, 255, 255, 255};
|
|
TextLine textWidthRef = renderText(state.renderer, font, "Radiant Wave", white);
|
|
float targetWidth = textWidthRef.width * 1.5f;
|
|
|
|
if (textWidthRef.texture)
|
|
SDL_DestroyTexture(textWidthRef.texture);
|
|
|
|
TTF_CloseFont(font);
|
|
|
|
SDL_Surface *logoSurface = IMG_Load("assets/images/RDW-Title-White-medium.png");
|
|
if (!logoSurface)
|
|
{
|
|
SDL_Log("Failed to load logo: %s", SDL_GetError());
|
|
}
|
|
|
|
if (logoSurface)
|
|
{
|
|
title_.texture = SDL_CreateTextureFromSurface(state.renderer, logoSurface);
|
|
float origW = static_cast<float>(logoSurface->w);
|
|
float origH = static_cast<float>(logoSurface->h);
|
|
SDL_DestroySurface(logoSurface);
|
|
|
|
float scale = targetWidth / origW;
|
|
title_.width = targetWidth;
|
|
title_.height = origH * scale;
|
|
}
|
|
|
|
font = TTF_OpenFont("assets/fonts/Roboto-Regular.ttf", 32);
|
|
if (!font)
|
|
{
|
|
SDL_Log("Failed to load font: %s", SDL_GetError());
|
|
return;
|
|
}
|
|
|
|
play_ = renderText(state.renderer, font, "Play", white);
|
|
settings_ = renderText(state.renderer, font, "Settings", white);
|
|
|
|
TTF_CloseFont(font);
|
|
|
|
SDL_Surface *bgSurface = IMG_Load("assets/images/RWT-Circular-Color-ForOnDark-NoText.png");
|
|
if (bgSurface)
|
|
{
|
|
bg_.texture = SDL_CreateTextureFromSurface(state.renderer, bgSurface);
|
|
bg_.width = static_cast<float>(bgSurface->w);
|
|
bg_.height = static_cast<float>(bgSurface->h);
|
|
SDL_DestroySurface(bgSurface);
|
|
SDL_SetTextureAlphaMod(bg_.texture, 204);
|
|
}
|
|
|
|
footerFont_ = TTF_OpenFont("assets/fonts/Roboto-Regular.ttf", 20);
|
|
const std::vector<std::string> footerNames = {
|
|
"Version:",
|
|
"License:",
|
|
"Key:",
|
|
"Email:",
|
|
"WiFi:",
|
|
"Internet:",
|
|
"Audio:",
|
|
"Date:",
|
|
"Time:",
|
|
};
|
|
footerNames_.clear();
|
|
footerValues_.clear();
|
|
footerNames_.resize(footerNames.size());
|
|
footerValues_.resize(footerNames.size());
|
|
SDL_Color footerColor = {200, 200, 200, 255};
|
|
if (footerFont_)
|
|
{
|
|
for (size_t i = 0; i < footerNames.size(); ++i)
|
|
{
|
|
footerNames_[i].ensure(state.renderer, footerFont_, footerNames[i], footerColor);
|
|
}
|
|
}
|
|
}
|
|
|
|
void Home::destroyTextures()
|
|
{
|
|
if (bg_.texture)
|
|
{
|
|
SDL_DestroyTexture(bg_.texture);
|
|
bg_.texture = nullptr;
|
|
}
|
|
if (title_.texture)
|
|
{
|
|
SDL_DestroyTexture(title_.texture);
|
|
title_.texture = nullptr;
|
|
}
|
|
if (play_.texture)
|
|
{
|
|
SDL_DestroyTexture(play_.texture);
|
|
play_.texture = nullptr;
|
|
}
|
|
if (settings_.texture)
|
|
{
|
|
SDL_DestroyTexture(settings_.texture);
|
|
settings_.texture = nullptr;
|
|
}
|
|
for (auto &t : footerNames_)
|
|
t.destroy();
|
|
for (auto &t : footerValues_)
|
|
t.destroy();
|
|
footerNames_.clear();
|
|
footerValues_.clear();
|
|
if (footerFont_)
|
|
{
|
|
TTF_CloseFont(footerFont_);
|
|
footerFont_ = nullptr;
|
|
}
|
|
}
|
|
|
|
void Home::refreshStatus()
|
|
{
|
|
if (!state_)
|
|
return;
|
|
email_ = state_->db.getKV(radiantwave::db::KV_EMAIL_ADDRESS);
|
|
license_ = state_->db.getKV(radiantwave::db::KV_LICENSE_KEY);
|
|
dateFormat_ = state_->db.getKV("date_format", radiantwave::datetime::DEFAULT_DATE_FORMAT);
|
|
ssid_ = state_->network.currentSSID();
|
|
internet_ = state_->network.hasInternet();
|
|
}
|
|
|
|
void Home::syncProclamationsIfEmpty(AppState &state)
|
|
{
|
|
// Pull the proclamation list on first run so Play has the default
|
|
// proclamation immediately, even if the user never opens the
|
|
// Proclamation options. Skipped once the database has content so a
|
|
// blocking sync never runs on every Home push. The API call is also
|
|
// skipped when the database is closed (fallback path in provisioning).
|
|
if (state.db.isOpen() && state.db.getActiveProclamations().empty())
|
|
{
|
|
// TODO(portal #4/#5): once the portal exposes color palettes, sync them
|
|
// alongside proclamations here (sync::syncColorPalettesFromApi) so Play's
|
|
// scroller has portal colors from the very first run.
|
|
radiantwave::sync::syncProclamationsFromApi(state.db, DEFAULT_PROPLEAMATION_ID);
|
|
}
|
|
}
|
|
|
|
std::string Home::audioStatus() const
|
|
{
|
|
if (!state_)
|
|
return "Idle";
|
|
std::string status = state_->audioDownloader.statusString();
|
|
return status.empty() ? "Idle" : status;
|
|
}
|