#include "proclamation_options_page.h" #include "app_state.h" #include "network/proclamation_sync.h" #include "logical_size.h" #include #include #include #include namespace { // Case-insensitive word-prefix match: true when any word in `text` (split on // non-alphanumeric boundaries) starts with `query`. Both must already be // lowercase. Mirrors the old Go browser's matching behavior. bool matchesWordPrefix(const std::string& text, const std::string& query) { if (query.empty()) return false; size_t i = 0; while (i < text.size()) { while (i < text.size() && !std::isalnum(static_cast(text[i]))) { ++i; } if (i >= text.size()) break; size_t start = i; while (i < text.size() && std::isalnum(static_cast(text[i]))) { ++i; } if (text.compare(start, query.size(), query) == 0) return true; } return false; } } // anonymous namespace void ProclamationOptionsPage::onPush(AppState& state) { state_ = &state; TTF_Init(); createFonts(); SDL_StartTextInput(state.window); // TODO(portal #4/#5): once the portal exposes color palettes, sync them // alongside proclamations here (sync::syncColorPalettesFromApi after this // succeeds) so palette updates reach the scroller on every manual refresh. bool synced = radiantwave::sync::syncProclamationsFromApi(state.db, DEFAULT_PROPLEAMATION_ID); statusMessage_ = synced ? "Synced with server" : "Offline - showing saved proclamations"; loadFromDb(); rebuildItems(); rebuildVisible(); state.keybinds.bind(SDLK_UP, "proc_up", [this]() { if (highlightIndex_ > 0) { highlightIndex_--; ensureHighlightVisible(); } }); state.keybinds.bind(SDLK_DOWN, "proc_down", [this]() { if (highlightIndex_ < activeListSize() - 1) { highlightIndex_++; ensureHighlightVisible(); } }); state.keybinds.bind(SDLK_D, SDL_KMOD_CTRL, "proc_clear_all", [this]() { clearAllSelections(); }); } void ProclamationOptionsPage::onResume(AppState& state) { state_ = &state; createFonts(); SDL_StartTextInput(state.window); loadFromDb(); rebuildItems(); rebuildVisible(); state.keybinds.bind(SDLK_UP, "proc_up", [this]() { if (highlightIndex_ > 0) { highlightIndex_--; ensureHighlightVisible(); } }); state.keybinds.bind(SDLK_DOWN, "proc_down", [this]() { if (!visible_.empty() && highlightIndex_ < static_cast(visible_.size()) - 1) { highlightIndex_++; ensureHighlightVisible(); } }); state.keybinds.bind(SDLK_D, SDL_KMOD_CTRL, "proc_clear_all", [this]() { clearAllSelections(); }); } void ProclamationOptionsPage::onPause(AppState& state) { state.keybinds.unbindAll(); SDL_StopTextInput(state.window); destroyFonts(); destroyTextures(); } void ProclamationOptionsPage::onPop(AppState& state) { state.keybinds.unbindAll(); SDL_StopTextInput(state.window); destroyFonts(); destroyTextures(); TTF_Quit(); } void ProclamationOptionsPage::createFonts() { destroyFonts(); fontTitle_ = TTF_OpenFont("assets/fonts/Roboto-Regular.ttf", 48); fontItem_ = TTF_OpenFont("assets/fonts/Roboto-Regular.ttf", 36); fontSmall_ = TTF_OpenFont("assets/fonts/Roboto-Regular.ttf", 20); } void ProclamationOptionsPage::destroyFonts() { if (fontTitle_) { TTF_CloseFont(fontTitle_); fontTitle_ = nullptr; } if (fontItem_) { TTF_CloseFont(fontItem_); fontItem_ = nullptr; } if (fontSmall_) { TTF_CloseFont(fontSmall_); fontSmall_ = nullptr; } } void ProclamationOptionsPage::destroyTextures() { titleText_.destroy(); statusText_.destroy(); searchText_.destroy(); emptyHintText_.destroy(); footerText_.destroy(); for (auto& t : rowTexts_) t.destroy(); rowTexts_.clear(); for (auto& t : searchRowTexts_) t.destroy(); searchRowTexts_.clear(); } void ProclamationOptionsPage::loadFromDb() { proclamations_ = state_->db.getAllProclamations(); categories_ = state_->db.getAllCategories(); state_->db.ensureDefaultProclamationSelected(DEFAULT_PROPLEAMATION_ID); for (auto& p : proclamations_) { if (p.id == DEFAULT_PROPLEAMATION_ID) { p.selected = true; } } searchPool_.clear(); searchPool_.reserve(proclamations_.size()); for (const auto& p : proclamations_) { std::string lower = p.title; lower += ' '; lower += p.content; for (auto& c : lower) { if (c >= 'A' && c <= 'Z') c = static_cast(c - 'A' + 'a'); } searchPool_.push_back(std::move(lower)); } } void ProclamationOptionsPage::rebuildItems() { items_.clear(); // Always-on top-level proclamation, never inside a category. items_.push_back({ FlatItem::Type::Proclamation, DEFAULT_PROPLEAMATION_ID, DEFAULT_PROPLEAMATION_NAME, 0, false, true, true, }); for (const auto& root : categories_) { if (root.parent_id != 0) continue; items_.push_back({ FlatItem::Type::RootCategory, std::to_string(root.id), root.name, 0, false, false, false, }); for (const auto& sub : categories_) { if (sub.parent_id != root.id) continue; items_.push_back({ FlatItem::Type::SubCategory, std::to_string(sub.id), sub.name, 1, true, false, false, }); for (const auto& p : proclamations_) { if (p.id == DEFAULT_PROPLEAMATION_ID) continue; if (p.category_id != sub.id) continue; items_.push_back({ FlatItem::Type::Proclamation, p.id, p.title, 2, false, p.selected, false, }); } } // Proclamations assigned directly to the root category. for (const auto& p : proclamations_) { if (p.id == DEFAULT_PROPLEAMATION_ID) continue; if (p.category_id != root.id) continue; items_.push_back({ FlatItem::Type::Proclamation, p.id, p.title, 1, false, p.selected, false, }); } } } void ProclamationOptionsPage::rebuildVisible() { visible_.clear(); if (items_.empty()) { highlightIndex_ = 0; return; } // Tree walk: track the currently-open root and sub category (items_ is // already ordered root -> sub -> proclamation, so each category dominates // the items until the next category of equal or shallower depth). int currentRoot = -1; // items_ index of the current root category int currentSub = -1; // items_ index of the current sub-category for (size_t i = 0; i < items_.size(); ++i) { const FlatItem& item = items_[i]; if (item.type == FlatItem::Type::RootCategory) { currentRoot = static_cast(i); currentSub = -1; visible_.push_back(i); continue; } if (item.type == FlatItem::Type::SubCategory) { bool rootOpen = currentRoot >= 0 && items_[currentRoot].expanded; if (rootOpen) { currentSub = static_cast(i); visible_.push_back(i); } else { currentSub = -1; } continue; } // Proclamation bool parentVisible = false; if (item.depth == 0) { parentVisible = true; // the always-on top-level proclamation } else if (currentRoot >= 0 && items_[currentRoot].expanded) { parentVisible = (item.depth == 1) || (item.depth == 2 && currentSub >= 0); } if (parentVisible) visible_.push_back(i); } if (highlightIndex_ >= static_cast(visible_.size())) { highlightIndex_ = std::max(0, static_cast(visible_.size()) - 1); } } void ProclamationOptionsPage::toggleItem() { if (visible_.empty()) return; FlatItem& item = items_[visible_[highlightIndex_]]; if (item.type == FlatItem::Type::RootCategory) { item.expanded = !item.expanded; rebuildVisible(); return; } if (item.isDefault) { statusMessage_ = "The top-level proclamation is always on"; return; } item.selected = !item.selected; for (auto& p : proclamations_) { if (p.id == item.id) { p.selected = item.selected; break; } } saveSelectionToDb(); } void ProclamationOptionsPage::enterSearchMode() { if (searchActive_) return; searchActive_ = true; searchResults_.clear(); highlightIndex_ = 0; scrollOffset_ = 0; } void ProclamationOptionsPage::exitSearchMode() { searchActive_ = false; searchQuery_.clear(); searchResults_.clear(); highlightIndex_ = 0; scrollOffset_ = 0; rebuildVisible(); } // Appends the printable ASCII portion of a UTF-8 text-input event to the // query. Non-ASCII (and control) characters are dropped; the search list is // rebuilt on every change. void ProclamationOptionsPage::appendSearchText(const char* utf8) { for (const unsigned char* p = reinterpret_cast(utf8); p && *p; ++p) { unsigned char c = *p; if (c >= 0x20 && c <= 0x7E) searchQuery_ += static_cast(c); } rebuildSearchResults(); } void ProclamationOptionsPage::rebuildSearchResults() { searchResults_.clear(); for (size_t i = 0; i < proclamations_.size(); ++i) { if (matchesWordPrefix(searchPool_[i], searchQuery_)) { searchResults_.push_back(i); } } if (highlightIndex_ >= static_cast(searchResults_.size())) { highlightIndex_ = std::max(0, static_cast(searchResults_.size()) - 1); } if (scrollOffset_ >= static_cast(searchResults_.size())) { scrollOffset_ = 0; } ensureHighlightVisible(); } int ProclamationOptionsPage::activeListSize() const { return searchActive_ ? static_cast(searchResults_.size()) : static_cast(visible_.size()); } // Toggles the highlighted search result. Selection rules mirror toggleItem(): // the always-on top-level proclamation is not togglable. Selection state is // synced back into items_ so the browse tree stays consistent when search mode // ends. void ProclamationOptionsPage::toggleSearchItem() { if (searchResults_.empty()) return; radiantwave::db::ProclamationRecord& p = proclamations_[searchResults_[highlightIndex_]]; if (p.id == DEFAULT_PROPLEAMATION_ID) { statusMessage_ = "The top-level proclamation is always on"; return; } p.selected = !p.selected; for (auto& item : items_) { if (item.type == FlatItem::Type::Proclamation && item.id == p.id) { item.selected = p.selected; break; } } saveSelectionToDb(); } void ProclamationOptionsPage::collapseCurrent() { if (visible_.empty()) return; FlatItem& item = items_[visible_[highlightIndex_]]; if (item.type == FlatItem::Type::RootCategory) { if (item.expanded) { item.expanded = false; rebuildVisible(); } } } void ProclamationOptionsPage::expandCurrent() { if (visible_.empty()) return; FlatItem& item = items_[visible_[highlightIndex_]]; if (item.type == FlatItem::Type::RootCategory) { if (!item.expanded) { item.expanded = true; rebuildVisible(); } } } void ProclamationOptionsPage::moveToParent() { if (visible_.empty()) return; const FlatItem& item = items_[visible_[highlightIndex_]]; if (item.depth <= 0) return; int start = static_cast(visible_[highlightIndex_]); for (int i = start - 1; i >= 0; --i) { const FlatItem& candidate = items_[i]; if ((candidate.type == FlatItem::Type::RootCategory || candidate.type == FlatItem::Type::SubCategory) && candidate.depth == item.depth - 1) { for (size_t vi = 0; vi < visible_.size(); ++vi) { if (visible_[vi] == static_cast(i)) { highlightIndex_ = static_cast(vi); ensureHighlightVisible(); return; } } } } } void ProclamationOptionsPage::saveSelectionToDb() { std::vector selected; for (const auto& p : proclamations_) { if (p.selected) selected.push_back(p.id); } state_->db.saveSelectedProclamations(selected, DEFAULT_PROPLEAMATION_ID); } // Deselects every proclamation except the always-on top-level one. The // highlight keeps its current row and the list rebuilds so category/selection // state (and the DB) all stay consistent. void ProclamationOptionsPage::clearAllSelections() { for (auto& p : proclamations_) { p.selected = (p.id == DEFAULT_PROPLEAMATION_ID); } saveSelectionToDb(); rebuildItems(); rebuildVisible(); if (searchActive_) { rebuildSearchResults(); } statusMessage_ = "All selections cleared (top-level proclamation stays on)"; } void ProclamationOptionsPage::ensureHighlightVisible() { if (highlightIndex_ < scrollOffset_) { scrollOffset_ = highlightIndex_; } int windowEnd = scrollOffset_ + visibleCount_; if (highlightIndex_ >= windowEnd) { scrollOffset_ = highlightIndex_ - visibleCount_ + 1; } if (scrollOffset_ < 0) scrollOffset_ = 0; } void ProclamationOptionsPage::scrollTo(int target) { int maxScroll = std::max(0, activeListSize() - visibleCount_); scrollOffset_ = std::clamp(target, 0, maxScroll); if (highlightIndex_ < scrollOffset_ || highlightIndex_ >= scrollOffset_ + visibleCount_) { highlightIndex_ = scrollOffset_; } } void ProclamationOptionsPage::handleEvent(const SDL_Event& event) { if (!state_) return; // Printable characters arrive as text input. The first non-space character // opens search; while search is active every printable character — // including space — is appended to the query. if (event.type == SDL_EVENT_TEXT_INPUT) { const char* text = event.text.text; if (!text || text[0] == '\0') return; if (!searchActive_) { if (strcmp(text, " ") == 0) return; // bare space does not open search bool printable = true; for (const unsigned char* p = reinterpret_cast(text); *p; ++p) { if (*p < 0x20 || *p > 0x7E) { printable = false; break; } } if (!printable) return; enterSearchMode(); } appendSearchText(text); return; } if (event.type != SDL_EVENT_KEY_DOWN) return; SDL_Keycode key = event.key.key; // SPACE is deliberately not handled here: it never selects a proclamation // (Enter does); its text-input event types a space into the search query. if (key == SDLK_RETURN || key == SDLK_KP_ENTER) { if (searchActive_) toggleSearchItem(); else toggleItem(); } else if (key == SDLK_BACKSPACE) { if (searchActive_ && !searchQuery_.empty()) { searchQuery_.pop_back(); if (searchQuery_.empty()) { exitSearchMode(); } else { rebuildSearchResults(); } } } else if (key == SDLK_LEFT) { if (searchActive_) return; if (!visible_.empty()) { const FlatItem& item = items_[visible_[highlightIndex_]]; if (item.type == FlatItem::Type::RootCategory && item.expanded) { collapseCurrent(); } else { moveToParent(); } } } else if (key == SDLK_RIGHT) { if (searchActive_) return; expandCurrent(); } else if (key == SDLK_PAGEUP) { scrollTo(std::max(0, scrollOffset_ - visibleCount_ + 1)); } else if (key == SDLK_PAGEDOWN) { scrollTo(scrollOffset_ + visibleCount_ - 1); } else if (key == SDLK_ESCAPE) { if (searchActive_) exitSearchMode(); else state_->pages.pop(*state_); } } void ProclamationOptionsPage::update(float dt) { } void ProclamationOptionsPage::render(SDL_Renderer* renderer) { int winW, winH; radiantwave::getLayoutSize(renderer, &winW, &winH); float fw = static_cast(winW); float fh = static_cast(winH); // Background SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); SDL_SetRenderDrawColor(renderer, 15, 18, 25, 255); SDL_FRect bgRect{0, 0, fw, fh}; SDL_RenderFillRect(renderer, &bgRect); if (!fontTitle_ || !fontItem_ || !fontSmall_) return; SDL_Color white = {255, 255, 255, 255}; SDL_Color gray = {170, 170, 170, 255}; SDL_Color categoryBlue = {100, 180, 255, 255}; SDL_Color selectedOrange = {255, 170, 60, 255}; float titleY = 50.0f; float titleH = 0.0f; // 1. Title titleText_.ensure(renderer, fontTitle_, "Proclamation Options", white); if (titleText_.texture) { float x = (fw - titleText_.width) / 2.0f; SDL_FRect dst{x, titleY, titleText_.width, titleText_.height}; SDL_RenderTexture(renderer, titleText_.texture, nullptr, &dst); titleH = titleText_.height; } // 2. Status line. The always-on top-level proclamation is not counted, // so the number reflects only what the user explicitly selected. float statusY = titleY + titleH + 12.0f; float searchBarY = statusY; { int selectedCount = 0; for (const auto& p : proclamations_) { if (p.selected && p.id != DEFAULT_PROPLEAMATION_ID) selectedCount++; } std::string status = statusMessage_ + " Selected: " + std::to_string(selectedCount); statusText_.ensure(renderer, fontSmall_, status, gray); if (statusText_.texture) { float x = (fw - statusText_.width) / 2.0f; SDL_FRect dst{x, statusY, statusText_.width, statusText_.height}; SDL_RenderTexture(renderer, statusText_.texture, nullptr, &dst); } } // 2b. Search bar (only while searching). if (searchActive_) { searchBarY = statusY + statusText_.height + 14.0f; SDL_Color searchYellow = {255, 230, 100, 255}; std::string label = "Search: " + searchQuery_ + "|"; searchText_.ensure(renderer, fontSmall_, label, searchYellow); if (searchText_.texture) { float x = (fw - searchText_.width) / 2.0f; SDL_FRect dst{x, searchBarY, searchText_.width, searchText_.height}; SDL_RenderTexture(renderer, searchText_.texture, nullptr, &dst); } } // 3. List { const float margin = 12.0f; const float indent = 28.0f; float itemH = 42.0f; int strW = 0, strH = 0; TTF_GetStringSize(fontItem_, "Ag", 0, &strW, &strH); itemH = static_cast(strH); float rowPitch = itemH + margin; float listTop = searchBarY + 40.0f; float footerTop = fh - 55.0f; float available = footerTop - listTop; visibleCount_ = std::max(1, static_cast(available / rowPitch)); int listSize = activeListSize(); if (listSize == 0) { visibleCount_ = 1; } // Clamp scroll/highlight to the visible window. int maxScroll = std::max(0, listSize - visibleCount_); if (scrollOffset_ > maxScroll) scrollOffset_ = maxScroll; if (highlightIndex_ >= listSize) { highlightIndex_ = std::max(0, listSize - 1); } if (highlightIndex_ < scrollOffset_ || highlightIndex_ >= scrollOffset_ + visibleCount_) { if (highlightIndex_ < scrollOffset_) scrollOffset_ = highlightIndex_; else scrollOffset_ = highlightIndex_ - visibleCount_ + 1; if (scrollOffset_ < 0) scrollOffset_ = 0; } if (searchActive_) { // --- Search results (flat proclamation pool, no categories) --- if (searchResults_.empty()) { emptyHintText_.ensure(renderer, fontSmall_, "No matching proclamations", gray); if (emptyHintText_.texture) { float x = (fw - emptyHintText_.width) / 2.0f; SDL_FRect dst{x, listTop, emptyHintText_.width, emptyHintText_.height}; SDL_RenderTexture(renderer, emptyHintText_.texture, nullptr, &dst); } } else { // 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(w)); } std::vector textWidths(static_cast(listSize)); for (int i = scrollOffset_; i < scrollOffset_ + visibleCount_ && i < listSize; ++i) { int w = 0, h = 0; TTF_GetStringSize(fontItem_, proclamations_[searchResults_[i]].title.c_str(), 0, &w, &h); textWidths[i] = static_cast(w); } float sharedX = (fw - maxTotalWidth) / 2.0f; if (searchRowTexts_.size() != proclamations_.size()) searchRowTexts_.resize(proclamations_.size()); int rowIndex = 0; for (int i = scrollOffset_; i < scrollOffset_ + visibleCount_ && i < listSize; ++i, ++rowIndex) { const radiantwave::db::ProclamationRecord& p = proclamations_[searchResults_[i]]; float y = listTop + rowIndex * rowPitch; SDL_Color color = white; if (p.selected) { color = selectedOrange; } searchRowTexts_[searchResults_[i]].ensure(renderer, fontItem_, p.title, color); if (searchRowTexts_[searchResults_[i]].texture) { SDL_FRect dst{sharedX, y, searchRowTexts_[searchResults_[i]].width, searchRowTexts_[searchResults_[i]].height}; SDL_RenderTexture(renderer, searchRowTexts_[searchResults_[i]].texture, nullptr, &dst); } // 6px selection underline (occupies half the 12px margin). if (i == highlightIndex_) { SDL_SetRenderDrawColor(renderer, 255, 255, 255, 255); SDL_FRect line{sharedX, y + itemH + 2.0f, textWidths[i], 6.0f}; SDL_RenderFillRect(renderer, &line); } } } } else { // --- Browse tree --- // Stable width: compute from every item so the list doesn't // shift left/right when groups expand or collapse. float maxTotalWidth = 0.0f; for (const auto& item : items_) { 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); float total = item.depth * indent + static_cast(w); maxTotalWidth = std::max(maxTotalWidth, total); } std::vector textWidths(visible_.size()); for (int i = scrollOffset_; i < scrollOffset_ + visibleCount_ && i < static_cast(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(w); } float sharedX = (fw - maxTotalWidth) / 2.0f; if (rowTexts_.size() != items_.size()) rowTexts_.resize(items_.size()); int rowIndex = 0; for (int i = scrollOffset_; i < scrollOffset_ + visibleCount_ && i < static_cast(visible_.size()); ++i, ++rowIndex) { const FlatItem& item = items_[visible_[i]]; float y = listTop + rowIndex * rowPitch; std::string label = item.label; if (item.type == FlatItem::Type::RootCategory) { label = (item.expanded ? "[-] " : "[+] ") + label; } SDL_Color color = white; if (item.type == FlatItem::Type::RootCategory || item.type == FlatItem::Type::SubCategory) { color = categoryBlue; } else if (item.selected || item.isDefault) { color = selectedOrange; } float x = sharedX + item.depth * indent; rowTexts_[visible_[i]].ensure(renderer, fontItem_, label, color); if (rowTexts_[visible_[i]].texture) { SDL_FRect dst{x, y, rowTexts_[visible_[i]].width, rowTexts_[visible_[i]].height}; SDL_RenderTexture(renderer, rowTexts_[visible_[i]].texture, nullptr, &dst); } // 6px selection underline (occupies half the 12px margin). if (i == highlightIndex_) { SDL_SetRenderDrawColor(renderer, 255, 255, 255, 255); SDL_FRect line{x, y + itemH + 2.0f, textWidths[i], 6.0f}; SDL_RenderFillRect(renderer, &line); } } } } // 4. Footer (mode-specific hints; ENTER selects, SPACE never does). if (searchActive_) { footerText_.ensure(renderer, fontSmall_, "[UP/DOWN] Move [ENTER] Select [BACKSPACE] Edit [ESC] Cancel Search", gray); } else { footerText_.ensure(renderer, fontSmall_, "[UP/DOWN] Move [ENTER] Toggle [Type to Search] [LEFT/RIGHT] Expand/Collapse [CTRL+D] Clear All [ESC] Back", gray); } if (footerText_.texture) { float x = (fw - footerText_.width) / 2.0f; SDL_FRect dst{x, fh - 35.0f, footerText_.width, footerText_.height}; SDL_RenderTexture(renderer, footerText_.texture, nullptr, &dst); } }