830 lines
29 KiB
C++
830 lines
29 KiB
C++
#include "proclamation_options_page.h"
|
|
#include "app_state.h"
|
|
#include "network/proclamation_sync.h"
|
|
#include "logical_size.h"
|
|
#include <SDL3/SDL.h>
|
|
#include <algorithm>
|
|
#include <cctype>
|
|
#include <cstring>
|
|
|
|
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<unsigned char>(text[i]))) {
|
|
++i;
|
|
}
|
|
if (i >= text.size()) break;
|
|
|
|
size_t start = i;
|
|
while (i < text.size() &&
|
|
std::isalnum(static_cast<unsigned char>(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<int>(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();
|
|
groups_ = state_->db.getAllGroups();
|
|
|
|
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<char>(c - 'A' + 'a');
|
|
}
|
|
searchPool_.push_back(std::move(lower));
|
|
}
|
|
}
|
|
|
|
void ProclamationOptionsPage::rebuildItems()
|
|
{
|
|
items_.clear();
|
|
|
|
// Schedule master toggle: enables/disables following the portal schedule.
|
|
// It is a plain on/off row (selected stores the on state) and sits above
|
|
// the always-on proclamation so it is always the first list entry.
|
|
items_.push_back({
|
|
FlatItem::Type::ScheduleToggle,
|
|
"schedule",
|
|
"Schedule Active",
|
|
0, false, state_->config.get().proclamation.schedule_active, false,
|
|
});
|
|
|
|
// 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,
|
|
});
|
|
|
|
// Groups that actually exist, so a proclamation pointing at a group the
|
|
// portal has since removed falls back to the ungrouped bucket.
|
|
auto groupExists = [this](int groupId) {
|
|
return std::any_of(groups_.begin(), groups_.end(),
|
|
[groupId](const auto& g) { return g.id == groupId; });
|
|
};
|
|
|
|
for (const auto& category : categories_) {
|
|
items_.push_back({
|
|
FlatItem::Type::Category,
|
|
std::to_string(category.id),
|
|
category.name,
|
|
0, false, false, false,
|
|
});
|
|
|
|
// Groups are tag-like and global. A group is shown under a category
|
|
// only when it has at least one proclamation in that category, so
|
|
// impossible pairings (e.g. "Physical / Prayers") never render.
|
|
for (const auto& group : groups_) {
|
|
std::vector<const radiantwave::db::ProclamationRecord*> members;
|
|
for (const auto& p : proclamations_) {
|
|
if (p.id == DEFAULT_PROPLEAMATION_ID) continue;
|
|
if (p.category_id != category.id || p.group_id != group.id) continue;
|
|
members.push_back(&p);
|
|
}
|
|
if (members.empty()) continue;
|
|
|
|
items_.push_back({
|
|
FlatItem::Type::Group,
|
|
std::to_string(group.id),
|
|
group.name,
|
|
1, true, false, false,
|
|
});
|
|
for (const auto* p : members) {
|
|
items_.push_back({
|
|
FlatItem::Type::Proclamation,
|
|
p->id,
|
|
p->title,
|
|
2, false, p->selected, false,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Proclamations with no group (or a stale group id) sit directly under
|
|
// the category.
|
|
for (const auto& p : proclamations_) {
|
|
if (p.id == DEFAULT_PROPLEAMATION_ID) continue;
|
|
if (p.category_id != category.id) continue;
|
|
if (p.group_id != 0 && groupExists(p.group_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 category and group (items_ is
|
|
// already ordered category -> group -> proclamation, so each category
|
|
// dominates the items until the next category).
|
|
int currentCategory = -1; // items_ index of the current category
|
|
int currentGroup = -1; // items_ index of the current group
|
|
|
|
for (size_t i = 0; i < items_.size(); ++i) {
|
|
const FlatItem& item = items_[i];
|
|
|
|
if (item.type == FlatItem::Type::ScheduleToggle) {
|
|
visible_.push_back(i);
|
|
continue;
|
|
}
|
|
|
|
if (item.type == FlatItem::Type::Category) {
|
|
currentCategory = static_cast<int>(i);
|
|
currentGroup = -1;
|
|
visible_.push_back(i);
|
|
continue;
|
|
}
|
|
|
|
if (item.type == FlatItem::Type::Group) {
|
|
bool categoryOpen = currentCategory >= 0 && items_[currentCategory].expanded;
|
|
if (categoryOpen) {
|
|
currentGroup = static_cast<int>(i);
|
|
visible_.push_back(i);
|
|
} else {
|
|
currentGroup = -1;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Proclamation
|
|
bool parentVisible = false;
|
|
if (item.depth == 0) {
|
|
parentVisible = true; // the always-on top-level proclamation
|
|
} else if (currentCategory >= 0 && items_[currentCategory].expanded) {
|
|
parentVisible = (item.depth == 1) ||
|
|
(item.depth == 2 && currentGroup >= 0 &&
|
|
items_[currentGroup].expanded);
|
|
}
|
|
if (parentVisible) visible_.push_back(i);
|
|
}
|
|
|
|
if (highlightIndex_ >= static_cast<int>(visible_.size())) {
|
|
highlightIndex_ = std::max(0, static_cast<int>(visible_.size()) - 1);
|
|
}
|
|
}
|
|
|
|
void ProclamationOptionsPage::toggleItem()
|
|
{
|
|
if (visible_.empty()) return;
|
|
FlatItem& item = items_[visible_[highlightIndex_]];
|
|
|
|
if (item.type == FlatItem::Type::Category || item.type == FlatItem::Type::Group) {
|
|
item.expanded = !item.expanded;
|
|
rebuildVisible();
|
|
return;
|
|
}
|
|
|
|
if (item.type == FlatItem::Type::ScheduleToggle) {
|
|
item.selected = !item.selected;
|
|
const bool enabled = item.selected;
|
|
state_->config.update([enabled](radiantwave::config::AppConfig& cfg) {
|
|
cfg.proclamation.schedule_active = enabled;
|
|
});
|
|
statusMessage_ = enabled
|
|
? "Schedule active - playback follows the portal schedule"
|
|
: "Schedule inactive - playback uses your selections";
|
|
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<const unsigned char*>(utf8);
|
|
p && *p; ++p) {
|
|
unsigned char c = *p;
|
|
if (c >= 0x20 && c <= 0x7E) searchQuery_ += static_cast<char>(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<int>(searchResults_.size())) {
|
|
highlightIndex_ = std::max(0, static_cast<int>(searchResults_.size()) - 1);
|
|
}
|
|
if (scrollOffset_ >= static_cast<int>(searchResults_.size())) {
|
|
scrollOffset_ = 0;
|
|
}
|
|
ensureHighlightVisible();
|
|
}
|
|
|
|
int ProclamationOptionsPage::activeListSize() const
|
|
{
|
|
return searchActive_
|
|
? static_cast<int>(searchResults_.size())
|
|
: static_cast<int>(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::Category || item.type == FlatItem::Type::Group) {
|
|
if (item.expanded) {
|
|
item.expanded = false;
|
|
rebuildVisible();
|
|
}
|
|
}
|
|
}
|
|
|
|
void ProclamationOptionsPage::expandCurrent()
|
|
{
|
|
if (visible_.empty()) return;
|
|
FlatItem& item = items_[visible_[highlightIndex_]];
|
|
if (item.type == FlatItem::Type::Category || item.type == FlatItem::Type::Group) {
|
|
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<int>(visible_[highlightIndex_]);
|
|
for (int i = start - 1; i >= 0; --i) {
|
|
const FlatItem& candidate = items_[i];
|
|
if ((candidate.type == FlatItem::Type::Category ||
|
|
candidate.type == FlatItem::Type::Group) &&
|
|
candidate.depth == item.depth - 1) {
|
|
for (size_t vi = 0; vi < visible_.size(); ++vi) {
|
|
if (visible_[vi] == static_cast<size_t>(i)) {
|
|
highlightIndex_ = static_cast<int>(vi);
|
|
ensureHighlightVisible();
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
void ProclamationOptionsPage::saveSelectionToDb()
|
|
{
|
|
std::vector<std::string> 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<const unsigned char*>(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::Category ||
|
|
item.type == FlatItem::Type::Group) && 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<float>(winW);
|
|
float fh = static_cast<float>(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 groupBlue = {160, 210, 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<float>(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<int>(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<float>(w));
|
|
}
|
|
|
|
std::vector<float> textWidths(static_cast<size_t>(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<float>(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 ---
|
|
// The schedule toggle renders its on/off state inline; category
|
|
// and group rows get an expand/collapse marker.
|
|
auto labelFor = [](const FlatItem& item) -> std::string {
|
|
if (item.type == FlatItem::Type::ScheduleToggle) {
|
|
return "Schedule Active: " +
|
|
std::string(item.selected ? "[ON]" : "[OFF]");
|
|
}
|
|
if (item.type == FlatItem::Type::Category ||
|
|
item.type == FlatItem::Type::Group) {
|
|
return (item.expanded ? "[-] " : "[+] ") + item.label;
|
|
}
|
|
return item.label;
|
|
};
|
|
|
|
// 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 = labelFor(item);
|
|
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 = labelFor(item);
|
|
int w = 0, h = 0;
|
|
TTF_GetStringSize(fontItem_, label.c_str(), 0, &w, &h);
|
|
textWidths[i] = static_cast<float>(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<int>(visible_.size()); ++i, ++rowIndex) {
|
|
const FlatItem& item = items_[visible_[i]];
|
|
float y = listTop + rowIndex * rowPitch;
|
|
|
|
std::string label = labelFor(item);
|
|
|
|
SDL_Color color = white;
|
|
if (item.type == FlatItem::Type::Category) {
|
|
color = categoryBlue;
|
|
} else if (item.type == FlatItem::Type::Group) {
|
|
color = groupBlue;
|
|
} 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);
|
|
}
|
|
}
|