From 92ba9f39fb4a708806505bdc10abefa285352250 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Tue, 25 Aug 2026 11:28:02 -0500 Subject: [PATCH] Switch from window-size to render-output-size --- src/scroller_page.cpp | 1400 ++++++++++++++++++++--------------------- 1 file changed, 685 insertions(+), 715 deletions(-) diff --git a/src/scroller_page.cpp b/src/scroller_page.cpp index 6c6dc79..ec918ed 100644 --- a/src/scroller_page.cpp +++ b/src/scroller_page.cpp @@ -2,805 +2,775 @@ #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 #include #include #include +#include #include #include #include -#include #include #include 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; + + // 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(windowW); + screenH_ = static_cast(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(); + + 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. + std::string track = state.config.get().audio.current_track; + 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))) { + 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_); + } + }); +} { - state_ = &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_); + // 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_); - SDL_Window *window = SDL_GetRenderWindow(state.renderer); - int windowW = 0; - int windowH = 0; - if (window) - { - SDL_GetWindowSize(window, &windowW, &windowH); - } - if (windowW <= 0) - { - windowW = radiantwave::kDesignWidth; - } - if (windowH <= 0) - { - windowH = radiantwave::kDesignHeight; - } + SDL_Window *window = SDL_GetRenderWindow(state.renderer); + int windowW = 0; + int windowH = 0; + if (window) { + SDL_GetWindowSize(window, &windowW, &windowH); + } + if (windowW <= 0) { + windowW = radiantwave::kDesignWidth; + } + if (windowH <= 0) { + windowH = radiantwave::kDesignHeight; + } - SDL_SetRenderLogicalPresentation(state.renderer, windowW, windowH, - SDL_LOGICAL_PRESENTATION_DISABLED); + SDL_SetRenderLogicalPresentation(state.renderer, windowW, windowH, + SDL_LOGICAL_PRESENTATION_DISABLED); - screenW_ = static_cast(windowW); - screenH_ = static_cast(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_; + screenW_ = static_cast(windowW); + screenH_ = static_cast(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(); + TTF_Init(); + buildStreams(state); + createFont(state); + createPatterns(); - prefilling_ = true; - prefillLines(); - prefilling_ = false; + 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. - std::string track = state.config.get().audio.current_track; - 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))) - { - track = stream.audioFilename; - } - } - if (std::filesystem::exists(audioFilePath(track))) - { - state.audio.playTrackFadeIn(track, kFadeSeconds); - } + // 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. + std::string track = state.config.get().audio.current_track; + 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))) { + 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_); - } }); + 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(); +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_); - } }); + 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::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_); +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(); + 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_) { + state_->audio.update(dt); + } + + 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::update(float dt) -{ - if (state_) - { - state_->audio.update(dt); - } +void ScrollerPage::render(SDL_Renderer *renderer) { + SDL_SetRenderDrawColor(renderer, 0, 0, 0, 0); + SDL_RenderClear(renderer); - 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; - } - } - } + 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::render(SDL_Renderer *renderer) -{ - SDL_SetRenderDrawColor(renderer, 0, 0, 0, 0); - SDL_RenderClear(renderer); +void ScrollerPage::buildStreams(AppState &state) { + streams_.clear(); + fallbackPalette_.clear(); + lines_.clear(); + streamIndex_ = 0; - 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); - } - } + 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> + palettesByFreqId; + for (const auto &rec : proclamations) { + int freqId = rec.solfeggio_frequency_id; + if (palettesByFreqId.count(freqId) != 0) { + continue; + } + std::vector 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 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; + } + } + + streams_ = radiantwave::scroller::buildStreams( + proclamations, palettesByFreqId, fallbackPalette_, audioByFreqId, + DEFAULT_PROPLEAMATION_ID); } -void ScrollerPage::buildStreams(AppState &state) -{ - streams_.clear(); - fallbackPalette_.clear(); - lines_.clear(); - streamIndex_ = 0; +void ScrollerPage::createFont(AppState &state) { + if (font_) { + TTF_CloseFont(font_); + font_ = nullptr; + } - auto proclamations = state.db.getActiveProclamations(); - if (proclamations.empty()) - { - return; - } + // 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(); - for (const auto &c : state.db.getPaletteForFrequencyId(5)) - { // 528 Hz palette - fallbackPalette_.push_back({c.r, c.g, c.b, c.a}); - } + fontPxSize_ = state.config.get().proclamation.font_size; + if (fontPxSize_ <= 0) { + fontPxSize_ = 32; + } - std::unordered_map> palettesByFreqId; - for (const auto &rec : proclamations) - { - int freqId = rec.solfeggio_frequency_id; - if (palettesByFreqId.count(freqId) != 0) - { - continue; - } - std::vector 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); - } + font_ = TTF_OpenFont("assets/fonts/Roboto-Regular.ttf", fontPxSize_); + if (!font_) { + return; + } - std::unordered_map 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; - } - } + int w = 0; + int h = 0; + TTF_GetStringSize(font_, "Mg", 0, &w, &h); + lineHeight_ = + h > 0 ? static_cast(h) : static_cast(fontPxSize_); - streams_ = radiantwave::scroller::buildStreams( - proclamations, palettesByFreqId, fallbackPalette_, audioByFreqId, - DEFAULT_PROPLEAMATION_ID); + spaceWidth_ = measureWidth(" "); + if (spaceWidth_ <= 0.0f) { + spaceWidth_ = static_cast(fontPxSize_) * 0.5f; + } } -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(h) : static_cast(fontPxSize_); - - spaceWidth_ = measureWidth(" "); - if (spaceWidth_ <= 0.0f) - { - spaceWidth_ = static_cast(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); }); + } } -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 &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(words.size()); + double perWord = (total + (n - 1.0) * spaceWidth_) / n; + if (perWord <= 0.0) { + perWord = 1.0; + } + + int fit = static_cast(std::lround(screenW_ / perWord)); + return fit < 1 ? 1 : fit; } -int ScrollerPage::computePeakWordCount(const std::vector &words) const -{ - if (words.empty() || screenW_ <= 0.0f) - { - return 1; - } +void ScrollerPage::destroyResources() { + clearCharTextures(); - // 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(words.size()); - double perWord = (total + (n - 1.0) * spaceWidth_) / n; - if (perWord <= 0.0) - { - perWord = 1.0; - } + for (auto &entry : wordTextures_) { + if (entry.second.texture) { + SDL_DestroyTexture(entry.second.texture); + } + } + wordTextures_.clear(); - int fit = static_cast(std::lround(screenW_ / perWord)); - return fit < 1 ? 1 : fit; + if (font_) { + TTF_CloseFont(font_); + font_ = nullptr; + } } -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::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(std::ceil(screenH_ / lineHeight_)) + 2; + for (int i = 0; i < n; ++i) { + float baseY = -lineHeight_ + static_cast(i) * lineHeight_; + addLineAt(baseY); + } } -void ScrollerPage::prefillLines() -{ - if (streams_.empty() || lineHeight_ <= 0.0f) - { - return; - } +bool ScrollerPage::addLineAt(float baseY) { + if (streams_.empty() || !font_) { + return false; + } - int n = static_cast(std::ceil(screenH_ / lineHeight_)) + 2; - for (int i = 0; i < n; ++i) - { - float baseY = -lineHeight_ + static_cast(i) * lineHeight_; - addLineAt(baseY); - } + ProclamationStream &stream = streams_[streamIndex_]; + if (stream.words.empty()) { + advanceStream(); + return false; + } + + FibonacciLinePattern &pattern = patterns_[streamIndex_]; + + // Capture palette state before any stream advance. + std::vector paletteCopy = + stream.palette; + int lineColorIndex = stream.colorIndex; + stream.colorIndex++; + + std::vector 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(stream.words.size())) { + stream.wordsPlaced -= static_cast(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(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; } -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 paletteCopy = stream.palette; - int lineColorIndex = stream.colorIndex; - stream.colorIndex++; - - std::vector 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(stream.words.size())) - { - stream.wordsPlaced -= static_cast(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(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; + } + streamIndex_ = (streamIndex_ + 1) % static_cast(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()) { + 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::advanceStream() -{ - if (streams_.empty()) - { - return; - } - streamIndex_ = (streamIndex_ + 1) % static_cast(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()) - { - 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. + if (!state_->config.get().audio.dynamic_audio) { + 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::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) - { - 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::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 codePoints; + if (radiantwave::splitUtf8(word, codePoints) && !codePoints.empty()) { + std::vector 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(std::lround(totalWidth))); + const int wordH = + std::max(1, static_cast(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(entry->glyphW), + static_cast(entry->glyphH)}; + SDL_Rect dstRect{static_cast(std::lround(x)), 0, + static_cast(entry->glyphW), + static_cast(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(wordSurface->w); + cached.height = static_cast(wordSurface->h); + SDL_DestroySurface(wordSurface); + if (!cached.texture) { + cached.width = 0.0f; + cached.height = 0.0f; + } + } + } + } + } + wordTextures_[word] = cached; } -void ScrollerPage::ensureWordTexture(const std::string &word) -{ - if (wordTextures_.count(word) != 0) - { - return; - } +void ScrollerPage::ensureCharTexture(const std::string &glyph) { + if (charTextures_.count(glyph) != 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 codePoints; - if (radiantwave::splitUtf8(word, codePoints) && !codePoints.empty()) - { - std::vector 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; - } - } + // 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 source(static_cast(gW) * gH * 4); + const uint8_t *pixels = + static_cast(surface->pixels); + for (int y = 0; y < gH; ++y) { + std::memcpy(source.data() + static_cast(y) * gW * 4, + pixels + + static_cast(y) * surface->pitch, + static_cast(gW) * 4); + } - if (!glyphs.empty()) - { - const int wordW = std::max(1, static_cast(std::lround(totalWidth))); - const int wordH = std::max(1, static_cast(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(entry->glyphW), - static_cast(entry->glyphH)}; - SDL_Rect dstRect{static_cast(std::lround(x)), 0, - static_cast(entry->glyphW), - static_cast(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(wordSurface->w); - cached.height = static_cast(wordSurface->h); - SDL_DestroySurface(wordSurface); - if (!cached.texture) - { - cached.width = 0.0f; - cached.height = 0.0f; - } - } - } - } - } - wordTextures_[word] = cached; + std::vector scrambled(static_cast(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(tile->pixels); + for (int y = 0; y < n; ++y) { + std::memcpy(dst + static_cast(y) * + tile->pitch, + scrambled.data() + + static_cast(y) * n * 4, + static_cast(n) * 4); + } + entry.surface = tile; + entry.glyphW = static_cast(gW); + entry.glyphH = static_cast(gH); + entry.advance = measureWidth(glyph); + if (entry.advance <= 0.0f) { + entry.advance = entry.glyphW; + } + } + } + } + SDL_DestroySurface(surface); + } + } + charTextures_[glyph] = entry; } -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 source(static_cast(gW) * gH * 4); - const uint8_t *pixels = static_cast(surface->pixels); - for (int y = 0; y < gH; ++y) - { - std::memcpy(source.data() + static_cast(y) * gW * 4, - pixels + static_cast(y) * surface->pitch, - static_cast(gW) * 4); - } - - std::vector scrambled(static_cast(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(tile->pixels); - for (int y = 0; y < n; ++y) - { - std::memcpy(dst + static_cast(y) * tile->pitch, - scrambled.data() + static_cast(y) * n * 4, - static_cast(n) * 4); - } - entry.surface = tile; - entry.glyphW = static_cast(gW); - entry.glyphH = static_cast(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(); } -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(w); } -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(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; +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; }