feat: Expandable selected row for long filenames in File Browser
When the selected row's filename overflows the available text width
(with extension), the row expands to 2 lines with smart text wrapping.
The file extension moves to the second row (right-aligned). Non-selected
rows retain single-line truncation.
Key behaviors:
- 3-tier text wrapping: preferred delimiters (" - ", " -- ", en/em-dash),
word boundaries, then character-level fallback
- Row-height line spacing for natural visual rhythm
- Icons aligned with line 1 (LyraTheme)
- Pagination uses effectivePageItems with anti-leak clamping to prevent
page boundary shifts while ensuring all items remain accessible
- Boundary item duplication: items bumped from a page due to expansion
appear at the top of the next page, guarded against cascading
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -45,6 +45,105 @@ void drawBatteryIcon(const GfxRenderer& renderer, int x, int y, int battWidth, i
|
|||||||
|
|
||||||
renderer.fillRect(x + 2, y + 2, filledWidth, rectHeight - 4);
|
renderer.fillRect(x + 2, y + 2, filledWidth, rectHeight - 4);
|
||||||
}
|
}
|
||||||
|
// Truncate a string with "..." to fit within maxWidth.
|
||||||
|
std::string truncateWithEllipsis(const GfxRenderer& renderer, int fontId, const std::string& text, int maxWidth) {
|
||||||
|
std::string truncated = text;
|
||||||
|
std::string withEllipsis = truncated + "...";
|
||||||
|
while (!truncated.empty() && renderer.getTextWidth(fontId, withEllipsis.c_str()) > maxWidth) {
|
||||||
|
utf8RemoveLastChar(truncated);
|
||||||
|
withEllipsis = truncated + "...";
|
||||||
|
}
|
||||||
|
return truncated.empty() ? std::string("...") : withEllipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text wrapping with 3-tier break logic:
|
||||||
|
// 1) Preferred delimiters: " -- ", " - ", en-dash, em-dash (title-author separator)
|
||||||
|
// 2) Word boundaries: last space or hyphen that fits
|
||||||
|
// 3) Character-level fallback for long unbroken tokens
|
||||||
|
// The last allowed line is truncated with "..." if it overflows.
|
||||||
|
std::vector<std::string> wrapTextToLines(const GfxRenderer& renderer, int fontId, const std::string& text, int maxWidth,
|
||||||
|
int maxLines) {
|
||||||
|
std::vector<std::string> lines;
|
||||||
|
if (text.empty() || maxWidth <= 0 || maxLines <= 0) return lines;
|
||||||
|
|
||||||
|
if (renderer.getTextWidth(fontId, text.c_str()) <= maxWidth) {
|
||||||
|
lines.push_back(text);
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxLines == 1) {
|
||||||
|
lines.push_back(truncateWithEllipsis(renderer, fontId, text, maxWidth));
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier 1: Try preferred delimiters (last occurrence to maximize line 1 content).
|
||||||
|
// \xe2\x80\x93 = en-dash, \xe2\x80\x94 = em-dash
|
||||||
|
static const char* const preferredDelimiters[] = {" -- ", " - ", " \xe2\x80\x93 ", " \xe2\x80\x94 "};
|
||||||
|
for (const char* delim : preferredDelimiters) {
|
||||||
|
size_t delimLen = strlen(delim);
|
||||||
|
auto pos = text.rfind(delim);
|
||||||
|
if (pos != std::string::npos && pos > 0) {
|
||||||
|
std::string firstPart = text.substr(0, pos);
|
||||||
|
if (renderer.getTextWidth(fontId, firstPart.c_str()) <= maxWidth) {
|
||||||
|
lines.push_back(firstPart);
|
||||||
|
std::string remainder = text.substr(pos + delimLen);
|
||||||
|
if (renderer.getTextWidth(fontId, remainder.c_str()) > maxWidth) {
|
||||||
|
lines.push_back(truncateWithEllipsis(renderer, fontId, remainder, maxWidth));
|
||||||
|
} else {
|
||||||
|
lines.push_back(remainder);
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier 2 & 3: Word-boundary wrapping with character-level fallback.
|
||||||
|
std::string currentLine;
|
||||||
|
const unsigned char* ptr = reinterpret_cast<const unsigned char*>(text.c_str());
|
||||||
|
std::string lineAtBreak;
|
||||||
|
const unsigned char* ptrAtBreak = nullptr;
|
||||||
|
|
||||||
|
while (*ptr != 0) {
|
||||||
|
const unsigned char* charStart = ptr;
|
||||||
|
uint32_t cp = utf8NextCodepoint(&ptr);
|
||||||
|
std::string nextChar(reinterpret_cast<const char*>(charStart), static_cast<size_t>(ptr - charStart));
|
||||||
|
std::string candidate = currentLine + nextChar;
|
||||||
|
|
||||||
|
if (renderer.getTextWidth(fontId, candidate.c_str()) <= maxWidth) {
|
||||||
|
currentLine = candidate;
|
||||||
|
if (cp == ' ' || cp == '-') {
|
||||||
|
lineAtBreak = currentLine;
|
||||||
|
ptrAtBreak = ptr;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overflow
|
||||||
|
if (static_cast<int>(lines.size()) < maxLines - 1 && !currentLine.empty()) {
|
||||||
|
if (ptrAtBreak != nullptr) {
|
||||||
|
std::string line = lineAtBreak;
|
||||||
|
while (!line.empty() && line.back() == ' ') line.pop_back();
|
||||||
|
lines.push_back(line);
|
||||||
|
ptr = ptrAtBreak;
|
||||||
|
while (*ptr == ' ') ++ptr;
|
||||||
|
currentLine.clear();
|
||||||
|
} else {
|
||||||
|
lines.push_back(currentLine);
|
||||||
|
currentLine = nextChar;
|
||||||
|
}
|
||||||
|
lineAtBreak.clear();
|
||||||
|
ptrAtBreak = nullptr;
|
||||||
|
} else {
|
||||||
|
lines.push_back(truncateWithEllipsis(renderer, fontId, currentLine, maxWidth));
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentLine.empty()) {
|
||||||
|
lines.push_back(currentLine);
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
void BaseTheme::drawBatteryLeft(const GfxRenderer& renderer, Rect rect, const bool showPercentage) const {
|
void BaseTheme::drawBatteryLeft(const GfxRenderer& renderer, Rect rect, const bool showPercentage) const {
|
||||||
@@ -193,25 +292,36 @@ void BaseTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
|
|||||||
int rowHeight =
|
int rowHeight =
|
||||||
(rowSubtitle != nullptr) ? BaseMetrics::values.listWithSubtitleRowHeight : BaseMetrics::values.listRowHeight;
|
(rowSubtitle != nullptr) ? BaseMetrics::values.listWithSubtitleRowHeight : BaseMetrics::values.listRowHeight;
|
||||||
int pageItems = rect.height / rowHeight;
|
int pageItems = rect.height / rowHeight;
|
||||||
|
int contentWidth = rect.width - 5;
|
||||||
|
auto font = (rowSubtitle != nullptr) ? UI_12_FONT_ID : UI_10_FONT_ID;
|
||||||
|
|
||||||
const int totalPages = (itemCount + pageItems - 1) / pageItems;
|
// Detect if selected row's title overflows and needs 2-line expansion
|
||||||
|
bool selectedExpands = false;
|
||||||
|
if (selectedIndex >= 0 && rowSubtitle == nullptr && rowValue != nullptr) {
|
||||||
|
int titleTextWidth = contentWidth - BaseMetrics::values.contentSidePadding * 2 - 60;
|
||||||
|
auto selTitle = rowTitle(selectedIndex);
|
||||||
|
if (renderer.getTextWidth(font, selTitle.c_str()) > titleTextWidth) {
|
||||||
|
selectedExpands = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const int effectivePageItems = selectedExpands ? std::max(1, pageItems - 1) : pageItems;
|
||||||
|
const int totalPages = (itemCount + effectivePageItems - 1) / effectivePageItems;
|
||||||
if (totalPages > 1) {
|
if (totalPages > 1) {
|
||||||
constexpr int indicatorWidth = 20;
|
constexpr int indicatorWidth = 20;
|
||||||
constexpr int arrowSize = 6;
|
constexpr int arrowSize = 6;
|
||||||
constexpr int margin = 15; // Offset from right edge
|
constexpr int margin = 15;
|
||||||
|
|
||||||
const int centerX = rect.x + rect.width - indicatorWidth / 2 - margin;
|
const int centerX = rect.x + rect.width - indicatorWidth / 2 - margin;
|
||||||
const int indicatorTop = rect.y; // Offset to avoid overlapping side button hints
|
const int indicatorTop = rect.y;
|
||||||
const int indicatorBottom = rect.y + rect.height - arrowSize;
|
const int indicatorBottom = rect.y + rect.height - arrowSize;
|
||||||
|
|
||||||
// Draw up arrow at top (^) - narrow point at top, wide base at bottom
|
|
||||||
for (int i = 0; i < arrowSize; ++i) {
|
for (int i = 0; i < arrowSize; ++i) {
|
||||||
const int lineWidth = 1 + i * 2;
|
const int lineWidth = 1 + i * 2;
|
||||||
const int startX = centerX - i;
|
const int startX = centerX - i;
|
||||||
renderer.drawLine(startX, indicatorTop + i, startX + lineWidth - 1, indicatorTop + i);
|
renderer.drawLine(startX, indicatorTop + i, startX + lineWidth - 1, indicatorTop + i);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw down arrow at bottom (v) - wide base at top, narrow point at bottom
|
|
||||||
for (int i = 0; i < arrowSize; ++i) {
|
for (int i = 0; i < arrowSize; ++i) {
|
||||||
const int lineWidth = 1 + (arrowSize - 1 - i) * 2;
|
const int lineWidth = 1 + (arrowSize - 1 - i) * 2;
|
||||||
const int startX = centerX - (arrowSize - 1 - i);
|
const int startX = centerX - (arrowSize - 1 - i);
|
||||||
@@ -220,37 +330,89 @@ void BaseTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw selection
|
// Compute page start: use effective page items but prevent backward leak
|
||||||
int contentWidth = rect.width - 5;
|
int pageStartIndex;
|
||||||
if (selectedIndex >= 0) {
|
if (selectedExpands) {
|
||||||
renderer.fillRect(0, rect.y + selectedIndex % pageItems * rowHeight - 2, rect.width, rowHeight);
|
int rawStart = selectedIndex / effectivePageItems * effectivePageItems;
|
||||||
}
|
int originalStart = selectedIndex / pageItems * pageItems;
|
||||||
// Draw all items
|
pageStartIndex = std::max(rawStart, originalStart);
|
||||||
const auto pageStartIndex = selectedIndex / pageItems * pageItems;
|
if (selectedIndex >= pageStartIndex + effectivePageItems) {
|
||||||
for (int i = pageStartIndex; i < itemCount && i < pageStartIndex + pageItems; i++) {
|
pageStartIndex = selectedIndex - effectivePageItems + 1;
|
||||||
const int itemY = rect.y + (i % pageItems) * rowHeight;
|
|
||||||
int textWidth = contentWidth - BaseMetrics::values.contentSidePadding * 2 - (rowValue != nullptr ? 60 : 0);
|
|
||||||
|
|
||||||
// Draw name
|
|
||||||
auto itemName = rowTitle(i);
|
|
||||||
auto font = (rowSubtitle != nullptr) ? UI_12_FONT_ID : UI_10_FONT_ID;
|
|
||||||
auto item = renderer.truncatedText(font, itemName.c_str(), textWidth);
|
|
||||||
renderer.drawText(font, rect.x + BaseMetrics::values.contentSidePadding, itemY, item.c_str(), i != selectedIndex);
|
|
||||||
|
|
||||||
if (rowSubtitle != nullptr) {
|
|
||||||
// Draw subtitle
|
|
||||||
std::string subtitleText = rowSubtitle(i);
|
|
||||||
auto subtitle = renderer.truncatedText(UI_10_FONT_ID, subtitleText.c_str(), textWidth);
|
|
||||||
renderer.drawText(UI_10_FONT_ID, rect.x + BaseMetrics::values.contentSidePadding, itemY + 30, subtitle.c_str(),
|
|
||||||
i != selectedIndex);
|
|
||||||
}
|
}
|
||||||
|
if (pageStartIndex > 0 && pageStartIndex == originalStart
|
||||||
|
&& selectedIndex < pageStartIndex + effectivePageItems - 1) {
|
||||||
|
int checkWidth = contentWidth - BaseMetrics::values.contentSidePadding * 2 - 60;
|
||||||
|
auto prevTitle = rowTitle(pageStartIndex - 1);
|
||||||
|
if (renderer.getTextWidth(font, prevTitle.c_str()) > checkWidth) {
|
||||||
|
pageStartIndex--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pageStartIndex = selectedIndex / pageItems * pageItems;
|
||||||
|
// Include previous page's boundary item if it would need expansion when selected,
|
||||||
|
// so it doesn't vanish when navigating from it to the current page.
|
||||||
|
if (pageStartIndex > 0 && selectedIndex < pageStartIndex + pageItems - 1) {
|
||||||
|
int checkWidth = contentWidth - BaseMetrics::values.contentSidePadding * 2 - 60;
|
||||||
|
auto prevTitle = rowTitle(pageStartIndex - 1);
|
||||||
|
if (renderer.getTextWidth(font, prevTitle.c_str()) > checkWidth) {
|
||||||
|
pageStartIndex--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (rowValue != nullptr) {
|
// Draw selection highlight
|
||||||
// Draw value
|
if (selectedIndex >= 0) {
|
||||||
std::string valueText = rowValue(i);
|
int selRowsBeforeOnPage = selectedIndex - pageStartIndex;
|
||||||
const auto valueTextWidth = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str());
|
int selY = rect.y + selRowsBeforeOnPage * rowHeight - 2;
|
||||||
renderer.drawText(UI_10_FONT_ID, rect.x + contentWidth - BaseMetrics::values.contentSidePadding - valueTextWidth,
|
int selHeight = selectedExpands ? 2 * rowHeight : rowHeight;
|
||||||
itemY, valueText.c_str(), i != selectedIndex);
|
renderer.fillRect(0, selY, rect.width, selHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw all items
|
||||||
|
int yPos = rect.y;
|
||||||
|
for (int i = pageStartIndex; i < itemCount && i < pageStartIndex + effectivePageItems; i++) {
|
||||||
|
const bool isExpanded = (selectedExpands && i == selectedIndex);
|
||||||
|
|
||||||
|
auto itemName = rowTitle(i);
|
||||||
|
|
||||||
|
if (isExpanded) {
|
||||||
|
int wrapWidth = contentWidth - BaseMetrics::values.contentSidePadding * 2;
|
||||||
|
auto lines = wrapTextToLines(renderer, font, itemName, wrapWidth, 2);
|
||||||
|
|
||||||
|
for (size_t l = 0; l < lines.size(); ++l) {
|
||||||
|
renderer.drawText(font, rect.x + BaseMetrics::values.contentSidePadding,
|
||||||
|
yPos + static_cast<int>(l) * rowHeight, lines[l].c_str(), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rowValue != nullptr) {
|
||||||
|
std::string valueText = rowValue(i);
|
||||||
|
const auto valueTextWidth = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str());
|
||||||
|
renderer.drawText(UI_10_FONT_ID,
|
||||||
|
rect.x + contentWidth - BaseMetrics::values.contentSidePadding - valueTextWidth,
|
||||||
|
yPos + rowHeight, valueText.c_str(), false);
|
||||||
|
}
|
||||||
|
yPos += 2 * rowHeight;
|
||||||
|
} else {
|
||||||
|
int textWidth = contentWidth - BaseMetrics::values.contentSidePadding * 2 - (rowValue != nullptr ? 60 : 0);
|
||||||
|
auto item = renderer.truncatedText(font, itemName.c_str(), textWidth);
|
||||||
|
renderer.drawText(font, rect.x + BaseMetrics::values.contentSidePadding, yPos, item.c_str(),
|
||||||
|
i != selectedIndex);
|
||||||
|
|
||||||
|
if (rowSubtitle != nullptr) {
|
||||||
|
std::string subtitleText = rowSubtitle(i);
|
||||||
|
auto subtitle = renderer.truncatedText(UI_10_FONT_ID, subtitleText.c_str(), textWidth);
|
||||||
|
renderer.drawText(UI_10_FONT_ID, rect.x + BaseMetrics::values.contentSidePadding, yPos + 30,
|
||||||
|
subtitle.c_str(), i != selectedIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rowValue != nullptr) {
|
||||||
|
std::string valueText = rowValue(i);
|
||||||
|
const auto valueTextWidth = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str());
|
||||||
|
renderer.drawText(UI_10_FONT_ID,
|
||||||
|
rect.x + contentWidth - BaseMetrics::values.contentSidePadding - valueTextWidth, yPos,
|
||||||
|
valueText.c_str(), i != selectedIndex);
|
||||||
|
}
|
||||||
|
yPos += rowHeight;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,6 +84,95 @@ const uint8_t* iconForName(UIIcon icon, int size) {
|
|||||||
}
|
}
|
||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
|
std::string truncateWithEllipsis(const GfxRenderer& renderer, int fontId, const std::string& text, int maxWidth) {
|
||||||
|
std::string truncated = text;
|
||||||
|
std::string withEllipsis = truncated + "...";
|
||||||
|
while (!truncated.empty() && renderer.getTextWidth(fontId, withEllipsis.c_str()) > maxWidth) {
|
||||||
|
utf8RemoveLastChar(truncated);
|
||||||
|
withEllipsis = truncated + "...";
|
||||||
|
}
|
||||||
|
return truncated.empty() ? std::string("...") : withEllipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<std::string> wrapTextToLines(const GfxRenderer& renderer, int fontId, const std::string& text, int maxWidth,
|
||||||
|
int maxLines) {
|
||||||
|
std::vector<std::string> lines;
|
||||||
|
if (text.empty() || maxWidth <= 0 || maxLines <= 0) return lines;
|
||||||
|
|
||||||
|
if (renderer.getTextWidth(fontId, text.c_str()) <= maxWidth) {
|
||||||
|
lines.push_back(text);
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxLines == 1) {
|
||||||
|
lines.push_back(truncateWithEllipsis(renderer, fontId, text, maxWidth));
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
static const char* const preferredDelimiters[] = {" -- ", " - ", " \xe2\x80\x93 ", " \xe2\x80\x94 "};
|
||||||
|
for (const char* delim : preferredDelimiters) {
|
||||||
|
size_t delimLen = strlen(delim);
|
||||||
|
auto pos = text.rfind(delim);
|
||||||
|
if (pos != std::string::npos && pos > 0) {
|
||||||
|
std::string firstPart = text.substr(0, pos);
|
||||||
|
if (renderer.getTextWidth(fontId, firstPart.c_str()) <= maxWidth) {
|
||||||
|
lines.push_back(firstPart);
|
||||||
|
std::string remainder = text.substr(pos + delimLen);
|
||||||
|
if (renderer.getTextWidth(fontId, remainder.c_str()) > maxWidth) {
|
||||||
|
lines.push_back(truncateWithEllipsis(renderer, fontId, remainder, maxWidth));
|
||||||
|
} else {
|
||||||
|
lines.push_back(remainder);
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string currentLine;
|
||||||
|
const unsigned char* ptr = reinterpret_cast<const unsigned char*>(text.c_str());
|
||||||
|
std::string lineAtBreak;
|
||||||
|
const unsigned char* ptrAtBreak = nullptr;
|
||||||
|
|
||||||
|
while (*ptr != 0) {
|
||||||
|
const unsigned char* charStart = ptr;
|
||||||
|
uint32_t cp = utf8NextCodepoint(&ptr);
|
||||||
|
std::string nextChar(reinterpret_cast<const char*>(charStart), static_cast<size_t>(ptr - charStart));
|
||||||
|
std::string candidate = currentLine + nextChar;
|
||||||
|
|
||||||
|
if (renderer.getTextWidth(fontId, candidate.c_str()) <= maxWidth) {
|
||||||
|
currentLine = candidate;
|
||||||
|
if (cp == ' ' || cp == '-') {
|
||||||
|
lineAtBreak = currentLine;
|
||||||
|
ptrAtBreak = ptr;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (static_cast<int>(lines.size()) < maxLines - 1 && !currentLine.empty()) {
|
||||||
|
if (ptrAtBreak != nullptr) {
|
||||||
|
std::string line = lineAtBreak;
|
||||||
|
while (!line.empty() && line.back() == ' ') line.pop_back();
|
||||||
|
lines.push_back(line);
|
||||||
|
ptr = ptrAtBreak;
|
||||||
|
while (*ptr == ' ') ++ptr;
|
||||||
|
currentLine.clear();
|
||||||
|
} else {
|
||||||
|
lines.push_back(currentLine);
|
||||||
|
currentLine = nextChar;
|
||||||
|
}
|
||||||
|
lineAtBreak.clear();
|
||||||
|
ptrAtBreak = nullptr;
|
||||||
|
} else {
|
||||||
|
lines.push_back(truncateWithEllipsis(renderer, fontId, currentLine, maxWidth));
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentLine.empty()) {
|
||||||
|
lines.push_back(currentLine);
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
void LyraTheme::drawBatteryLeft(const GfxRenderer& renderer, Rect rect, const bool showPercentage) const {
|
void LyraTheme::drawBatteryLeft(const GfxRenderer& renderer, Rect rect, const bool showPercentage) const {
|
||||||
@@ -278,13 +367,35 @@ void LyraTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
|
|||||||
(rowSubtitle != nullptr) ? LyraMetrics::values.listWithSubtitleRowHeight : LyraMetrics::values.listRowHeight;
|
(rowSubtitle != nullptr) ? LyraMetrics::values.listWithSubtitleRowHeight : LyraMetrics::values.listRowHeight;
|
||||||
int pageItems = rect.height / rowHeight;
|
int pageItems = rect.height / rowHeight;
|
||||||
|
|
||||||
const int totalPages = (itemCount + pageItems - 1) / pageItems;
|
// Detect if selected row's title overflows and needs 2-line expansion
|
||||||
|
bool selectedExpands = false;
|
||||||
|
if (selectedIndex >= 0 && rowSubtitle == nullptr && rowValue != nullptr) {
|
||||||
|
int prelTotalPages = (itemCount + pageItems - 1) / pageItems;
|
||||||
|
int prelContentWidth =
|
||||||
|
rect.width -
|
||||||
|
(prelTotalPages > 1 ? (LyraMetrics::values.scrollBarWidth + LyraMetrics::values.scrollBarRightOffset) : 1);
|
||||||
|
int prelTextWidth = prelContentWidth - LyraMetrics::values.contentSidePadding * 2 - hPaddingInSelection * 2;
|
||||||
|
if (rowIcon != nullptr) prelTextWidth -= listIconSize + hPaddingInSelection;
|
||||||
|
|
||||||
|
auto selTitle = rowTitle(selectedIndex);
|
||||||
|
auto selValue = rowValue(selectedIndex);
|
||||||
|
int selValueWidth = 0;
|
||||||
|
if (!selValue.empty()) {
|
||||||
|
selValue = renderer.truncatedText(UI_10_FONT_ID, selValue.c_str(), maxListValueWidth);
|
||||||
|
selValueWidth = renderer.getTextWidth(UI_10_FONT_ID, selValue.c_str()) + hPaddingInSelection;
|
||||||
|
}
|
||||||
|
if (renderer.getTextWidth(UI_10_FONT_ID, selTitle.c_str()) > prelTextWidth - selValueWidth) {
|
||||||
|
selectedExpands = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const int effectivePageItems = selectedExpands ? std::max(1, pageItems - 1) : pageItems;
|
||||||
|
const int totalPages = (itemCount + effectivePageItems - 1) / effectivePageItems;
|
||||||
if (totalPages > 1) {
|
if (totalPages > 1) {
|
||||||
const int scrollAreaHeight = rect.height;
|
const int scrollAreaHeight = rect.height;
|
||||||
|
|
||||||
// Draw scroll bar
|
const int scrollBarHeight = (scrollAreaHeight * effectivePageItems) / itemCount;
|
||||||
const int scrollBarHeight = (scrollAreaHeight * pageItems) / itemCount;
|
const int currentPage = selectedIndex / effectivePageItems;
|
||||||
const int currentPage = selectedIndex / pageItems;
|
|
||||||
const int scrollBarY = rect.y + ((scrollAreaHeight - scrollBarHeight) * currentPage) / (totalPages - 1);
|
const int scrollBarY = rect.y + ((scrollAreaHeight - scrollBarHeight) * currentPage) / (totalPages - 1);
|
||||||
const int scrollBarX = rect.x + rect.width - LyraMetrics::values.scrollBarRightOffset;
|
const int scrollBarX = rect.x + rect.width - LyraMetrics::values.scrollBarRightOffset;
|
||||||
renderer.drawLine(scrollBarX, rect.y, scrollBarX, rect.y + scrollAreaHeight, true);
|
renderer.drawLine(scrollBarX, rect.y, scrollBarX, rect.y + scrollAreaHeight, true);
|
||||||
@@ -292,19 +403,71 @@ void LyraTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
|
|||||||
scrollBarHeight, true);
|
scrollBarHeight, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw selection
|
|
||||||
int contentWidth =
|
int contentWidth =
|
||||||
rect.width -
|
rect.width -
|
||||||
(totalPages > 1 ? (LyraMetrics::values.scrollBarWidth + LyraMetrics::values.scrollBarRightOffset) : 1);
|
(totalPages > 1 ? (LyraMetrics::values.scrollBarWidth + LyraMetrics::values.scrollBarRightOffset) : 1);
|
||||||
|
|
||||||
|
// Compute page start: use effective page items but prevent backward leak
|
||||||
|
int pageStartIndex;
|
||||||
|
if (selectedExpands) {
|
||||||
|
int rawStart = selectedIndex / effectivePageItems * effectivePageItems;
|
||||||
|
int originalStart = selectedIndex / pageItems * pageItems;
|
||||||
|
pageStartIndex = std::max(rawStart, originalStart);
|
||||||
|
if (selectedIndex >= pageStartIndex + effectivePageItems) {
|
||||||
|
pageStartIndex = selectedIndex - effectivePageItems + 1;
|
||||||
|
}
|
||||||
|
if (pageStartIndex > 0 && pageStartIndex == originalStart
|
||||||
|
&& selectedIndex < pageStartIndex + effectivePageItems - 1) {
|
||||||
|
int checkTextWidth = contentWidth - LyraMetrics::values.contentSidePadding * 2 - hPaddingInSelection * 2;
|
||||||
|
if (rowIcon != nullptr) checkTextWidth -= listIconSize + hPaddingInSelection;
|
||||||
|
auto prevTitle = rowTitle(pageStartIndex - 1);
|
||||||
|
int prevValueWidth = 0;
|
||||||
|
if (rowValue != nullptr) {
|
||||||
|
auto prevValue = rowValue(pageStartIndex - 1);
|
||||||
|
prevValue = renderer.truncatedText(UI_10_FONT_ID, prevValue.c_str(), maxListValueWidth);
|
||||||
|
if (!prevValue.empty()) {
|
||||||
|
prevValueWidth = renderer.getTextWidth(UI_10_FONT_ID, prevValue.c_str()) + hPaddingInSelection;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (renderer.getTextWidth(UI_10_FONT_ID, prevTitle.c_str()) > checkTextWidth - prevValueWidth) {
|
||||||
|
pageStartIndex--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pageStartIndex = selectedIndex / pageItems * pageItems;
|
||||||
|
// Include previous page's boundary item if it would need expansion when selected,
|
||||||
|
// so it doesn't vanish when navigating from it to the current page.
|
||||||
|
if (pageStartIndex > 0 && selectedIndex < pageStartIndex + pageItems - 1) {
|
||||||
|
int checkTextWidth = contentWidth - LyraMetrics::values.contentSidePadding * 2 - hPaddingInSelection * 2;
|
||||||
|
if (rowIcon != nullptr) checkTextWidth -= listIconSize + hPaddingInSelection;
|
||||||
|
auto prevTitle = rowTitle(pageStartIndex - 1);
|
||||||
|
int prevValueWidth = 0;
|
||||||
|
if (rowValue != nullptr) {
|
||||||
|
auto prevValue = rowValue(pageStartIndex - 1);
|
||||||
|
prevValue = renderer.truncatedText(UI_10_FONT_ID, prevValue.c_str(), maxListValueWidth);
|
||||||
|
if (!prevValue.empty()) {
|
||||||
|
prevValueWidth = renderer.getTextWidth(UI_10_FONT_ID, prevValue.c_str()) + hPaddingInSelection;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (renderer.getTextWidth(UI_10_FONT_ID, prevTitle.c_str()) > checkTextWidth - prevValueWidth) {
|
||||||
|
pageStartIndex--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw selection highlight
|
||||||
if (selectedIndex >= 0) {
|
if (selectedIndex >= 0) {
|
||||||
renderer.fillRoundedRect(LyraMetrics::values.contentSidePadding, rect.y + selectedIndex % pageItems * rowHeight,
|
int selRowsBeforeOnPage = selectedIndex - pageStartIndex;
|
||||||
contentWidth - LyraMetrics::values.contentSidePadding * 2, rowHeight, cornerRadius,
|
int selY = rect.y + selRowsBeforeOnPage * rowHeight;
|
||||||
|
int selHeight = selectedExpands ? 2 * rowHeight : rowHeight;
|
||||||
|
renderer.fillRoundedRect(LyraMetrics::values.contentSidePadding, selY,
|
||||||
|
contentWidth - LyraMetrics::values.contentSidePadding * 2, selHeight, cornerRadius,
|
||||||
Color::LightGray);
|
Color::LightGray);
|
||||||
}
|
}
|
||||||
|
|
||||||
int textX = rect.x + LyraMetrics::values.contentSidePadding + hPaddingInSelection;
|
int textX = rect.x + LyraMetrics::values.contentSidePadding + hPaddingInSelection;
|
||||||
int textWidth = contentWidth - LyraMetrics::values.contentSidePadding * 2 - hPaddingInSelection * 2;
|
int textWidth = contentWidth - LyraMetrics::values.contentSidePadding * 2 - hPaddingInSelection * 2;
|
||||||
int iconSize;
|
int iconSize = listIconSize;
|
||||||
if (rowIcon != nullptr) {
|
if (rowIcon != nullptr) {
|
||||||
iconSize = (rowSubtitle != nullptr) ? mainMenuIconSize : listIconSize;
|
iconSize = (rowSubtitle != nullptr) ? mainMenuIconSize : listIconSize;
|
||||||
textX += iconSize + hPaddingInSelection;
|
textX += iconSize + hPaddingInSelection;
|
||||||
@@ -312,52 +475,78 @@ void LyraTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Draw all items
|
// Draw all items
|
||||||
const auto pageStartIndex = selectedIndex / pageItems * pageItems;
|
|
||||||
int iconY = (rowSubtitle != nullptr) ? 16 : 10;
|
int iconY = (rowSubtitle != nullptr) ? 16 : 10;
|
||||||
for (int i = pageStartIndex; i < itemCount && i < pageStartIndex + pageItems; i++) {
|
int yPos = rect.y;
|
||||||
const int itemY = rect.y + (i % pageItems) * rowHeight;
|
for (int i = pageStartIndex; i < itemCount && i < pageStartIndex + effectivePageItems; i++) {
|
||||||
int rowTextWidth = textWidth;
|
const bool isExpanded = (selectedExpands && i == selectedIndex);
|
||||||
|
|
||||||
// Draw name
|
|
||||||
int valueWidth = 0;
|
int valueWidth = 0;
|
||||||
std::string valueText = "";
|
std::string valueText;
|
||||||
if (rowValue != nullptr) {
|
if (rowValue != nullptr) {
|
||||||
valueText = rowValue(i);
|
valueText = rowValue(i);
|
||||||
valueText = renderer.truncatedText(UI_10_FONT_ID, valueText.c_str(), maxListValueWidth);
|
valueText = renderer.truncatedText(UI_10_FONT_ID, valueText.c_str(), maxListValueWidth);
|
||||||
valueWidth = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str()) + hPaddingInSelection;
|
if (!valueText.empty()) {
|
||||||
rowTextWidth -= valueWidth;
|
valueWidth = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str()) + hPaddingInSelection;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
auto itemName = rowTitle(i);
|
auto itemName = rowTitle(i);
|
||||||
auto item = renderer.truncatedText(UI_10_FONT_ID, itemName.c_str(), rowTextWidth);
|
|
||||||
renderer.drawText(UI_10_FONT_ID, textX, itemY + 7, item.c_str(), true);
|
|
||||||
|
|
||||||
if (rowIcon != nullptr) {
|
if (isExpanded) {
|
||||||
UIIcon icon = rowIcon(i);
|
int wrapWidth = textWidth;
|
||||||
const uint8_t* iconBitmap = iconForName(icon, iconSize);
|
auto lines = wrapTextToLines(renderer, UI_10_FONT_ID, itemName, wrapWidth, 2);
|
||||||
if (iconBitmap != nullptr) {
|
|
||||||
renderer.drawIcon(iconBitmap, rect.x + LyraMetrics::values.contentSidePadding + hPaddingInSelection,
|
|
||||||
itemY + iconY, iconSize, iconSize);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rowSubtitle != nullptr) {
|
for (size_t l = 0; l < lines.size(); ++l) {
|
||||||
// Draw subtitle
|
renderer.drawText(UI_10_FONT_ID, textX, yPos + 7 + static_cast<int>(l) * rowHeight, lines[l].c_str(), true);
|
||||||
std::string subtitleText = rowSubtitle(i);
|
|
||||||
auto subtitle = renderer.truncatedText(SMALL_FONT_ID, subtitleText.c_str(), rowTextWidth);
|
|
||||||
renderer.drawText(SMALL_FONT_ID, textX, itemY + 30, subtitle.c_str(), true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw value
|
|
||||||
if (!valueText.empty()) {
|
|
||||||
if (i == selectedIndex && highlightValue) {
|
|
||||||
renderer.fillRoundedRect(
|
|
||||||
contentWidth - LyraMetrics::values.contentSidePadding - hPaddingInSelection - valueWidth, itemY,
|
|
||||||
valueWidth + hPaddingInSelection, rowHeight, cornerRadius, Color::Black);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
renderer.drawText(UI_10_FONT_ID, rect.x + contentWidth - LyraMetrics::values.contentSidePadding - valueWidth,
|
if (rowIcon != nullptr) {
|
||||||
itemY + 6, valueText.c_str(), !(i == selectedIndex && highlightValue));
|
UIIcon icon = rowIcon(i);
|
||||||
|
const uint8_t* iconBitmap = iconForName(icon, iconSize);
|
||||||
|
if (iconBitmap != nullptr) {
|
||||||
|
renderer.drawIcon(iconBitmap, rect.x + LyraMetrics::values.contentSidePadding + hPaddingInSelection,
|
||||||
|
yPos + iconY, iconSize, iconSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!valueText.empty()) {
|
||||||
|
renderer.drawText(UI_10_FONT_ID,
|
||||||
|
rect.x + contentWidth - LyraMetrics::values.contentSidePadding - valueWidth,
|
||||||
|
yPos + rowHeight + 7, valueText.c_str(), true);
|
||||||
|
}
|
||||||
|
yPos += 2 * rowHeight;
|
||||||
|
} else {
|
||||||
|
int rowTextWidth = textWidth - valueWidth;
|
||||||
|
|
||||||
|
auto item = renderer.truncatedText(UI_10_FONT_ID, itemName.c_str(), rowTextWidth);
|
||||||
|
renderer.drawText(UI_10_FONT_ID, textX, yPos + 7, item.c_str(), true);
|
||||||
|
|
||||||
|
if (rowIcon != nullptr) {
|
||||||
|
UIIcon icon = rowIcon(i);
|
||||||
|
const uint8_t* iconBitmap = iconForName(icon, iconSize);
|
||||||
|
if (iconBitmap != nullptr) {
|
||||||
|
renderer.drawIcon(iconBitmap, rect.x + LyraMetrics::values.contentSidePadding + hPaddingInSelection,
|
||||||
|
yPos + iconY, iconSize, iconSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rowSubtitle != nullptr) {
|
||||||
|
std::string subtitleText = rowSubtitle(i);
|
||||||
|
auto subtitle = renderer.truncatedText(SMALL_FONT_ID, subtitleText.c_str(), rowTextWidth);
|
||||||
|
renderer.drawText(SMALL_FONT_ID, textX, yPos + 30, subtitle.c_str(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!valueText.empty()) {
|
||||||
|
if (i == selectedIndex && highlightValue) {
|
||||||
|
renderer.fillRoundedRect(
|
||||||
|
contentWidth - LyraMetrics::values.contentSidePadding - hPaddingInSelection - valueWidth, yPos,
|
||||||
|
valueWidth + hPaddingInSelection, rowHeight, cornerRadius, Color::Black);
|
||||||
|
}
|
||||||
|
renderer.drawText(UI_10_FONT_ID,
|
||||||
|
rect.x + contentWidth - LyraMetrics::values.contentSidePadding - valueWidth, yPos + 6,
|
||||||
|
valueText.c_str(), !(i == selectedIndex && highlightValue));
|
||||||
|
}
|
||||||
|
yPos += rowHeight;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user