12 Commits
Author SHA1 Message Date
NeilClack 43cd93bbd7 Switch from window-size to render-output-size 2026-08-25 11:30:20 -05:00
NeilClack 92ba9f39fb Switch from window-size to render-output-size 2026-08-25 11:28:02 -05:00
NeilClack 400c6a782b Scroller: use full window height on rotated/tall displays
build-dev / nix-build (push) Successful in 41s
Override the LETTERBOX logical presentation while the scroller is
active so it renders into the full physical window.  On a 1920x6480
rotated display the scroller now scrolls through all 6480 pixels of
vertical space instead of being confined to the 1920x1080 letterbox
region.  Text scale is unchanged (same font size, same pixel
rasterization); more lines are simply visible at once.

- onPush: save current logical presentation, switch to DISABLED with
  real window dimensions
- onPop: restore the saved LETTERBOX presentation
- resize handler in main.cpp: skip re-apply while scroller is active
  (it owns its own presentation and restores on pop)
2026-08-25 10:55:53 -05:00
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
10 changed files with 882 additions and 773 deletions
+68
View File
@@ -0,0 +1,68 @@
name: build-dev
on:
# Every push to dev: build, run the offline test suite, publish to the
# dev Attic cache.
push:
branches: [dev]
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.
- 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"
# Dev builds fingerprint as the short commit hash.
# Persisted via GITHUB_ENV so the impure nix eval below reads it via
# builtins.getEnv.
- name: Compute version
run: |
echo "APP_VERSION=$(git rev-parse --short HEAD)" >> "$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
'
# 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
@@ -1,9 +1,8 @@
name: build
name: build-main
on:
# Manual only for now. Select the branch to build when dispatching:
# main -> APP_VERSION is the current git tag
# dev -> APP_VERSION is the current commit hash
# Manual only. Dispatch against main: build, run the offline test suite,
# publish to the release Attic cache.
workflow_dispatch:
jobs:
@@ -12,8 +11,8 @@ jobs:
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` on main
# resolves the real nearest tag instead of falling back to a hash.
# 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
@@ -32,26 +31,27 @@ jobs:
git describe --tags --always 2>&1 || true
test -f flake.nix && echo "flake.nix present" || echo "NO flake.nix"
# Compute the version string baked into the binary's Home footer:
# main reports the git tag, dev reports the commit hash fingerprint.
# The key stays in the build step's environment (never echoed).
# 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
id: version
run: |
if [ "${{ gitea.ref_name }}" = "main" ]; then
APP_VERSION="$(git describe --tags --always)"
else
APP_VERSION="$(git rev-parse --short HEAD)"
fi
echo "Branch: ${{ gitea.ref_name }} -> APP_VERSION=$APP_VERSION"
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). The offline test suite
# runs in the package's checkPhase.
# (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
run: |
nix build -L ".#checks.$(nix eval --impure --raw --expr 'builtins.currentSystem').default" \
--impure --out-link result-checks
- name: Live portal tests
env:
@@ -66,10 +66,11 @@ jobs:
./build/test_license_service
'
- name: Push to Attic cache
# 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: |
nix profile install github:zhaofengli/attic#attic-client \
--extra-experimental-features "nix-command flakes"
attic login radiantwave https://attic.radiantwavetech.com "${{ secrets.ATTIC_TOKEN }}"
attic push radiantwave ./result-kiosk ./result-android ./result-commercial
attic login radiantwave https://attic.radiantwavetech.com "$ATTIC_TOKEN"
attic push radiantwave:radiantwave ./result-pkg
+3 -3
View File
@@ -37,7 +37,7 @@ struct AudioManifestEntry {
// and keeps the database's audio_files table in sync with what is actually
// on disk.
//
// Fetches new_checksums.md5, compares each entry against the local file's MD5,
// Fetches checksums.md5, compares each entry against the local file's MD5,
// and re-downloads anything that is missing or whose checksum differs.
// Files present on disk but absent from the manifest are deleted, and the
// audio_files table is reconciled on every pass (and after each individual
@@ -68,7 +68,7 @@ public:
static constexpr const char *AUDIO_BASE_URL =
"https://files.radiantwavetech.com/audio/";
static constexpr const char *MANIFEST_FILENAME = "new_checksums.md5";
static constexpr const char *MANIFEST_FILENAME = "checksums.md5";
private:
void run();
@@ -88,7 +88,7 @@ private:
db::DatabaseManager *db_ = nullptr;
};
// Parses new_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
// skipped. Returns false if no valid entries were found.
bool parseManifest(const std::string &text,
+2 -2
View File
@@ -281,10 +281,10 @@ void Home::render(SDL_Renderer *renderer)
fourSpaces.destroy();
const float margin = 20.0f;
float rectRight = fw - margin;
float rectRight = fw - margin - 50.0f;
float rectLeft = rectRight - (maxNameW + spaceW4 + maxValueW);
float lineY = fh - margin;
float lineY = fh - margin - 50.0f;
const float lineGap = 6.0f;
for (size_t i = footerNames_.size(); i-- > 0;)
{
+39 -5
View File
@@ -4,13 +4,47 @@
namespace radiantwave {
inline void getLayoutSize(SDL_Renderer* renderer, int* w, int* h)
// 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;
if (!SDL_GetRenderLogicalPresentation(renderer, w, h, &mode) ||
mode == SDL_LOGICAL_PRESENTATION_DISABLED || *w <= 0 || *h <= 0)
{
SDL_GetWindowSize(SDL_GetRenderWindow(renderer), w, h);
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;
}
}
+66 -10
View File
@@ -4,8 +4,10 @@
#include "app_state.h"
#include "home.h"
#include "logical_size.h"
#include "logger/logger.h"
#include "provisioning.h"
#include "scroller_page.h"
namespace {
// How long audio fades out before the app tears down on quit. Only applied
@@ -32,11 +34,26 @@ SDL_AppResult SDL_AppInit(void **appstate, int argc, char *argv[]) {
auto *state = new AppState{};
// SDL_WINDOW_FULLSCREEN fills the desktop's native resolution, so the
// compositor never has to scale the moving scroller (scaling moving text
// causes flicker).
state->window =
SDL_CreateWindow("RadiantWave", 1920, 1080, SDL_WINDOW_FULLSCREEN);
// Size the fullscreen window from the display's actual mode so the very
// first buffer commit already matches what the compositor scanouts 1:1.
// Requesting a hardcoded size left the backbuffer at that size on
// displays running a different mode, and frames reached the panel
// unscaled (quarter-size on 4K, cropped on smaller panels).
const SDL_DisplayMode *displayMode =
SDL_GetCurrentDisplayMode(SDL_GetPrimaryDisplay());
int windowWidth =
(displayMode && displayMode->w > 0) ? displayMode->w
: radiantwave::kDesignWidth;
int windowHeight =
(displayMode && displayMode->h > 0) ? displayMode->h
: radiantwave::kDesignHeight;
// Borderless desktop fullscreen: the compositor never has to scale the
// moving scroller (scaling moving text causes flicker). Deliberately no
// SDL_WINDOW_HIGH_PIXEL_DENSITY — kiosk panels are not HiDPI, so keeping
// the pixel buffer equal to the display mode keeps scanout 1:1.
state->window = SDL_CreateWindow("RadiantWave", windowWidth, windowHeight,
SDL_WINDOW_FULLSCREEN);
if (!state->window) {
SDL_Log("Failed to create window: %s", SDL_GetError());
delete state;
@@ -59,16 +76,38 @@ SDL_AppResult SDL_AppInit(void **appstate, int argc, char *argv[]) {
std::string(SDL_GetError()) + ")");
}
// Lock the renderer to a 1920x1080 logical coordinate space. All page
// layout code already targets this resolution, so everything renders at
// the correct size on any display — the GPU scales to native pixels.
if (!SDL_SetRenderLogicalPresentation(state->renderer, 1920, 1080,
SDL_LOGICAL_PRESENTATION_STRETCH)) {
// 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");
}
{
// Record how the requested mode, window, pixel buffer, and render
// output actually compare — this is the evidence trail for
// display-scaling problems on field units.
int windowW = 0, windowH = 0;
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");
}
SDL_HideCursor();
// Initialize configuration, database, and network manager
@@ -157,6 +196,23 @@ SDL_AppResult SDL_AppEvent(void *appstate, SDL_Event *event) {
state.quitRequested = true;
}
if (event->type == SDL_EVENT_WINDOW_RESIZED) {
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. Skip while the
// scroller is active — it owns its own presentation (NONE, full
// window) and restores LETTERBOX in onPop.
if (!dynamic_cast<ScrollerPage *>(state.pages.current())) {
SDL_SetRenderLogicalPresentation(state.renderer,
radiantwave::kDesignWidth,
radiantwave::kDesignHeight,
SDL_LOGICAL_PRESENTATION_LETTERBOX);
}
}
if (!state.keybinds.handleEvent(*event)) {
state.pages.handleEvent(*event);
}
+30 -17
View File
@@ -198,7 +198,7 @@ void ProclamationOptionsPage::rebuildItems()
FlatItem::Type::SubCategory,
std::to_string(sub.id),
sub.name,
1, false, false, false,
1, true, false, false,
});
for (const auto& p : proclamations_) {
@@ -268,7 +268,7 @@ void ProclamationOptionsPage::rebuildVisible()
parentVisible = true; // the always-on top-level proclamation
} else if (currentRoot >= 0 && items_[currentRoot].expanded) {
parentVisible = (item.depth == 1) ||
(item.depth == 2 && currentSub >= 0 && items_[currentSub].expanded);
(item.depth == 2 && currentSub >= 0);
}
if (parentVisible) visible_.push_back(i);
}
@@ -283,7 +283,7 @@ void ProclamationOptionsPage::toggleItem()
if (visible_.empty()) return;
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;
rebuildVisible();
return;
@@ -388,7 +388,7 @@ void ProclamationOptionsPage::collapseCurrent()
{
if (visible_.empty()) return;
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) {
item.expanded = false;
rebuildVisible();
@@ -400,7 +400,7 @@ void ProclamationOptionsPage::expandCurrent()
{
if (visible_.empty()) return;
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) {
item.expanded = true;
rebuildVisible();
@@ -526,8 +526,7 @@ void ProclamationOptionsPage::handleEvent(const SDL_Event& event)
if (searchActive_) return;
if (!visible_.empty()) {
const FlatItem& item = items_[visible_[highlightIndex_]];
if ((item.type == FlatItem::Type::RootCategory ||
item.type == FlatItem::Type::SubCategory) && item.expanded) {
if (item.type == FlatItem::Type::RootCategory && item.expanded) {
collapseCurrent();
} else {
moveToParent();
@@ -655,14 +654,18 @@ void ProclamationOptionsPage::render(SDL_Renderer* renderer)
SDL_RenderTexture(renderer, emptyHintText_.texture, nullptr, &dst);
}
} 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;
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));
for (int i = scrollOffset_; i < scrollOffset_ + visibleCount_ && i < listSize; ++i) {
const std::string& label = proclamations_[searchResults_[i]].title;
int w = 0, h = 0;
TTF_GetStringSize(fontItem_, label.c_str(), 0, &w, &h);
maxTotalWidth = std::max(maxTotalWidth, static_cast<float>(w));
TTF_GetStringSize(fontItem_, proclamations_[searchResults_[i]].title.c_str(), 0, &w, &h);
textWidths[i] = static_cast<float>(w);
}
float sharedX = (fw - maxTotalWidth) / 2.0f;
@@ -695,19 +698,29 @@ void ProclamationOptionsPage::render(SDL_Renderer* renderer)
}
} else {
// --- 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;
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]];
for (const auto& item : items_) {
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;
}
int w = 0, h = 0;
TTF_GetStringSize(fontItem_, label.c_str(), 0, &w, &h);
float total = item.depth * indent + static_cast<float>(w);
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);
}
@@ -721,7 +734,7 @@ void ProclamationOptionsPage::render(SDL_Renderer* renderer)
float y = listTop + rowIndex * rowPitch;
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;
}
+206 -273
View File
@@ -2,45 +2,68 @@
#include "app_state.h"
#include "audio/audio_downloader.h"
#include "logical_size.h"
#include "proclamation_options_page.h"
#include "scramble/fibonacci_scrambler.h"
#include "utf8_utils.h"
#include "logical_size.h"
#include <SDL3/SDL.h>
#include <SDL3_ttf/SDL_ttf.h>
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <cstdint>
#include <filesystem>
#include <vector>
using radiantwave::FibonacciLinePattern;
using radiantwave::scroller::ProclamationStream;
namespace
{
namespace {
constexpr float kFadeSeconds = 5.0f;
constexpr float kVelocityMin = -3500.0f;
constexpr float kVelocityMax = 3500.0f;
constexpr float kFadeSeconds = 5.0f;
constexpr float kVelocityMin = -3500.0f;
constexpr float kVelocityMax = 3500.0f;
} // anonymous namespace
void ScrollerPage::onPush(AppState &state)
{
void ScrollerPage::onPush(AppState &state) {
state_ = &state;
int winW = 0;
int winH = 0;
radiantwave::getLayoutSize(state.renderer, &winW, &winH);
screenW_ = static_cast<float>(winW);
screenH_ = static_cast<float>(winH);
// Lines are capped at the full screen width; the widest Fibonacci lines are
// sized to fill it with whole words (peak word count derived from the
// Save the current logical presentation (LETTERBOX at design resolution)
// and switch to NONE so we render into the full physical window. This
// lets the scroller scroll the entire vertical extent of tall/rotated
// displays while keeping text at the same pixel scale. onPop restores
// the original presentation for other pages.
SDL_GetRenderLogicalPresentation(state.renderer, &savedLogicalW_,
&savedLogicalH_, &savedMode_);
// Use the renderer's actual output size in pixels, not the window size in
// points. SDL_GetWindowSize() returns screen coordinates, which can be
// smaller than the real drawable if the display has a content scale
// applied (common on some monitors/compositors) — that mismatch caused
// this page to lay out and center within a sub-region of the screen
// instead of the full physical canvas. SDL_GetRenderOutputSize() gives us
// the true pixel dimensions of whatever we're actually rendering into.
int windowW = 0;
int windowH = 0;
SDL_GetRenderOutputSize(state.renderer, &windowW, &windowH);
if (windowW <= 0) {
windowW = radiantwave::kDesignWidth;
}
if (windowH <= 0) {
windowH = radiantwave::kDesignHeight;
}
SDL_SetRenderLogicalPresentation(state.renderer, windowW, windowH,
SDL_LOGICAL_PRESENTATION_DISABLED);
screenW_ = static_cast<float>(windowW);
screenH_ = static_cast<float>(windowH);
// Lines are capped at the full screen width; the widest Fibonacci lines
// are sized to fill it with whole words (peak word count derived from the
// screen), independent of resolution.
maxLineWidth_ = screenW_;
@@ -58,63 +81,59 @@ void ScrollerPage::onPush(AppState &state)
// plays. A frequency file that is not on disk yet falls back to the manual
// track rather than playing nothing.
std::string track = state.config.get().audio.current_track;
if (state.config.get().audio.dynamic_audio && !streams_.empty())
{
if (state.config.get().audio.dynamic_audio && !streams_.empty()) {
const ProclamationStream &stream = streams_[streamIndex_];
if (stream.solfeggio_frequency_id != 0 && !stream.audioFilename.empty() &&
std::filesystem::exists(audioFilePath(stream.audioFilename)))
{
if (stream.solfeggio_frequency_id != 0 &&
!stream.audioFilename.empty() &&
std::filesystem::exists(audioFilePath(stream.audioFilename))) {
track = stream.audioFilename;
}
}
if (std::filesystem::exists(audioFilePath(track)))
{
if (std::filesystem::exists(audioFilePath(track))) {
state.audio.playTrackFadeIn(track, kFadeSeconds);
}
state.keybinds.bind(SDLK_ESCAPE, "scroller_exit", [this]()
{
state.keybinds.bind(SDLK_ESCAPE, "scroller_exit", [this]() {
if (state_) {
state_->pages.pop(*state_);
} });
}
});
}
void ScrollerPage::onResume(AppState &state)
{
void ScrollerPage::onResume(AppState &state) {
state_ = &state;
createFont(state);
createPatterns();
rebuildWordTextures();
state.keybinds.bind(SDLK_ESCAPE, "scroller_exit", [this]()
{
state.keybinds.bind(SDLK_ESCAPE, "scroller_exit", [this]() {
if (state_) {
state_->pages.pop(*state_);
} });
}
});
}
void ScrollerPage::onPause(AppState &state)
{
void ScrollerPage::onPause(AppState &state) {
state.keybinds.unbindAll();
destroyResources();
}
void ScrollerPage::onPop(AppState &state)
{
void ScrollerPage::onPop(AppState &state) {
// Restore the original logical presentation so other pages (Home,
// Settings, etc.) render in the design-resolution LETTERBOX space.
SDL_SetRenderLogicalPresentation(state.renderer, savedLogicalW_,
savedLogicalH_, savedMode_);
state.audio.stopTrackFadeOut(kFadeSeconds);
state.keybinds.unbindAll();
destroyResources();
TTF_Quit();
}
void ScrollerPage::handleEvent(const SDL_Event &)
{
}
void ScrollerPage::handleEvent(const SDL_Event &) {}
void ScrollerPage::update(float dt)
{
if (state_)
{
void ScrollerPage::update(float dt) {
if (state_) {
state_->audio.update(dt);
}
@@ -122,69 +141,55 @@ void ScrollerPage::update(float dt)
scrollOffset_ += velocity_ * dt;
float move = std::floor(scrollOffset_);
if (move != 0.0f)
{
if (move != 0.0f) {
scrollOffset_ -= move;
for (auto &line : lines_)
{
for (auto &w : line.words)
{
for (auto &line : lines_) {
for (auto &w : line.words) {
w.y += move;
}
}
}
if (lines_.empty())
{
if (lines_.empty()) {
return;
}
if (velocity_ > 0.0f)
{
if (velocity_ > 0.0f) {
// Lines move downward. Recycle every line that scrolled fully off the
// bottom this frame (movement is continuous, so one frame can cover
// several line heights) and add a replacement just above the top.
while (!lines_.empty() && lines_.back().words[0].y > screenH_)
{
while (!lines_.empty() && lines_.back().words[0].y > screenH_) {
float baseY = lines_.front().words[0].y - lineHeight_;
lines_.pop_back();
if (!addLineAt(baseY))
{
if (!addLineAt(baseY)) {
break;
}
}
}
else if (velocity_ < 0.0f)
{
} else if (velocity_ < 0.0f) {
// Lines move upward. Recycle every line that scrolled fully off the top
// this frame and add a replacement just below the bottom.
while (!lines_.empty() &&
lines_.front().words[0].y + lines_.front().words[0].height < 0.0f)
{
lines_.front().words[0].y + lines_.front().words[0].height <
0.0f) {
float baseY = lines_.back().words[0].y + lineHeight_;
lines_.pop_front();
if (!addLineAt(baseY))
{
if (!addLineAt(baseY)) {
break;
}
}
}
}
void ScrollerPage::render(SDL_Renderer *renderer)
{
void ScrollerPage::render(SDL_Renderer *renderer) {
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 0);
SDL_RenderClear(renderer);
for (const auto &line : lines_)
{
if (line.words.empty())
{
for (const auto &line : lines_) {
if (line.words.empty()) {
continue;
}
float startX = std::floor((screenW_ - line.width) / 2.0f);
for (const auto &w : line.words)
{
for (const auto &w : line.words) {
SDL_SetTextureColorMod(w.texture, line.r, line.g, line.b);
SDL_FRect dst{startX + w.x, w.y, w.width, w.height};
SDL_RenderTexture(renderer, w.texture, nullptr, &dst);
@@ -192,53 +197,46 @@ void ScrollerPage::render(SDL_Renderer *renderer)
}
}
void ScrollerPage::buildStreams(AppState &state)
{
void ScrollerPage::buildStreams(AppState &state) {
streams_.clear();
fallbackPalette_.clear();
lines_.clear();
streamIndex_ = 0;
auto proclamations = state.db.getActiveProclamations();
if (proclamations.empty())
{
if (proclamations.empty()) {
return;
}
for (const auto &c : state.db.getPaletteForFrequencyId(5))
{ // 528 Hz palette
for (const auto &c :
state.db.getPaletteForFrequencyId(5)) { // 528 Hz palette
fallbackPalette_.push_back({c.r, c.g, c.b, c.a});
}
std::unordered_map<int, std::vector<radiantwave::scroller::StreamColor>> palettesByFreqId;
for (const auto &rec : proclamations)
{
std::unordered_map<int, std::vector<radiantwave::scroller::StreamColor>>
palettesByFreqId;
for (const auto &rec : proclamations) {
int freqId = rec.solfeggio_frequency_id;
if (palettesByFreqId.count(freqId) != 0)
{
if (palettesByFreqId.count(freqId) != 0) {
continue;
}
std::vector<radiantwave::scroller::StreamColor> colors;
for (const auto &c : state.db.getPaletteForFrequencyId(freqId))
{
for (const auto &c : state.db.getPaletteForFrequencyId(freqId)) {
colors.push_back({c.r, c.g, c.b, c.a});
}
palettesByFreqId[freqId] = std::move(colors);
}
std::unordered_map<int, radiantwave::db::AudioFileRecord> audioByFreqId;
for (const auto &af : state.db.getAudioFiles())
{
for (const auto &af : state.db.getAudioFiles()) {
int freqId = af.solfeggio_frequency_id;
// Self-heal for databases seeded from a legacy manifest that left the
// frequency id at 0 for every row: recover the id from the filename.
if (freqId == 0)
{
if (freqId == 0) {
freqId = radiantwave::audio::frequencyIdForHz(
radiantwave::audio::hzFromFilename(af.filename));
}
if (freqId != 0)
{
if (freqId != 0) {
audioByFreqId[freqId] = af;
}
}
@@ -248,10 +246,8 @@ void ScrollerPage::buildStreams(AppState &state)
DEFAULT_PROPLEAMATION_ID);
}
void ScrollerPage::createFont(AppState &state)
{
if (font_)
{
void ScrollerPage::createFont(AppState &state) {
if (font_) {
TTF_CloseFont(font_);
font_ = nullptr;
}
@@ -262,46 +258,41 @@ void ScrollerPage::createFont(AppState &state)
clearCharTextures();
fontPxSize_ = state.config.get().proclamation.font_size;
if (fontPxSize_ <= 0)
{
if (fontPxSize_ <= 0) {
fontPxSize_ = 32;
}
font_ = TTF_OpenFont("assets/fonts/Roboto-Regular.ttf", fontPxSize_);
if (!font_)
{
if (!font_) {
return;
}
int w = 0;
int h = 0;
TTF_GetStringSize(font_, "Mg", 0, &w, &h);
lineHeight_ = h > 0 ? static_cast<float>(h) : static_cast<float>(fontPxSize_);
lineHeight_ =
h > 0 ? static_cast<float>(h) : static_cast<float>(fontPxSize_);
spaceWidth_ = measureWidth(" ");
if (spaceWidth_ <= 0.0f)
{
if (spaceWidth_ <= 0.0f) {
spaceWidth_ = static_cast<float>(fontPxSize_) * 0.5f;
}
}
void ScrollerPage::createPatterns()
{
void ScrollerPage::createPatterns() {
patterns_.clear();
patterns_.reserve(streams_.size());
for (size_t i = 0; i < streams_.size(); ++i)
{
for (size_t i = 0; i < streams_.size(); ++i) {
int peak = computePeakWordCount(streams_[i].words);
patterns_.emplace_back(peak, maxLineWidth_, spaceWidth_,
[this](const std::string &word)
{ return measureWidth(word); });
patterns_.emplace_back(
peak, maxLineWidth_, spaceWidth_,
[this](const std::string &word) { return measureWidth(word); });
}
}
int ScrollerPage::computePeakWordCount(const std::vector<std::string> &words) const
{
if (words.empty() || screenW_ <= 0.0f)
{
int ScrollerPage::computePeakWordCount(
const std::vector<std::string> &words) const {
if (words.empty() || screenW_ <= 0.0f) {
return 1;
}
@@ -309,14 +300,12 @@ int ScrollerPage::computePeakWordCount(const std::vector<std::string> &words) co
// spacing. The peak is the number of whole words that fit across the
// screen, so the widest Fibonacci lines fill the width at natural spacing.
double total = 0.0;
for (const auto &w : words)
{
for (const auto &w : words) {
total += measureWidth(w);
}
double n = static_cast<double>(words.size());
double perWord = (total + (n - 1.0) * spaceWidth_) / n;
if (perWord <= 0.0)
{
if (perWord <= 0.0) {
perWord = 1.0;
}
@@ -324,45 +313,36 @@ int ScrollerPage::computePeakWordCount(const std::vector<std::string> &words) co
return fit < 1 ? 1 : fit;
}
void ScrollerPage::destroyResources()
{
void ScrollerPage::destroyResources() {
clearCharTextures();
for (auto &entry : wordTextures_)
{
if (entry.second.texture)
{
for (auto &entry : wordTextures_) {
if (entry.second.texture) {
SDL_DestroyTexture(entry.second.texture);
}
}
wordTextures_.clear();
if (font_)
{
if (font_) {
TTF_CloseFont(font_);
font_ = nullptr;
}
}
void ScrollerPage::rebuildWordTextures()
{
void ScrollerPage::rebuildWordTextures() {
wordTextures_.clear();
for (auto &line : lines_)
{
for (auto &line : lines_) {
float width = 0.0f;
for (auto &item : line.words)
{
for (auto &item : line.words) {
ensureWordTexture(item.text);
const CachedWord &cached = wordTextures_[item.text];
if (!cached.texture)
{
if (!cached.texture) {
continue;
}
item.texture = cached.texture;
item.width = cached.width;
item.height = cached.height;
if (width > 0.0f)
{
if (width > 0.0f) {
width += spaceWidth_;
}
item.x = width;
@@ -372,31 +352,25 @@ void ScrollerPage::rebuildWordTextures()
}
}
void ScrollerPage::prefillLines()
{
if (streams_.empty() || lineHeight_ <= 0.0f)
{
void ScrollerPage::prefillLines() {
if (streams_.empty() || lineHeight_ <= 0.0f) {
return;
}
int n = static_cast<int>(std::ceil(screenH_ / lineHeight_)) + 2;
for (int i = 0; i < n; ++i)
{
for (int i = 0; i < n; ++i) {
float baseY = -lineHeight_ + static_cast<float>(i) * lineHeight_;
addLineAt(baseY);
}
}
bool ScrollerPage::addLineAt(float baseY)
{
if (streams_.empty() || !font_)
{
bool ScrollerPage::addLineAt(float baseY) {
if (streams_.empty() || !font_) {
return false;
}
ProclamationStream &stream = streams_[streamIndex_];
if (stream.words.empty())
{
if (stream.words.empty()) {
advanceStream();
return false;
}
@@ -404,7 +378,8 @@ bool ScrollerPage::addLineAt(float baseY)
FibonacciLinePattern &pattern = patterns_[streamIndex_];
// Capture palette state before any stream advance.
std::vector<radiantwave::scroller::StreamColor> paletteCopy = stream.palette;
std::vector<radiantwave::scroller::StreamColor> paletteCopy =
stream.palette;
int lineColorIndex = stream.colorIndex;
stream.colorIndex++;
@@ -414,17 +389,15 @@ bool ScrollerPage::addLineAt(float baseY)
stream.wordsPlaced += placed;
// First word of a fresh pass is the "start" of the proclamation; print to
// stdout once so the console shows the current title while it plays.
if (stream.wordsPlaced == placed)
{
if (stream.wordsPlaced == placed) {
std::printf("Playing %s\n", stream.name.c_str());
}
bool advanced = false;
while (!advanced && !stream.words.empty() && stream.wordsPlaced >= static_cast<int>(stream.words.size()))
{
while (!advanced && !stream.words.empty() &&
stream.wordsPlaced >= static_cast<int>(stream.words.size())) {
stream.wordsPlaced -= static_cast<int>(stream.words.size());
stream.completed++;
if (stream.completed >= stream.repeat)
{
if (stream.completed >= stream.repeat) {
stream.completed = 0;
advanceStream();
advanced = true;
@@ -433,12 +406,10 @@ bool ScrollerPage::addLineAt(float baseY)
ScrollerLine line;
float width = 0.0f;
for (const auto &text : wordStrs)
{
for (const auto &text : wordStrs) {
ensureWordTexture(text);
const CachedWord &cached = wordTextures_[text];
if (!cached.texture)
{
if (!cached.texture) {
continue;
}
WordItem item;
@@ -447,8 +418,7 @@ bool ScrollerPage::addLineAt(float baseY)
item.width = cached.width;
item.height = cached.height;
item.y = baseY;
if (width > 0.0f)
{
if (width > 0.0f) {
width += spaceWidth_;
}
item.x = width;
@@ -456,77 +426,63 @@ bool ScrollerPage::addLineAt(float baseY)
line.words.push_back(item);
}
if (line.words.empty())
{
if (line.words.empty()) {
return false;
}
line.width = width;
if (!paletteCopy.empty())
{
const auto &c = paletteCopy[lineColorIndex % static_cast<int>(paletteCopy.size())];
if (!paletteCopy.empty()) {
const auto &c =
paletteCopy[lineColorIndex % static_cast<int>(paletteCopy.size())];
line.r = c.r;
line.g = c.g;
line.b = c.b;
}
if (!lines_.empty() && baseY < lines_.front().words[0].y)
{
if (!lines_.empty() && baseY < lines_.front().words[0].y) {
lines_.push_front(std::move(line));
}
else
{
} else {
lines_.push_back(std::move(line));
}
return true;
}
void ScrollerPage::advanceStream()
{
if (streams_.empty())
{
void ScrollerPage::advanceStream() {
if (streams_.empty()) {
return;
}
streamIndex_ = (streamIndex_ + 1) % static_cast<int>(streams_.size());
// Diagnostic: report the palette the incoming stream will render so the
// console shows whether colors should change when this stream starts.
const ProclamationStream &stream = streams_[streamIndex_];
if (!stream.palette.empty())
{
if (!stream.palette.empty()) {
const auto &c = stream.palette[0];
std::printf("[Scroller] -> %s freqId=%d palette[0]=#%02X%02X%02X\n",
stream.name.c_str(), stream.solfeggio_frequency_id,
c.r, c.g, c.b);
}
else
{
stream.name.c_str(), stream.solfeggio_frequency_id, c.r,
c.g, c.b);
} else {
std::printf("[Scroller] -> %s freqId=%d palette=EMPTY\n",
stream.name.c_str(), stream.solfeggio_frequency_id);
}
switchAudioForCurrentStream();
}
void ScrollerPage::switchAudioForCurrentStream()
{
if (prefilling_ || streams_.empty() || !state_)
{
void ScrollerPage::switchAudioForCurrentStream() {
if (prefilling_ || streams_.empty() || !state_) {
return;
}
// Dynamic audio only: with a fixed manual track selected the audio must
// never switch as proclamations change.
if (!state_->config.get().audio.dynamic_audio)
{
if (!state_->config.get().audio.dynamic_audio) {
return;
}
const ProclamationStream &stream = streams_[streamIndex_];
if (stream.solfeggio_frequency_id == 0 || stream.audioFilename.empty())
{
if (stream.solfeggio_frequency_id == 0 || stream.audioFilename.empty()) {
return; // default stream: never touch audio
}
if (!std::filesystem::exists(audioFilePath(stream.audioFilename)))
{
if (!std::filesystem::exists(audioFilePath(stream.audioFilename))) {
return; // keep current track; retried on the next activation
}
// Crossfades over kFadeSeconds; a no-op when the requested track is the
@@ -534,14 +490,11 @@ void ScrollerPage::switchAudioForCurrentStream()
state_->audio.playTrackCrossfade(stream.audioFilename, kFadeSeconds);
}
void ScrollerPage::updateAutoVelocity(float dt)
{
switch (velocityState_)
{
void ScrollerPage::updateAutoVelocity(float dt) {
switch (velocityState_) {
case 0: // ramping to the negative extreme
velocity_ -= velocityStep_ * dt;
if (velocity_ <= kVelocityMin)
{
if (velocity_ <= kVelocityMin) {
velocity_ = kVelocityMin;
velocityTimer_ = velocityDelay_;
velocityState_ = 1;
@@ -549,15 +502,13 @@ void ScrollerPage::updateAutoVelocity(float dt)
break;
case 1: // holding at the negative extreme
velocityTimer_ -= dt;
if (velocityTimer_ <= 0.0f)
{
if (velocityTimer_ <= 0.0f) {
velocityState_ = 2;
}
break;
case 2: // ramping to the positive extreme
velocity_ += velocityStep_ * dt;
if (velocity_ >= kVelocityMax)
{
if (velocity_ >= kVelocityMax) {
velocity_ = kVelocityMax;
velocityTimer_ = velocityDelay_;
velocityState_ = 3;
@@ -565,8 +516,7 @@ void ScrollerPage::updateAutoVelocity(float dt)
break;
case 3: // holding at the positive extreme
velocityTimer_ -= dt;
if (velocityTimer_ <= 0.0f)
{
if (velocityTimer_ <= 0.0f) {
velocityState_ = 0;
}
break;
@@ -576,10 +526,8 @@ void ScrollerPage::updateAutoVelocity(float dt)
}
}
void ScrollerPage::ensureWordTexture(const std::string &word)
{
if (wordTextures_.count(word) != 0)
{
void ScrollerPage::ensureWordTexture(const std::string &word) {
if (wordTextures_.count(word) != 0) {
return;
}
@@ -602,69 +550,64 @@ void ScrollerPage::ensureWordTexture(const std::string &word)
// breaking, centering, and the Fibonacci line pattern are unchanged; only
// the glyph pixels move.
CachedWord cached;
if (font_ && state_)
{
if (font_ && state_) {
std::vector<uint32_t> codePoints;
if (radiantwave::splitUtf8(word, codePoints) && !codePoints.empty())
{
if (radiantwave::splitUtf8(word, codePoints) && !codePoints.empty()) {
std::vector<CharEntry *> glyphs;
float totalWidth = 0.0f;
float glyphHeight = 0.0f;
for (uint32_t cp : codePoints)
{
for (uint32_t cp : codePoints) {
const std::string glyphText = radiantwave::encodeUtf8(cp);
if (glyphText.empty())
{
if (glyphText.empty()) {
continue;
}
ensureCharTexture(glyphText);
CharEntry &entry = charTextures_[glyphText];
if (!entry.surface)
{
if (!entry.surface) {
continue;
}
glyphs.push_back(&entry);
totalWidth += entry.advance;
if (entry.glyphH > glyphHeight)
{
if (entry.glyphH > glyphHeight) {
glyphHeight = entry.glyphH;
}
}
if (!glyphs.empty())
{
const int wordW = std::max(1, static_cast<int>(std::lround(totalWidth)));
const int wordH = std::max(1, static_cast<int>(std::lround(glyphHeight)));
SDL_Surface *wordSurface = SDL_CreateSurface(wordW, wordH, SDL_PIXELFORMAT_RGBA8888);
if (wordSurface)
{
if (!glyphs.empty()) {
const int wordW =
std::max(1, static_cast<int>(std::lround(totalWidth)));
const int wordH =
std::max(1, static_cast<int>(std::lround(glyphHeight)));
SDL_Surface *wordSurface =
SDL_CreateSurface(wordW, wordH, SDL_PIXELFORMAT_RGBA8888);
if (wordSurface) {
// Every glyph surface carries the font's full line height
// (probe-verified), so y = 0 keeps every crop on the shared
// baseline and per-word textures stay line-height tall.
float x = 0.0f;
for (const CharEntry *entry : glyphs)
{
for (const CharEntry *entry : glyphs) {
SDL_Rect srcRect{0, 0, static_cast<int>(entry->glyphW),
static_cast<int>(entry->glyphH)};
SDL_Rect dstRect{static_cast<int>(std::lround(x)), 0,
static_cast<int>(entry->glyphW),
static_cast<int>(entry->glyphH)};
SDL_BlitSurface(entry->surface, &srcRect, wordSurface, &dstRect);
SDL_BlitSurface(entry->surface, &srcRect, wordSurface,
&dstRect);
x += entry->advance;
}
cached.texture = SDL_CreateTextureFromSurface(state_->renderer, wordSurface);
cached.texture = SDL_CreateTextureFromSurface(
state_->renderer, wordSurface);
// The scroller steps glyphs in whole pixels, so bilinear
// filtering can only smear them: nearest-neighbor keeps
// every frame pixel-sharp.
if (cached.texture)
{
SDL_SetTextureScaleMode(cached.texture, SDL_SCALEMODE_NEAREST);
if (cached.texture) {
SDL_SetTextureScaleMode(cached.texture,
SDL_SCALEMODE_NEAREST);
}
cached.width = static_cast<float>(wordSurface->w);
cached.height = static_cast<float>(wordSurface->h);
SDL_DestroySurface(wordSurface);
if (!cached.texture)
{
if (!cached.texture) {
cached.width = 0.0f;
cached.height = 0.0f;
}
@@ -675,10 +618,8 @@ void ScrollerPage::ensureWordTexture(const std::string &word)
wordTextures_[word] = cached;
}
void ScrollerPage::ensureCharTexture(const std::string &glyph)
{
if (charTextures_.count(glyph) != 0)
{
void ScrollerPage::ensureCharTexture(const std::string &glyph) {
if (charTextures_.count(glyph) != 0) {
return;
}
@@ -687,25 +628,23 @@ void ScrollerPage::ensureCharTexture(const std::string &glyph)
// font size; createFont() clears the atlas whenever the font or its size
// changes, and pause/pop clear it as part of destroyResources().
CharEntry entry;
if (font_ && state_)
{
if (font_ && state_) {
SDL_Surface *surface = TTF_RenderText_Blended(font_, glyph.c_str(), 0,
{255, 255, 255, 255});
if (surface)
{
if (surface) {
const int gW = surface->w;
const int gH = surface->h;
const int n = radiantwave::scramble::canvasSize(gW, gH);
if (n > 0)
{
if (n > 0) {
// The scrambler needs tightly packed RGBA8888; a TTF surface's
// pitch can exceed gW * 4, so copy row by row.
std::vector<uint8_t> source(static_cast<size_t>(gW) * gH * 4);
const uint8_t *pixels = static_cast<const uint8_t *>(surface->pixels);
for (int y = 0; y < gH; ++y)
{
const uint8_t *pixels =
static_cast<const uint8_t *>(surface->pixels);
for (int y = 0; y < gH; ++y) {
std::memcpy(source.data() + static_cast<size_t>(y) * gW * 4,
pixels + static_cast<size_t>(y) * surface->pitch,
pixels +
static_cast<size_t>(y) * surface->pitch,
static_cast<size_t>(gW) * 4);
}
@@ -714,25 +653,24 @@ void ScrollerPage::ensureCharTexture(const std::string &glyph)
// The fixed seed makes every device render the same glyph as
// the same scrambled pattern, with no key to persist or ship.
radiantwave::scramble::ScrambleConfig config;
if (radiantwave::scramble::scramble(source.data(), scrambled.data(),
gW, gH, config))
{
SDL_Surface *tile = SDL_CreateSurface(n, n, SDL_PIXELFORMAT_RGBA8888);
if (tile)
{
if (radiantwave::scramble::scramble(
source.data(), scrambled.data(), gW, gH, config)) {
SDL_Surface *tile =
SDL_CreateSurface(n, n, SDL_PIXELFORMAT_RGBA8888);
if (tile) {
uint8_t *dst = static_cast<uint8_t *>(tile->pixels);
for (int y = 0; y < n; ++y)
{
std::memcpy(dst + static_cast<size_t>(y) * tile->pitch,
scrambled.data() + static_cast<size_t>(y) * n * 4,
for (int y = 0; y < n; ++y) {
std::memcpy(dst + static_cast<size_t>(y) *
tile->pitch,
scrambled.data() +
static_cast<size_t>(y) * n * 4,
static_cast<size_t>(n) * 4);
}
entry.surface = tile;
entry.glyphW = static_cast<float>(gW);
entry.glyphH = static_cast<float>(gH);
entry.advance = measureWidth(glyph);
if (entry.advance <= 0.0f)
{
if (entry.advance <= 0.0f) {
entry.advance = entry.glyphW;
}
}
@@ -744,22 +682,17 @@ void ScrollerPage::ensureCharTexture(const std::string &glyph)
charTextures_[glyph] = entry;
}
void ScrollerPage::clearCharTextures()
{
for (auto &entry : charTextures_)
{
if (entry.second.surface)
{
void ScrollerPage::clearCharTextures() {
for (auto &entry : charTextures_) {
if (entry.second.surface) {
SDL_DestroySurface(entry.second.surface);
}
}
charTextures_.clear();
}
float ScrollerPage::measureWidth(const std::string &text) const
{
if (!font_)
{
float ScrollerPage::measureWidth(const std::string &text) const {
if (!font_) {
return 0.0f;
}
int w = 0;
@@ -768,9 +701,9 @@ float ScrollerPage::measureWidth(const std::string &text) const
return static_cast<float>(w);
}
std::string ScrollerPage::audioFilePath(const std::string &filename) const
{
std::string path1 = std::string(std::getenv("HOME") ? std::getenv("HOME") : "/tmp") +
std::string ScrollerPage::audioFilePath(const std::string &filename) const {
std::string path1 =
std::string(std::getenv("HOME") ? std::getenv("HOME") : "/tmp") +
"/.local/radiantwave/audio/" + filename;
std::string path2 = "assets/audio/" + filename;
return std::filesystem::exists(path1) ? path1 : path2;
+4
View File
@@ -115,4 +115,8 @@ private:
float velocityDelay_ = 2.5f;
int velocityState_ = 0;
float velocityTimer_ = 0.0f;
int savedLogicalW_ = 0;
int savedLogicalH_ = 0;
SDL_RendererLogicalPresentation savedMode_ = SDL_LOGICAL_PRESENTATION_DISABLED;
};
+18 -18
View File
@@ -19,18 +19,18 @@ namespace
{
constexpr int MENU_ITEM_COUNT = 9;
// 0 = WiFi Setup, 1 = Audio Options, 2 = Time Zone Setup, 3 = Proclamations,
// 4 = Email Address, 5 = License Key, 6 = Date Format,
// 7 = View System Logs, 8 = Back
// 0 = Proclamations, 1 = Audio Options, 2 = Email Address, 3 = License Key,
// 4 = WiFi Setup, 5 = Time Zone Setup, 6 = Date Format,
// 7 = System Logs, 8 = Back
constexpr const char *MENU_LABELS[MENU_ITEM_COUNT] = {
"WiFi Setup",
"Audio Options",
"Time Zone Setup",
"Proclamations",
"Audio Options",
"Email Address",
"License Key",
"WiFi Setup",
"Time Zone Setup",
"Date Format",
"View System Logs",
"System Logs",
"Back to Home",
};
@@ -78,15 +78,6 @@ void Settings::handleEvent(const SDL_Event &event)
switch (selectedItem_)
{
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.
if (state_->licenseTracker.isValid())
{
@@ -97,12 +88,21 @@ void Settings::handleEvent(const SDL_Event &event)
state_->pages.push(*state_, std::make_unique<LicenseBlockedPage>());
}
break;
case 4:
case 1:
state_->pages.push(*state_, std::make_unique<AudioSettingsPage>());
break;
case 2:
state_->pages.push(*state_, std::make_unique<EmailAddressPage>());
break;
case 5:
case 3:
state_->pages.push(*state_, std::make_unique<LicenseKeyPage>());
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:
state_->pages.push(*state_, std::make_unique<DateFormatPage>());
break;