Files
crosspoint-reader-mod/src/activities/reader/DictionaryWordSelectActivity.cpp
cottongin 02f2474e3b mod: adapt mod activities to #774 render() pattern
Migrate 5 mod Activity subclasses from old polling-based
display task pattern to the upstream render() super-class
pattern with freeRTOS notification:

- EpubReaderBookmarkSelectionActivity
- DictionaryWordSelectActivity
- DictionarySuggestionsActivity
- DictionaryDefinitionActivity
- LookedUpWordsActivity

Changes: remove own TaskHandle/SemaphoreHandle/updateRequired,
use requestUpdate() + render(RenderLock&&) override, fix
potential deadlocks around enterNewActivity() calls.

Also fix stale conflict marker in EpubReaderMenuActivity.h.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 13:22:40 -05:00

636 lines
24 KiB
C++

#include "DictionaryWordSelectActivity.h"
#include <GfxRenderer.h>
#include <algorithm>
#include <climits>
#include "CrossPointSettings.h"
#include "DictionaryDefinitionActivity.h"
#include "DictionarySuggestionsActivity.h"
#include "MappedInputManager.h"
#include "components/UITheme.h"
#include "fontIds.h"
#include "util/Dictionary.h"
#include "util/LookupHistory.h"
void DictionaryWordSelectActivity::onEnter() {
ActivityWithSubactivity::onEnter();
extractWords();
mergeHyphenatedWords();
if (!rows.empty()) {
currentRow = static_cast<int>(rows.size()) / 3;
currentWordInRow = 0;
}
requestUpdate();
}
void DictionaryWordSelectActivity::onExit() {
ActivityWithSubactivity::onExit();
}
bool DictionaryWordSelectActivity::isLandscape() const {
return orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CW ||
orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CCW;
}
bool DictionaryWordSelectActivity::isInverted() const {
return orientation == CrossPointSettings::ORIENTATION::INVERTED;
}
void DictionaryWordSelectActivity::extractWords() {
words.clear();
rows.clear();
for (const auto& element : page->elements) {
// PageLine is the only concrete PageElement type, identified by tag
const auto* line = static_cast<const PageLine*>(element.get());
const auto& block = line->getBlock();
if (!block) continue;
const auto& wordList = block->getWords();
const auto& xPosList = block->getWordXpos();
auto wordIt = wordList.begin();
auto xIt = xPosList.begin();
while (wordIt != wordList.end() && xIt != xPosList.end()) {
int16_t screenX = line->xPos + static_cast<int16_t>(*xIt) + marginLeft;
int16_t screenY = line->yPos + marginTop;
const std::string& wordText = *wordIt;
// Split on en-dash (U+2013: E2 80 93) and em-dash (U+2014: E2 80 94)
std::vector<size_t> splitStarts;
size_t partStart = 0;
for (size_t i = 0; i < wordText.size();) {
if (i + 2 < wordText.size() && static_cast<uint8_t>(wordText[i]) == 0xE2 &&
static_cast<uint8_t>(wordText[i + 1]) == 0x80 &&
(static_cast<uint8_t>(wordText[i + 2]) == 0x93 || static_cast<uint8_t>(wordText[i + 2]) == 0x94)) {
if (i > partStart) splitStarts.push_back(partStart);
i += 3;
partStart = i;
} else {
i++;
}
}
if (partStart < wordText.size()) splitStarts.push_back(partStart);
if (splitStarts.size() <= 1 && partStart == 0) {
// No dashes found -- add as a single word
int16_t wordWidth = renderer.getTextWidth(fontId, wordText.c_str());
words.push_back({wordText, screenX, screenY, wordWidth, 0});
} else {
// Add each part as a separate selectable word
for (size_t si = 0; si < splitStarts.size(); si++) {
size_t start = splitStarts[si];
size_t end = (si + 1 < splitStarts.size()) ? splitStarts[si + 1] : wordText.size();
// Find actual end by trimming any trailing dash bytes
size_t textEnd = end;
while (textEnd > start && textEnd <= wordText.size()) {
if (textEnd >= 3 && static_cast<uint8_t>(wordText[textEnd - 3]) == 0xE2 &&
static_cast<uint8_t>(wordText[textEnd - 2]) == 0x80 &&
(static_cast<uint8_t>(wordText[textEnd - 1]) == 0x93 ||
static_cast<uint8_t>(wordText[textEnd - 1]) == 0x94)) {
textEnd -= 3;
} else {
break;
}
}
std::string part = wordText.substr(start, textEnd - start);
if (part.empty()) continue;
std::string prefix = wordText.substr(0, start);
int16_t offsetX = prefix.empty() ? 0 : renderer.getTextWidth(fontId, prefix.c_str());
int16_t partWidth = renderer.getTextWidth(fontId, part.c_str());
words.push_back({part, static_cast<int16_t>(screenX + offsetX), screenY, partWidth, 0});
}
}
++wordIt;
++xIt;
}
}
// Group words into rows by Y position
if (words.empty()) return;
int16_t currentY = words[0].screenY;
rows.push_back({currentY, {}});
for (size_t i = 0; i < words.size(); i++) {
// Allow small Y tolerance (words on same line may differ by a pixel)
if (std::abs(words[i].screenY - currentY) > 2) {
currentY = words[i].screenY;
rows.push_back({currentY, {}});
}
words[i].row = static_cast<int16_t>(rows.size() - 1);
rows.back().wordIndices.push_back(static_cast<int>(i));
}
}
void DictionaryWordSelectActivity::mergeHyphenatedWords() {
for (size_t r = 0; r + 1 < rows.size(); r++) {
if (rows[r].wordIndices.empty() || rows[r + 1].wordIndices.empty()) continue;
int lastWordIdx = rows[r].wordIndices.back();
const std::string& lastWord = words[lastWordIdx].text;
if (lastWord.empty()) continue;
// Check if word ends with hyphen (regular '-' or soft hyphen U+00AD: 0xC2 0xAD)
bool endsWithHyphen = false;
if (lastWord.back() == '-') {
endsWithHyphen = true;
} else if (lastWord.size() >= 2 && static_cast<uint8_t>(lastWord[lastWord.size() - 2]) == 0xC2 &&
static_cast<uint8_t>(lastWord[lastWord.size() - 1]) == 0xAD) {
endsWithHyphen = true;
}
if (!endsWithHyphen) continue;
int nextWordIdx = rows[r + 1].wordIndices.front();
// Set bidirectional continuation links for highlighting both parts
words[lastWordIdx].continuationIndex = nextWordIdx;
words[nextWordIdx].continuationOf = lastWordIdx;
// Build merged lookup text: remove trailing hyphen and combine
std::string firstPart = lastWord;
if (firstPart.back() == '-') {
firstPart.pop_back();
} else if (firstPart.size() >= 2 && static_cast<uint8_t>(firstPart[firstPart.size() - 2]) == 0xC2 &&
static_cast<uint8_t>(firstPart[firstPart.size() - 1]) == 0xAD) {
firstPart.erase(firstPart.size() - 2);
}
std::string merged = firstPart + words[nextWordIdx].text;
words[lastWordIdx].lookupText = merged;
words[nextWordIdx].lookupText = merged;
words[nextWordIdx].continuationIndex = nextWordIdx; // self-ref so highlight logic finds the second part
}
// Cross-page hyphenation: last word on page + first word of next page
if (!nextPageFirstWord.empty() && !rows.empty()) {
int lastWordIdx = rows.back().wordIndices.back();
const std::string& lastWord = words[lastWordIdx].text;
if (!lastWord.empty()) {
bool endsWithHyphen = false;
if (lastWord.back() == '-') {
endsWithHyphen = true;
} else if (lastWord.size() >= 2 && static_cast<uint8_t>(lastWord[lastWord.size() - 2]) == 0xC2 &&
static_cast<uint8_t>(lastWord[lastWord.size() - 1]) == 0xAD) {
endsWithHyphen = true;
}
if (endsWithHyphen) {
std::string firstPart = lastWord;
if (firstPart.back() == '-') {
firstPart.pop_back();
} else if (firstPart.size() >= 2 && static_cast<uint8_t>(firstPart[firstPart.size() - 2]) == 0xC2 &&
static_cast<uint8_t>(firstPart[firstPart.size() - 1]) == 0xAD) {
firstPart.erase(firstPart.size() - 2);
}
std::string merged = firstPart + nextPageFirstWord;
words[lastWordIdx].lookupText = merged;
}
}
}
// Remove empty rows that may result from merging (e.g., a row whose only word was a continuation)
rows.erase(std::remove_if(rows.begin(), rows.end(), [](const Row& r) { return r.wordIndices.empty(); }), rows.end());
}
void DictionaryWordSelectActivity::loop() {
// Delegate to subactivity (definition/suggestions screen) if active
if (subActivity) {
subActivity->loop();
if (pendingBackFromDef) {
pendingBackFromDef = false;
exitActivity();
requestUpdate();
}
if (pendingExitToReader) {
pendingExitToReader = false;
exitActivity();
onBack();
}
return;
}
if (words.empty()) {
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
onBack();
}
return;
}
bool changed = false;
const bool landscape = isLandscape();
const bool inverted = isInverted();
// Button mapping depends on physical orientation:
// - Portrait: side Up/Down = row nav, face Left/Right = word nav
// - Inverted: same axes but reversed directions (device is flipped 180)
// - Landscape: face Left/Right = row nav (swapped), side Up/Down = word nav
bool rowPrevPressed, rowNextPressed, wordPrevPressed, wordNextPressed;
if (landscape && orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CW) {
rowPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::Left);
rowNextPressed = mappedInput.wasReleased(MappedInputManager::Button::Right);
wordPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::PageForward) ||
mappedInput.wasReleased(MappedInputManager::Button::Down);
wordNextPressed = mappedInput.wasReleased(MappedInputManager::Button::PageBack) ||
mappedInput.wasReleased(MappedInputManager::Button::Up);
} else if (landscape) {
rowPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::Right);
rowNextPressed = mappedInput.wasReleased(MappedInputManager::Button::Left);
wordPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::PageBack) ||
mappedInput.wasReleased(MappedInputManager::Button::Up);
wordNextPressed = mappedInput.wasReleased(MappedInputManager::Button::PageForward) ||
mappedInput.wasReleased(MappedInputManager::Button::Down);
} else if (inverted) {
rowPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::PageForward) ||
mappedInput.wasReleased(MappedInputManager::Button::Down);
rowNextPressed = mappedInput.wasReleased(MappedInputManager::Button::PageBack) ||
mappedInput.wasReleased(MappedInputManager::Button::Up);
wordPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::Right);
wordNextPressed = mappedInput.wasReleased(MappedInputManager::Button::Left);
} else {
// Portrait (default)
rowPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::PageBack) ||
mappedInput.wasReleased(MappedInputManager::Button::Up);
rowNextPressed = mappedInput.wasReleased(MappedInputManager::Button::PageForward) ||
mappedInput.wasReleased(MappedInputManager::Button::Down);
wordPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::Left);
wordNextPressed = mappedInput.wasReleased(MappedInputManager::Button::Right);
}
const int rowCount = static_cast<int>(rows.size());
// Helper: find closest word by X position in a target row
auto findClosestWord = [&](int targetRow) {
int wordIdx = rows[currentRow].wordIndices[currentWordInRow];
int currentCenterX = words[wordIdx].screenX + words[wordIdx].width / 2;
int bestMatch = 0;
int bestDist = INT_MAX;
for (int i = 0; i < static_cast<int>(rows[targetRow].wordIndices.size()); i++) {
int idx = rows[targetRow].wordIndices[i];
int centerX = words[idx].screenX + words[idx].width / 2;
int dist = std::abs(centerX - currentCenterX);
if (dist < bestDist) {
bestDist = dist;
bestMatch = i;
}
}
return bestMatch;
};
// Move to previous row (wrap to bottom)
if (rowPrevPressed) {
int targetRow = (currentRow > 0) ? currentRow - 1 : rowCount - 1;
currentWordInRow = findClosestWord(targetRow);
currentRow = targetRow;
changed = true;
}
// Move to next row (wrap to top)
if (rowNextPressed) {
int targetRow = (currentRow < rowCount - 1) ? currentRow + 1 : 0;
currentWordInRow = findClosestWord(targetRow);
currentRow = targetRow;
changed = true;
}
// Move to previous word (wrap to end of previous row)
if (wordPrevPressed) {
if (currentWordInRow > 0) {
currentWordInRow--;
} else if (rowCount > 1) {
currentRow = (currentRow > 0) ? currentRow - 1 : rowCount - 1;
currentWordInRow = static_cast<int>(rows[currentRow].wordIndices.size()) - 1;
}
changed = true;
}
// Move to next word (wrap to start of next row)
if (wordNextPressed) {
if (currentWordInRow < static_cast<int>(rows[currentRow].wordIndices.size()) - 1) {
currentWordInRow++;
} else if (rowCount > 1) {
currentRow = (currentRow < rowCount - 1) ? currentRow + 1 : 0;
currentWordInRow = 0;
}
changed = true;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
int wordIdx = rows[currentRow].wordIndices[currentWordInRow];
const std::string& rawWord = words[wordIdx].lookupText;
std::string cleaned = Dictionary::cleanWord(rawWord);
if (cleaned.empty()) {
{
Activity::RenderLock lock(*this);
GUI.drawPopup(renderer, "No word");
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
}
vTaskDelay(1000 / portTICK_PERIOD_MS);
requestUpdate();
return;
}
Rect popupLayout;
{
Activity::RenderLock lock(*this);
popupLayout = GUI.drawPopup(renderer, "Looking up...");
}
bool cancelled = false;
std::string definition = Dictionary::lookup(
cleaned,
[this, &popupLayout](int percent) {
Activity::RenderLock lock(*this);
GUI.fillPopupProgress(renderer, popupLayout, percent);
},
[this, &cancelled]() -> bool {
mappedInput.update();
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
cancelled = true;
return true;
}
return false;
});
if (cancelled) {
requestUpdate();
return;
}
LookupHistory::addWord(cachePath, cleaned);
if (!definition.empty()) {
enterNewActivity(new DictionaryDefinitionActivity(
renderer, mappedInput, cleaned, definition, fontId, orientation,
[this]() { pendingBackFromDef = true; }, [this]() { pendingExitToReader = true; }));
return;
}
// Try stem variants (e.g., "jumped" -> "jump")
auto stems = Dictionary::getStemVariants(cleaned);
for (const auto& stem : stems) {
std::string stemDef = Dictionary::lookup(stem);
if (!stemDef.empty()) {
enterNewActivity(new DictionaryDefinitionActivity(
renderer, mappedInput, stem, stemDef, fontId, orientation,
[this]() { pendingBackFromDef = true; }, [this]() { pendingExitToReader = true; }));
return;
}
}
// Find similar words for suggestions
auto similar = Dictionary::findSimilar(cleaned, 6);
if (!similar.empty()) {
enterNewActivity(new DictionarySuggestionsActivity(
renderer, mappedInput, cleaned, similar, fontId, orientation, cachePath,
[this]() { pendingBackFromDef = true; }, [this]() { pendingExitToReader = true; }));
return;
}
{
Activity::RenderLock lock(*this);
GUI.drawPopup(renderer, "Not found");
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
}
vTaskDelay(1500 / portTICK_PERIOD_MS);
requestUpdate();
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
onBack();
return;
}
if (changed) {
requestUpdate();
}
}
void DictionaryWordSelectActivity::render(Activity::RenderLock&&) {
renderer.clearScreen();
// Render the page content
page->render(renderer, fontId, marginLeft, marginTop);
if (!words.empty() && currentRow < static_cast<int>(rows.size())) {
int wordIdx = rows[currentRow].wordIndices[currentWordInRow];
const auto& w = words[wordIdx];
// Draw inverted highlight behind selected word
const int lineHeight = renderer.getLineHeight(fontId);
renderer.fillRect(w.screenX - 1, w.screenY - 1, w.width + 2, lineHeight + 2, true);
renderer.drawText(fontId, w.screenX, w.screenY, w.text.c_str(), false);
// Highlight the other half of a hyphenated word (whether selecting first or second part)
int otherIdx = (w.continuationOf >= 0) ? w.continuationOf : -1;
if (otherIdx < 0 && w.continuationIndex >= 0 && w.continuationIndex != wordIdx) {
otherIdx = w.continuationIndex;
}
if (otherIdx >= 0) {
const auto& other = words[otherIdx];
renderer.fillRect(other.screenX - 1, other.screenY - 1, other.width + 2, lineHeight + 2, true);
renderer.drawText(fontId, other.screenX, other.screenY, other.text.c_str(), false);
}
}
drawHints();
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
}
void DictionaryWordSelectActivity::drawHints() {
// Draw button hints in portrait orientation (matching physical buttons and theme).
// Any hint whose area would overlap the selected word highlight is completely skipped,
// leaving the page content underneath visible.
const auto origOrientation = renderer.getOrientation();
// Get portrait dimensions for overlap math
renderer.setOrientation(GfxRenderer::Orientation::Portrait);
const int portW = renderer.getScreenWidth(); // 480 in portrait
const int portH = renderer.getScreenHeight(); // 800 in portrait
renderer.setOrientation(origOrientation);
// Bottom button constants (match LyraTheme::drawButtonHints)
constexpr int buttonHeight = 40; // LyraMetrics::values.buttonHintsHeight
constexpr int buttonWidth = 80;
constexpr int cornerRadius = 6;
constexpr int textYOffset = 7;
constexpr int smallButtonHeight = 15;
constexpr int buttonPositions[] = {58, 146, 254, 342};
// Side button constants (match LyraTheme::drawSideButtonHints)
constexpr int sideButtonWidth = 30; // LyraMetrics::values.sideButtonHintsWidth
constexpr int sideButtonHeight = 78;
constexpr int sideButtonGap = 5;
constexpr int sideTopY = 345; // topHintButtonY
const int sideX = portW - sideButtonWidth;
const int sideButtonY[2] = {sideTopY, sideTopY + sideButtonHeight + sideButtonGap};
// Labels for face and side buttons depend on orientation,
// because the physical-to-logical mapping rotates with the screen.
const char* facePrev; // label for physical Left face button
const char* faceNext; // label for physical Right face button
const char* sideTop; // label for physical top side button (PageBack)
const char* sideBottom; // label for physical bottom side button (PageForward)
const bool landscape = isLandscape();
const bool inverted = isInverted();
if (landscape && orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CW) {
facePrev = "Line Up"; faceNext = "Line Dn";
sideTop = "Word \xC2\xBB"; sideBottom = "\xC2\xAB Word";
} else if (landscape) { // LANDSCAPE_CCW
facePrev = "Line Dn"; faceNext = "Line Up";
sideTop = "\xC2\xAB Word"; sideBottom = "Word \xC2\xBB";
} else if (inverted) {
facePrev = "Word \xC2\xBB"; faceNext = "\xC2\xAB Word";
sideTop = "Line Dn"; sideBottom = "Line Up";
} else { // Portrait (default)
facePrev = "\xC2\xAB Word"; faceNext = "Word \xC2\xBB";
sideTop = "Line Up"; sideBottom = "Line Dn";
}
const auto labels = mappedInput.mapLabels("\xC2\xAB Back", "Select", facePrev, faceNext);
const char* btnLabels[] = {labels.btn1, labels.btn2, labels.btn3, labels.btn4};
const char* sideLabels[] = {sideTop, sideBottom};
// ---- Determine which hints overlap the selected word ----
bool hideHint[4] = {false, false, false, false};
bool hideSide[2] = {false, false};
if (!words.empty() && currentRow < static_cast<int>(rows.size())) {
const int lineHeight = renderer.getLineHeight(fontId);
// Collect bounding boxes of the selected word (and its continuation) in current-orientation coords.
struct Box {
int x, y, w, h;
};
Box boxes[2];
int boxCount = 0;
int wordIdx = rows[currentRow].wordIndices[currentWordInRow];
const auto& sel = words[wordIdx];
boxes[0] = {sel.screenX - 1, sel.screenY - 1, sel.width + 2, lineHeight + 2};
boxCount = 1;
int otherIdx = (sel.continuationOf >= 0) ? sel.continuationOf : -1;
if (otherIdx < 0 && sel.continuationIndex >= 0 && sel.continuationIndex != wordIdx) {
otherIdx = sel.continuationIndex;
}
if (otherIdx >= 0) {
const auto& other = words[otherIdx];
boxes[1] = {other.screenX - 1, other.screenY - 1, other.width + 2, lineHeight + 2};
boxCount = 2;
}
// Convert each box from the current orientation to portrait coordinates,
// then check overlap against both bottom and side button hints.
for (int b = 0; b < boxCount; b++) {
int px, py, pw, ph;
if (origOrientation == GfxRenderer::Orientation::Portrait) {
px = boxes[b].x;
py = boxes[b].y;
pw = boxes[b].w;
ph = boxes[b].h;
} else if (origOrientation == GfxRenderer::Orientation::PortraitInverted) {
px = portW - boxes[b].x - boxes[b].w;
py = portH - boxes[b].y - boxes[b].h;
pw = boxes[b].w;
ph = boxes[b].h;
} else if (origOrientation == GfxRenderer::Orientation::LandscapeClockwise) {
px = boxes[b].y;
py = portH - boxes[b].x - boxes[b].w;
pw = boxes[b].h;
ph = boxes[b].w;
} else {
px = portW - boxes[b].y - boxes[b].h;
py = boxes[b].x;
pw = boxes[b].h;
ph = boxes[b].w;
}
// Bottom button overlap
int hintTop = portH - buttonHeight;
if (py + ph > hintTop) {
for (int i = 0; i < 4; i++) {
if (px + pw > buttonPositions[i] && px < buttonPositions[i] + buttonWidth) {
hideHint[i] = true;
}
}
}
// Side button overlap
if (px + pw > sideX) {
for (int s = 0; s < 2; s++) {
if (py + ph > sideButtonY[s] && py < sideButtonY[s] + sideButtonHeight) {
hideSide[s] = true;
}
}
}
}
}
// ---- Draw all hints in portrait mode ----
// Hidden buttons are skipped entirely so the page content underneath stays visible.
renderer.setOrientation(GfxRenderer::Orientation::Portrait);
// Bottom face buttons
for (int i = 0; i < 4; i++) {
if (hideHint[i]) continue;
const int x = buttonPositions[i];
renderer.fillRect(x, portH - buttonHeight, buttonWidth, buttonHeight, false);
if (btnLabels[i] != nullptr && btnLabels[i][0] != '\0') {
renderer.drawRoundedRect(x, portH - buttonHeight, buttonWidth, buttonHeight, 1, cornerRadius, true, true, false,
false, true);
const int tw = renderer.getTextWidth(SMALL_FONT_ID, btnLabels[i]);
const int tx = x + (buttonWidth - 1 - tw) / 2;
renderer.drawText(SMALL_FONT_ID, tx, portH - buttonHeight + textYOffset, btnLabels[i]);
} else {
renderer.drawRoundedRect(x, portH - smallButtonHeight, buttonWidth, smallButtonHeight, 1, cornerRadius, true,
true, false, false, true);
}
}
// Side buttons (custom-drawn with background, overlap hiding, truncation, and rotation)
const bool useCCW = (orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CCW);
for (int i = 0; i < 2; i++) {
if (hideSide[i]) continue;
if (sideLabels[i] == nullptr || sideLabels[i][0] == '\0') continue;
// Solid background
renderer.fillRect(sideX, sideButtonY[i], sideButtonWidth, sideButtonHeight, false);
// Outline (rounded on inner side, square on screen edge — matches theme)
renderer.drawRoundedRect(sideX, sideButtonY[i], sideButtonWidth, sideButtonHeight, 1, cornerRadius, true, false,
true, false, true);
// Truncate text if it would overflow the button height
const std::string truncated = renderer.truncatedText(SMALL_FONT_ID, sideLabels[i], sideButtonHeight);
const int tw = renderer.getTextWidth(SMALL_FONT_ID, truncated.c_str());
if (useCCW) {
// Text reads top-to-bottom (90° CCW rotation): y starts near top of button
renderer.drawTextRotated90CCW(SMALL_FONT_ID, sideX,
sideButtonY[i] + (sideButtonHeight - tw) / 2, truncated.c_str());
} else {
// Text reads bottom-to-top (90° CW rotation): y starts near bottom of button
renderer.drawTextRotated90CW(SMALL_FONT_ID, sideX,
sideButtonY[i] + (sideButtonHeight + tw) / 2, truncated.c_str());
}
}
renderer.setOrientation(origOrientation);
}