Files
radiantwave/src/scroller_page.cpp
T
NeilClack da0b6ffd5e Kiosk: apply schedule engine in playback and add System Settings toggle
ScrollerPage now resolves the active Block from ScheduleManager (reusing
the existing stream-switch path), honoring dynamicAudio true/false and
falling back to local selection for gaps, no schedule, or toggle off.
Adds a 'Schedule' On/Off entry (default On) in System Settings persisted
in config.yaml, and config plumbing (proclamation.schedule_enabled). All
on-device schedule times remain 12h AM/PM.
2026-09-29 10:34:45 -05:00

802 lines
24 KiB
C++

#include "scroller_page.h"
#include "app_state.h"
#include "audio/audio_downloader.h"
#include "logical_size.h"
#include "proclamation_options_page.h"
#include "scramble/fibonacci_scrambler.h"
#include "timezone_helper.h"
#include "utf8_utils.h"
#include <SDL3/SDL.h>
#include <SDL3_ttf/SDL_ttf.h>
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include <vector>
using radiantwave::FibonacciLinePattern;
using radiantwave::scroller::ProclamationStream;
namespace {
constexpr float kFadeSeconds = 5.0f;
constexpr float kVelocityMin = -3500.0f;
constexpr float kVelocityMax = 3500.0f;
} // anonymous namespace
void ScrollerPage::onPush(AppState &state) {
state_ = &state;
// 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_;
TTF_Init();
buildStreams(state);
createFont(state);
createPatterns();
lines_.clear();
prefilling_ = true;
prefillLines();
prefilling_ = false;
// Start audio. In dynamic mode the current stream's solfeggio-frequency
// track wins (when its file exists); otherwise the configured manual track
// plays. A frequency file that is not on disk yet falls back to the manual
// track rather than playing nothing. A schedule block with dynamicAudio=true
// forces dynamic audio for its duration; false leaves the device setting be.
std::string track = state.config.get().audio.current_track;
if (effectiveDynamicAudio() && !streams_.empty()) {
const ProclamationStream &stream = streams_[streamIndex_];
if (stream.solfeggio_frequency_id != 0 &&
!stream.audioFilename.empty() &&
std::filesystem::exists(audioFilePath(stream.audioFilename))) {
track = stream.audioFilename;
}
}
if (std::filesystem::exists(audioFilePath(track))) {
state.audio.playTrackFadeIn(track, kFadeSeconds);
}
state.keybinds.bind(SDLK_ESCAPE, "scroller_exit", [this]() {
if (state_) {
state_->pages.pop(*state_);
}
});
}
void ScrollerPage::onResume(AppState &state) {
state_ = &state;
createFont(state);
createPatterns();
rebuildWordTextures();
state.keybinds.bind(SDLK_ESCAPE, "scroller_exit", [this]() {
if (state_) {
state_->pages.pop(*state_);
}
});
}
void ScrollerPage::onPause(AppState &state) {
state.keybinds.unbindAll();
destroyResources();
}
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::update(float dt) {
if (state_) {
state_->audio.update(dt);
}
// Block boundaries are minute-granular, so a one-second poll is plenty and
// avoids mutating the process timezone every frame.
schedulePollTimer_ -= dt;
if (schedulePollTimer_ <= 0.0f) {
schedulePollTimer_ = 1.0f;
pollScheduleChange();
}
updateAutoVelocity(dt);
scrollOffset_ += velocity_ * dt;
float move = std::floor(scrollOffset_);
if (move != 0.0f) {
scrollOffset_ -= move;
for (auto &line : lines_) {
for (auto &w : line.words) {
w.y += move;
}
}
}
if (lines_.empty()) {
return;
}
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_) {
float baseY = lines_.front().words[0].y - lineHeight_;
lines_.pop_back();
if (!addLineAt(baseY)) {
break;
}
}
} 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) {
float baseY = lines_.back().words[0].y + lineHeight_;
lines_.pop_front();
if (!addLineAt(baseY)) {
break;
}
}
}
}
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()) {
continue;
}
float startX = std::floor((screenW_ - line.width) / 2.0f);
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);
}
}
}
void ScrollerPage::buildStreams(AppState &state) {
streams_.clear();
fallbackPalette_.clear();
streamIndex_ = 0;
scheduleChangePending_ = false;
// Schedule state: resolve the active Block from the schedule manager (which
// owns the fetched/cached payload and the local on/off toggle). The block
// boundary is handled in advanceStream(), so rebuilding mid-playback never
// truncates the current proclamation.
const radiantwave::config::AppConfig cfg = state.config.get();
timezone_ = cfg.timezone;
// Keep the engine's local toggle in step with config (it may have been
// changed in System Settings or hot-reloaded).
state.scheduleManager.setEnabled(cfg.proclamation.schedule_enabled);
const int now = radiantwave::timezone::currentLocalMinutes(timezone_);
const radiantwave::schedule::Weekday weekday =
radiantwave::schedule::weekdayFromIndex(
radiantwave::timezone::currentLocalWeekday(timezone_));
const radiantwave::schedule::Decision decision =
state.scheduleManager.activeBlock(now, weekday);
scheduleActive_ = state.scheduleManager.enabled();
if (decision.hasBlock) {
activeBlockProclamationIds_ = decision.block.proclamation_ids;
activeBlockDynamicAudio_ = decision.block.dynamic_audio;
activeBlockIndex_ = 1; // any non-negative marker: a block is active
} else {
activeBlockProclamationIds_.clear();
activeBlockDynamicAudio_ = false;
activeBlockIndex_ = -1;
}
auto proclamations = state.db.getActiveProclamations();
if (proclamations.empty()) {
return;
}
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) {
int freqId = rec.solfeggio_frequency_id;
if (palettesByFreqId.count(freqId) != 0) {
continue;
}
std::vector<radiantwave::scroller::StreamColor> colors;
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()) {
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) {
freqId = radiantwave::audio::frequencyIdForHz(
radiantwave::audio::hzFromFilename(af.filename));
}
if (freqId != 0) {
audioByFreqId[freqId] = af;
}
}
const std::vector<std::string>* scheduledIds = nullptr;
std::vector<std::string> scheduledOrder;
if (activeBlockIndex_ >= 0 && !activeBlockProclamationIds_.empty()) {
scheduledOrder = activeBlockProclamationIds_;
scheduledIds = &scheduledOrder;
}
streams_ = radiantwave::scroller::buildStreams(
proclamations, palettesByFreqId, fallbackPalette_, audioByFreqId,
DEFAULT_PROPLEAMATION_ID, scheduledIds);
}
bool ScrollerPage::scheduleBlockActiveNow() const {
if (!state_) {
return false;
}
const int now = radiantwave::timezone::currentLocalMinutes(timezone_);
const radiantwave::schedule::Weekday weekday =
radiantwave::schedule::weekdayFromIndex(
radiantwave::timezone::currentLocalWeekday(timezone_));
return state_->scheduleManager.activeBlock(now, weekday).hasBlock;
}
void ScrollerPage::pollScheduleChange() {
// The manager owns the local toggle; when off, there is never an active block.
if (!state_) {
return;
}
const bool activeNow = scheduleBlockActiveNow();
const bool hadBlock = activeBlockIndex_ >= 0;
if (activeNow != hadBlock) {
scheduleChangePending_ = true;
}
}
bool ScrollerPage::effectiveDynamicAudio() const {
// dynamicAudio=true on the active block forces dynamic audio, overriding
// whatever the device selected. false means "don't override": the device's
// own selection (dynamic or a fixed track) applies as usual.
if (scheduleActive_ && activeBlockIndex_ >= 0 && activeBlockDynamicAudio_) {
return true;
}
return state_ ? state_->config.get().audio.dynamic_audio : false;
}
void ScrollerPage::createFont(AppState &state) {
if (font_) {
TTF_CloseFont(font_);
font_ = nullptr;
}
// A different font or size changes every rasterized glyph, so the scrambled
// tile atlas is invalid. clearCharTextures() also runs on pause/pop, but
// this covers a re-entry that re-creates the font without a destroy cycle.
clearCharTextures();
fontPxSize_ = state.config.get().proclamation.font_size;
if (fontPxSize_ <= 0) {
fontPxSize_ = 32;
}
font_ = TTF_OpenFont("assets/fonts/Roboto-Regular.ttf", fontPxSize_);
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_);
spaceWidth_ = measureWidth(" ");
if (spaceWidth_ <= 0.0f) {
spaceWidth_ = static_cast<float>(fontPxSize_) * 0.5f;
}
}
void ScrollerPage::createPatterns() {
patterns_.clear();
patterns_.reserve(streams_.size());
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); });
}
}
int ScrollerPage::computePeakWordCount(
const std::vector<std::string> &words) const {
if (words.empty() || screenW_ <= 0.0f) {
return 1;
}
// Average rendered cost of one word, including its share of inter-word
// 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) {
total += measureWidth(w);
}
double n = static_cast<double>(words.size());
double perWord = (total + (n - 1.0) * spaceWidth_) / n;
if (perWord <= 0.0) {
perWord = 1.0;
}
int fit = static_cast<int>(std::lround(screenW_ / perWord));
return fit < 1 ? 1 : fit;
}
void ScrollerPage::destroyResources() {
clearCharTextures();
for (auto &entry : wordTextures_) {
if (entry.second.texture) {
SDL_DestroyTexture(entry.second.texture);
}
}
wordTextures_.clear();
if (font_) {
TTF_CloseFont(font_);
font_ = nullptr;
}
}
void ScrollerPage::rebuildWordTextures() {
wordTextures_.clear();
for (auto &line : lines_) {
float width = 0.0f;
for (auto &item : line.words) {
ensureWordTexture(item.text);
const CachedWord &cached = wordTextures_[item.text];
if (!cached.texture) {
continue;
}
item.texture = cached.texture;
item.width = cached.width;
item.height = cached.height;
if (width > 0.0f) {
width += spaceWidth_;
}
item.x = width;
width += item.width;
}
line.width = width;
}
}
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) {
float baseY = -lineHeight_ + static_cast<float>(i) * lineHeight_;
addLineAt(baseY);
}
}
bool ScrollerPage::addLineAt(float baseY) {
if (streams_.empty() || !font_) {
return false;
}
ProclamationStream &stream = streams_[streamIndex_];
if (stream.words.empty()) {
advanceStream();
return false;
}
FibonacciLinePattern &pattern = patterns_[streamIndex_];
// Capture palette state before any stream advance.
std::vector<radiantwave::scroller::StreamColor> paletteCopy =
stream.palette;
int lineColorIndex = stream.colorIndex;
stream.colorIndex++;
std::vector<std::string> wordStrs;
int placed = pattern.nextLine(stream.words, wordStrs);
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) {
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())) {
stream.wordsPlaced -= static_cast<int>(stream.words.size());
stream.completed++;
if (stream.completed >= stream.repeat) {
stream.completed = 0;
advanceStream();
advanced = true;
}
}
ScrollerLine line;
float width = 0.0f;
for (const auto &text : wordStrs) {
ensureWordTexture(text);
const CachedWord &cached = wordTextures_[text];
if (!cached.texture) {
continue;
}
WordItem item;
item.text = text;
item.texture = cached.texture;
item.width = cached.width;
item.height = cached.height;
item.y = baseY;
if (width > 0.0f) {
width += spaceWidth_;
}
item.x = width;
width += item.width;
line.words.push_back(item);
}
if (line.words.empty()) {
return false;
}
line.width = width;
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) {
lines_.push_front(std::move(line));
} else {
lines_.push_back(std::move(line));
}
return true;
}
void ScrollerPage::advanceStream() {
if (streams_.empty()) {
return;
}
if (scheduleChangePending_ && state_) {
// A schedule block changed while the previous proclamation was playing.
// Apply it only now, at a stream boundary, so the current proclamation
// finishes instead of being cut off.
buildStreams(*state_);
createPatterns();
streamIndex_ = 0;
} else {
streamIndex_ = (streamIndex_ + 1) % static_cast<int>(streams_.size());
}
if (streams_.empty()) {
return;
}
// 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()) {
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 {
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_) {
return;
}
// Dynamic audio only: with a fixed manual track selected the audio must
// never switch as proclamations change. An active schedule block's
// dynamicAudio flag overrides the device-wide setting.
if (!effectiveDynamicAudio()) {
return;
}
const ProclamationStream &stream = streams_[streamIndex_];
if (stream.solfeggio_frequency_id == 0 || stream.audioFilename.empty()) {
return; // default stream: never touch audio
}
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
// same one already playing.
state_->audio.playTrackCrossfade(stream.audioFilename, kFadeSeconds);
}
void ScrollerPage::updateAutoVelocity(float dt) {
switch (velocityState_) {
case 0: // ramping to the negative extreme
velocity_ -= velocityStep_ * dt;
if (velocity_ <= kVelocityMin) {
velocity_ = kVelocityMin;
velocityTimer_ = velocityDelay_;
velocityState_ = 1;
}
break;
case 1: // holding at the negative extreme
velocityTimer_ -= dt;
if (velocityTimer_ <= 0.0f) {
velocityState_ = 2;
}
break;
case 2: // ramping to the positive extreme
velocity_ += velocityStep_ * dt;
if (velocity_ >= kVelocityMax) {
velocity_ = kVelocityMax;
velocityTimer_ = velocityDelay_;
velocityState_ = 3;
}
break;
case 3: // holding at the positive extreme
velocityTimer_ -= dt;
if (velocityTimer_ <= 0.0f) {
velocityState_ = 0;
}
break;
default:
velocityState_ = 0;
break;
}
}
void ScrollerPage::ensureWordTexture(const std::string &word) {
if (wordTextures_.count(word) != 0) {
return;
}
// Proclamation text is never rendered as human-readable type
// ([This implementation] rendering pipeline). Each word is assembled from
// per-glyph scrambled tiles instead:
// 1. split the word into code points (utf8_utils.h) -- letters, digits,
// and common punctuation such as ` , ' " . - all get their own tile;
// 2. each code point's glyph is rasterized once, permuted into an N x N
// tile by the Fibonacci affine map
// (scramble/fibonacci_scrambler.h), and cached in charTextures_
// keyed by the glyph string (one scramble per code point per font
// size, amortized O(N^2 * rounds));
// 3. the word surface is built by blitting each tile's top-left
// glyphW x glyphH crop (the crop-window convention) at the glyph's
// advance position.
// Scrambling is per glyph rather than per word so a single tile serves
// every occurrence of a code point across the whole proclamation set.
// Word layout widths still come from TTF_GetStringSize(), so line
// breaking, centering, and the Fibonacci line pattern are unchanged; only
// the glyph pixels move.
CachedWord cached;
if (font_ && state_) {
std::vector<uint32_t> codePoints;
if (radiantwave::splitUtf8(word, codePoints) && !codePoints.empty()) {
std::vector<CharEntry *> glyphs;
float totalWidth = 0.0f;
float glyphHeight = 0.0f;
for (uint32_t cp : codePoints) {
const std::string glyphText = radiantwave::encodeUtf8(cp);
if (glyphText.empty()) {
continue;
}
ensureCharTexture(glyphText);
CharEntry &entry = charTextures_[glyphText];
if (!entry.surface) {
continue;
}
glyphs.push_back(&entry);
totalWidth += entry.advance;
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) {
// 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) {
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);
x += entry->advance;
}
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);
}
cached.width = static_cast<float>(wordSurface->w);
cached.height = static_cast<float>(wordSurface->h);
SDL_DestroySurface(wordSurface);
if (!cached.texture) {
cached.width = 0.0f;
cached.height = 0.0f;
}
}
}
}
}
wordTextures_[word] = cached;
}
void ScrollerPage::ensureCharTexture(const std::string &glyph) {
if (charTextures_.count(glyph) != 0) {
return;
}
// Rasterize, scramble, and cache one glyph tile. `charTextures_` is keyed
// on the glyph's UTF-8 text, so a code point is scrambled at most once per
// 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_) {
SDL_Surface *surface = TTF_RenderText_Blended(font_, glyph.c_str(), 0,
{255, 255, 255, 255});
if (surface) {
const int gW = surface->w;
const int gH = surface->h;
const int n = radiantwave::scramble::canvasSize(gW, gH);
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) {
std::memcpy(source.data() + static_cast<size_t>(y) * gW * 4,
pixels +
static_cast<size_t>(y) * surface->pitch,
static_cast<size_t>(gW) * 4);
}
std::vector<uint8_t> scrambled(static_cast<size_t>(n) * n * 4);
// Default key: compile-time seed (kDefaultSeed) and 4 rounds.
// 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) {
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,
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) {
entry.advance = entry.glyphW;
}
}
}
}
SDL_DestroySurface(surface);
}
}
charTextures_[glyph] = entry;
}
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_) {
return 0.0f;
}
int w = 0;
int h = 0;
TTF_GetStringSize(font_, text.c_str(), 0, &w, &h);
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") +
"/.local/radiantwave/audio/" + filename;
std::string path2 = "assets/audio/" + filename;
return std::filesystem::exists(path1) ? path1 : path2;
}