Files
radiantwave/src/proclamation_options_page.cpp
T
NeilClack 3c9de0bee2 Fix double-scaling by locking page layout to logical presentation
Pages computed layout from SDL_GetWindowSize() (real window
coordinates) while SDL_SetRenderLogicalPresentation() independently
remapped every draw call into a 1920x1080 space. The two coordinate
systems only agreed when the window happened to be exactly 1920x1080;
at any other size, layout coordinates were transformed a second time,
so content shrank/grew quadratically toward the top-left on resize or
on non-1080p monitors. Commenting the call out masked the bug by
making rendering 1:1 with the window, but then nothing scaled: fonts
and margins stayed fixed-pixel while backgrounds cover-fit, and HiDPI
displays drew at point resolution and relied on blurry compositor
upsampling.

Restore SDL_SetRenderLogicalPresentation(1920x1080, LETTERBOX) as the
single source of scaling, and route all twelve layout sites through a
new radiantwave::getLayoutSize() helper (src/logical_size.h) that
returns the renderer's logical size, so pages always lay out against
the design resolution regardless of window or monitor geometry. Add
SDL_WINDOW_HIGH_PIXEL_DENSITY so HiDPI displays render at native pixel
density instead of being upscaled by the OS. Cached per-page textures
become resolution-independent since layout no longer depends on window
size.
2026-08-21 09:29:23 -05:00

766 lines
26 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();
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();
// 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, false, 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<int>(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<int>(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 && items_[currentSub].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::RootCategory || item.type == FlatItem::Type::SubCategory) {
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<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::RootCategory || item.type == FlatItem::Type::SubCategory) {
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 || item.type == FlatItem::Type::SubCategory) {
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::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<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::RootCategory ||
item.type == FlatItem::Type::SubCategory) && 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 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 {
// Compute the widest row so the block can be centered.
float maxTotalWidth = 0.0f;
std::vector<float> textWidths(static_cast<size_t>(listSize));
for (int i = scrollOffset_; i < scrollOffset_ + visibleCount_ && i < listSize; ++i) {
const std::string& label = proclamations_[searchResults_[i]].title;
int w = 0, h = 0;
TTF_GetStringSize(fontItem_, label.c_str(), 0, &w, &h);
maxTotalWidth = std::max(maxTotalWidth, static_cast<float>(w));
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 ---
// Compute the widest row so the block can be centered.
float maxTotalWidth = 0.0f;
std::vector<float> textWidths(visible_.size());
for (int i = scrollOffset_; i < scrollOffset_ + visibleCount_ && i < static_cast<int>(visible_.size()); ++i) {
const FlatItem& item = items_[visible_[i]];
std::string label = item.label;
if (item.type == FlatItem::Type::RootCategory || item.type == FlatItem::Type::SubCategory) {
label = (item.expanded ? "[-] " : "[+] ") + label;
}
int w = 0, h = 0;
TTF_GetStringSize(fontItem_, label.c_str(), 0, &w, &h);
float total = item.depth * indent + static_cast<float>(w);
maxTotalWidth = std::max(maxTotalWidth, total);
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 = item.label;
if (item.type == FlatItem::Type::RootCategory || item.type == FlatItem::Type::SubCategory) {
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);
}
}