feat: UI themes, Lyra (#528)

## Summary

### What is the goal of this PR?

- Visual UI overhaul
- UI theme selection

### What changes are included?

- Added a setting "UI Theme": Classic, Lyra
- The classic theme is the current Crosspoint theme
- The Lyra theme implements these mockups:
https://www.figma.com/design/UhxoV4DgUnfrDQgMPPTXog/Lyra-Theme?node-id=2003-7596&t=4CSOZqf0n9uQMxDt-0
by Discord users yagofarias, ruby and gan_shu
- New functions in GFXRenderer to render rounded rectangles, greyscale
fills (using dithering) and thick lines
- Basic UI components are factored into BaseTheme methods which can be
overridden by each additional theme. Methods that are not overridden
will fallback to BaseTheme behavior. This means any new
features/components in CrossPoint only need to be developed for the
"Classic" BaseTheme.
- Additional themes can easily be developed by the community using this
foundation

![IMG_7649
Medium](https://github.com/user-attachments/assets/b516f5a9-2636-4565-acff-91a25b93b39b)
![IMG_7746
Medium](https://github.com/user-attachments/assets/def41810-ab6e-4952-b40f-b9ce7d62bea8)
![IMG_7651
Medium](https://github.com/user-attachments/assets/518a9a6d-107a-4be3-9533-43a2b64b944b)



## Additional Context

- Only the Home, Library and main Settings screens have been implemented
so far, this will be extended to the transfer screens and chapter
selection screen later on, but we need to get the ball rolling somehow
:)
- Loading extra covers on the home screen in the Lyra theme takes a
little more time (about 2 seconds), I added a loading bar popup (reusing
the Indexing progress bar from the reader view, factored into a neat UI
component) but the popup adds ~400ms to the loading time.
- ~~Home screen thumbnails will need to be generated separately for each
theme, because they are displayed in different sizes. Because we're
using dithering, displaying a thumb with the wrong size causes the
picture to look janky or dark as it does on the screenshots above. No
worries this will be fixed in a future PR.~~ Thumbs are now generated
with a size parameter
- UI Icons will need to be implemented in a future PR.

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**PARTIALLY**_
This is not a vibe coded PR. Copilot was used for autocompletion to save
time but I reviewed, understood and edited all generated code.

---------

Co-authored-by: Dave Allie <dave@daveallie.com>
This commit is contained in:
CaptainFrito
2026-02-05 17:50:11 +07:00
committed by GitHub
parent 2cf799f45b
commit bf87a7dc60
51 changed files with 2307 additions and 1397 deletions

View File

@@ -130,6 +130,12 @@ void GfxRenderer::drawLine(int x1, int y1, int x2, int y2, const bool state) con
}
}
void GfxRenderer::drawLine(int x1, int y1, int x2, int y2, const int lineWidth, const bool state) const {
for (int i = 0; i < lineWidth; i++) {
drawLine(x1, y1 + i, x2, y2 + i, state);
}
}
void GfxRenderer::drawRect(const int x, const int y, const int width, const int height, const bool state) const {
drawLine(x, y, x + width - 1, y, state);
drawLine(x + width - 1, y, x + width - 1, y + height - 1, state);
@@ -137,12 +143,215 @@ void GfxRenderer::drawRect(const int x, const int y, const int width, const int
drawLine(x, y, x, y + height - 1, state);
}
// Border is inside the rectangle
void GfxRenderer::drawRect(const int x, const int y, const int width, const int height, const int lineWidth,
const bool state) const {
for (int i = 0; i < lineWidth; i++) {
drawLine(x + i, y + i, x + width - i, y + i, state);
drawLine(x + width - i, y + i, x + width - i, y + height - i, state);
drawLine(x + width - i, y + height - i, x + i, y + height - i, state);
drawLine(x + i, y + height - i, x + i, y + i, state);
}
}
void GfxRenderer::drawArc(const int maxRadius, const int cx, const int cy, const int xDir, const int yDir,
const int lineWidth, const bool state) const {
const int stroke = std::min(lineWidth, maxRadius);
const int innerRadius = std::max(maxRadius - stroke, 0);
const int outerRadiusSq = maxRadius * maxRadius;
const int innerRadiusSq = innerRadius * innerRadius;
for (int dy = 0; dy <= maxRadius; ++dy) {
for (int dx = 0; dx <= maxRadius; ++dx) {
const int distSq = dx * dx + dy * dy;
if (distSq > outerRadiusSq || distSq < innerRadiusSq) {
continue;
}
const int px = cx + xDir * dx;
const int py = cy + yDir * dy;
drawPixel(px, py, state);
}
}
};
// Border is inside the rectangle, rounded corners
void GfxRenderer::drawRoundedRect(const int x, const int y, const int width, const int height, const int lineWidth,
const int cornerRadius, bool state) const {
drawRoundedRect(x, y, width, height, lineWidth, cornerRadius, true, true, true, true, state);
}
// Border is inside the rectangle, rounded corners
void GfxRenderer::drawRoundedRect(const int x, const int y, const int width, const int height, const int lineWidth,
const int cornerRadius, bool roundTopLeft, bool roundTopRight, bool roundBottomLeft,
bool roundBottomRight, bool state) const {
if (lineWidth <= 0 || width <= 0 || height <= 0) {
return;
}
const int maxRadius = std::min({cornerRadius, width / 2, height / 2});
if (maxRadius <= 0) {
drawRect(x, y, width, height, lineWidth, state);
return;
}
const int stroke = std::min(lineWidth, maxRadius);
const int right = x + width - 1;
const int bottom = y + height - 1;
const int horizontalWidth = width - 2 * maxRadius;
if (horizontalWidth > 0) {
if (roundTopLeft || roundTopRight) {
fillRect(x + maxRadius, y, horizontalWidth, stroke, state);
}
if (roundBottomLeft || roundBottomRight) {
fillRect(x + maxRadius, bottom - stroke + 1, horizontalWidth, stroke, state);
}
}
const int verticalHeight = height - 2 * maxRadius;
if (verticalHeight > 0) {
if (roundTopLeft || roundBottomLeft) {
fillRect(x, y + maxRadius, stroke, verticalHeight, state);
}
if (roundTopRight || roundBottomRight) {
fillRect(right - stroke + 1, y + maxRadius, stroke, verticalHeight, state);
}
}
if (roundTopLeft) {
drawArc(maxRadius, x + maxRadius, y + maxRadius, -1, -1, lineWidth, state);
}
if (roundTopRight) {
drawArc(maxRadius, right - maxRadius, y + maxRadius, 1, -1, lineWidth, state);
}
if (roundBottomRight) {
drawArc(maxRadius, right - maxRadius, bottom - maxRadius, 1, 1, lineWidth, state);
}
if (roundBottomLeft) {
drawArc(maxRadius, x + maxRadius, bottom - maxRadius, -1, 1, lineWidth, state);
}
}
void GfxRenderer::fillRect(const int x, const int y, const int width, const int height, const bool state) const {
for (int fillY = y; fillY < y + height; fillY++) {
drawLine(x, fillY, x + width - 1, fillY, state);
}
}
static constexpr uint8_t bayer4x4[4][4] = {
{0, 8, 2, 10},
{12, 4, 14, 6},
{3, 11, 1, 9},
{15, 7, 13, 5},
};
static constexpr int matrixSize = 4;
static constexpr int matrixLevels = matrixSize * matrixSize;
void GfxRenderer::drawPixelDither(const int x, const int y, Color color) const {
if (color == Color::Clear) {
} else if (color == Color::Black) {
drawPixel(x, y, true);
} else if (color == Color::White) {
drawPixel(x, y, false);
} else {
// Use dithering
const int greyLevel = static_cast<int>(color) - 1; // 0-15
const int normalizedGrey = (greyLevel * 255) / (matrixLevels - 1);
const int clampedGrey = std::max(0, std::min(normalizedGrey, 255));
const int threshold = (clampedGrey * (matrixLevels + 1)) / 256;
const int matrixX = x & (matrixSize - 1);
const int matrixY = y & (matrixSize - 1);
const uint8_t patternValue = bayer4x4[matrixY][matrixX];
const bool black = patternValue < threshold;
drawPixel(x, y, black);
}
}
// Use Bayer matrix 4x4 dithering to fill the rectangle with a grey level
void GfxRenderer::fillRectDither(const int x, const int y, const int width, const int height, Color color) const {
if (color == Color::Clear) {
} else if (color == Color::Black) {
fillRect(x, y, width, height, true);
} else if (color == Color::White) {
fillRect(x, y, width, height, false);
} else {
for (int fillY = y; fillY < y + height; fillY++) {
for (int fillX = x; fillX < x + width; fillX++) {
drawPixelDither(fillX, fillY, color);
}
}
}
}
void GfxRenderer::fillArc(const int maxRadius, const int cx, const int cy, const int xDir, const int yDir,
Color color) const {
const int radiusSq = maxRadius * maxRadius;
for (int dy = 0; dy <= maxRadius; ++dy) {
for (int dx = 0; dx <= maxRadius; ++dx) {
const int distSq = dx * dx + dy * dy;
const int px = cx + xDir * dx;
const int py = cy + yDir * dy;
if (distSq <= radiusSq) {
drawPixelDither(px, py, color);
}
}
}
}
void GfxRenderer::fillRoundedRect(const int x, const int y, const int width, const int height, const int cornerRadius,
const Color color) const {
fillRoundedRect(x, y, width, height, cornerRadius, true, true, true, true, color);
}
void GfxRenderer::fillRoundedRect(const int x, const int y, const int width, const int height, const int cornerRadius,
bool roundTopLeft, bool roundTopRight, bool roundBottomLeft, bool roundBottomRight,
const Color color) const {
if (width <= 0 || height <= 0) {
return;
}
const int maxRadius = std::min({cornerRadius, width / 2, height / 2});
if (maxRadius <= 0) {
fillRectDither(x, y, width, height, color);
return;
}
const int horizontalWidth = width - 2 * maxRadius;
if (horizontalWidth > 0) {
fillRectDither(x + maxRadius + 1, y, horizontalWidth - 2, height, color);
}
const int verticalHeight = height - 2 * maxRadius - 2;
if (verticalHeight > 0) {
fillRectDither(x, y + maxRadius + 1, maxRadius + 1, verticalHeight, color);
fillRectDither(x + width - maxRadius - 1, y + maxRadius + 1, maxRadius + 1, verticalHeight, color);
}
if (roundTopLeft) {
fillArc(maxRadius, x + maxRadius, y + maxRadius, -1, -1, color);
} else {
fillRectDither(x, y, maxRadius + 1, maxRadius + 1, color);
}
if (roundTopRight) {
fillArc(maxRadius, x + width - maxRadius - 1, y + maxRadius, 1, -1, color);
} else {
fillRectDither(x + width - maxRadius - 1, y, maxRadius + 1, maxRadius + 1, color);
}
if (roundBottomRight) {
fillArc(maxRadius, x + width - maxRadius - 1, y + height - maxRadius - 1, 1, 1, color);
} else {
fillRectDither(x + width - maxRadius - 1, y + height - maxRadius - 1, maxRadius + 1, maxRadius + 1, color);
}
if (roundBottomLeft) {
fillArc(maxRadius, x + maxRadius, y + height - maxRadius - 1, -1, 1, color);
} else {
fillRectDither(x, y + height - maxRadius - 1, maxRadius + 1, maxRadius + 1, color);
}
}
void GfxRenderer::drawImage(const uint8_t bitmap[], const int x, const int y, const int width, const int height) const {
int rotatedX = 0;
int rotatedY = 0;
@@ -166,6 +375,10 @@ void GfxRenderer::drawImage(const uint8_t bitmap[], const int x, const int y, co
display.drawImage(bitmap, rotatedX, rotatedY, width, height);
}
void GfxRenderer::drawIcon(const uint8_t bitmap[], const int x, const int y, const int width, const int height) const {
display.drawImage(bitmap, y, getScreenWidth() - width - x, height, width);
}
void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, const int maxWidth, const int maxHeight,
const float cropX, const float cropY) const {
// For 1-bit bitmaps, use optimized 1-bit rendering path (no crop support for 1-bit)
@@ -502,85 +715,6 @@ int GfxRenderer::getLineHeight(const int fontId) const {
return fontMap.at(fontId).getData(EpdFontFamily::REGULAR)->advanceY;
}
void GfxRenderer::drawButtonHints(const int fontId, const char* btn1, const char* btn2, const char* btn3,
const char* btn4) {
const Orientation orig_orientation = getOrientation();
setOrientation(Orientation::Portrait);
const int pageHeight = getScreenHeight();
constexpr int buttonWidth = 106;
constexpr int buttonHeight = 40;
constexpr int buttonY = 40; // Distance from bottom
constexpr int textYOffset = 7; // Distance from top of button to text baseline
constexpr int buttonPositions[] = {25, 130, 245, 350};
const char* labels[] = {btn1, btn2, btn3, btn4};
for (int i = 0; i < 4; i++) {
// Only draw if the label is non-empty
if (labels[i] != nullptr && labels[i][0] != '\0') {
const int x = buttonPositions[i];
fillRect(x, pageHeight - buttonY, buttonWidth, buttonHeight, false);
drawRect(x, pageHeight - buttonY, buttonWidth, buttonHeight);
const int textWidth = getTextWidth(fontId, labels[i]);
const int textX = x + (buttonWidth - 1 - textWidth) / 2;
drawText(fontId, textX, pageHeight - buttonY + textYOffset, labels[i]);
}
}
setOrientation(orig_orientation);
}
void GfxRenderer::drawSideButtonHints(const int fontId, const char* topBtn, const char* bottomBtn) const {
const int screenWidth = getScreenWidth();
constexpr int buttonWidth = 40; // Width on screen (height when rotated)
constexpr int buttonHeight = 80; // Height on screen (width when rotated)
constexpr int buttonX = 5; // Distance from right edge
// Position for the button group - buttons share a border so they're adjacent
constexpr int topButtonY = 345; // Top button position
const char* labels[] = {topBtn, bottomBtn};
// Draw the shared border for both buttons as one unit
const int x = screenWidth - buttonX - buttonWidth;
// Draw top button outline (3 sides, bottom open)
if (topBtn != nullptr && topBtn[0] != '\0') {
drawLine(x, topButtonY, x + buttonWidth - 1, topButtonY); // Top
drawLine(x, topButtonY, x, topButtonY + buttonHeight - 1); // Left
drawLine(x + buttonWidth - 1, topButtonY, x + buttonWidth - 1, topButtonY + buttonHeight - 1); // Right
}
// Draw shared middle border
if ((topBtn != nullptr && topBtn[0] != '\0') || (bottomBtn != nullptr && bottomBtn[0] != '\0')) {
drawLine(x, topButtonY + buttonHeight, x + buttonWidth - 1, topButtonY + buttonHeight); // Shared border
}
// Draw bottom button outline (3 sides, top is shared)
if (bottomBtn != nullptr && bottomBtn[0] != '\0') {
drawLine(x, topButtonY + buttonHeight, x, topButtonY + 2 * buttonHeight - 1); // Left
drawLine(x + buttonWidth - 1, topButtonY + buttonHeight, x + buttonWidth - 1,
topButtonY + 2 * buttonHeight - 1); // Right
drawLine(x, topButtonY + 2 * buttonHeight - 1, x + buttonWidth - 1, topButtonY + 2 * buttonHeight - 1); // Bottom
}
// Draw text for each button
for (int i = 0; i < 2; i++) {
if (labels[i] != nullptr && labels[i][0] != '\0') {
const int y = topButtonY + i * buttonHeight;
// Draw rotated text centered in the button
const int textWidth = getTextWidth(fontId, labels[i]);
const int textHeight = getTextHeight(fontId);
// Center the rotated text in the button
const int textX = x + (buttonWidth - textHeight) / 2;
const int textY = y + (buttonHeight + textWidth) / 2;
drawTextRotated90CW(fontId, textX, textY, labels[i]);
}
}
}
int GfxRenderer::getTextHeight(const int fontId) const {
if (fontMap.count(fontId) == 0) {
Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId);