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



## 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>
2026-02-05 17:50:11 +07:00
|
|
|
#include "LyraTheme.h"
|
|
|
|
|
|
|
|
|
|
#include <GfxRenderer.h>
|
2026-02-08 21:29:14 +01:00
|
|
|
#include <HalStorage.h>
|
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



## 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>
2026-02-05 17:50:11 +07:00
|
|
|
|
|
|
|
|
#include <cstdint>
|
|
|
|
|
#include <string>
|
|
|
|
|
|
|
|
|
|
#include "Battery.h"
|
|
|
|
|
#include "RecentBooksStore.h"
|
|
|
|
|
#include "components/UITheme.h"
|
|
|
|
|
#include "fontIds.h"
|
|
|
|
|
#include "util/StringUtils.h"
|
|
|
|
|
|
|
|
|
|
// Internal constants
|
|
|
|
|
namespace {
|
|
|
|
|
constexpr int batteryPercentSpacing = 4;
|
|
|
|
|
constexpr int hPaddingInSelection = 8;
|
|
|
|
|
constexpr int cornerRadius = 6;
|
|
|
|
|
constexpr int topHintButtonY = 345;
|
|
|
|
|
} // namespace
|
|
|
|
|
|
2026-02-16 14:36:36 +01:00
|
|
|
void LyraTheme::drawBatteryLeft(const GfxRenderer& renderer, Rect rect, const bool showPercentage) const {
|
|
|
|
|
// Left aligned: icon on left, percentage on right (reader mode)
|
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



## 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>
2026-02-05 17:50:11 +07:00
|
|
|
const uint16_t percentage = battery.readPercentage();
|
2026-02-16 14:36:36 +01:00
|
|
|
const int y = rect.y + 6;
|
|
|
|
|
const int battWidth = LyraMetrics::values.batteryWidth;
|
|
|
|
|
|
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



## 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>
2026-02-05 17:50:11 +07:00
|
|
|
if (showPercentage) {
|
|
|
|
|
const auto percentageText = std::to_string(percentage) + "%";
|
2026-02-16 14:36:36 +01:00
|
|
|
renderer.drawText(SMALL_FONT_ID, rect.x + batteryPercentSpacing + battWidth, rect.y, percentageText.c_str());
|
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



## 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>
2026-02-05 17:50:11 +07:00
|
|
|
}
|
2026-02-16 14:36:36 +01:00
|
|
|
|
|
|
|
|
// Draw icon
|
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



## 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>
2026-02-05 17:50:11 +07:00
|
|
|
const int x = rect.x;
|
2026-02-16 14:36:36 +01:00
|
|
|
// Top line
|
|
|
|
|
renderer.drawLine(x + 1, y, x + battWidth - 3, y);
|
|
|
|
|
// Bottom line
|
|
|
|
|
renderer.drawLine(x + 1, y + rect.height - 1, x + battWidth - 3, y + rect.height - 1);
|
|
|
|
|
// Left line
|
|
|
|
|
renderer.drawLine(x, y + 1, x, y + rect.height - 2);
|
|
|
|
|
// Battery end
|
|
|
|
|
renderer.drawLine(x + battWidth - 2, y + 1, x + battWidth - 2, y + rect.height - 2);
|
|
|
|
|
renderer.drawPixel(x + battWidth - 1, y + 3);
|
|
|
|
|
renderer.drawPixel(x + battWidth - 1, y + rect.height - 4);
|
|
|
|
|
renderer.drawLine(x + battWidth - 0, y + 4, x + battWidth - 0, y + rect.height - 5);
|
|
|
|
|
|
|
|
|
|
// Draw bars
|
|
|
|
|
if (percentage > 10) {
|
|
|
|
|
renderer.fillRect(x + 2, y + 2, 3, rect.height - 4);
|
|
|
|
|
}
|
|
|
|
|
if (percentage > 40) {
|
|
|
|
|
renderer.fillRect(x + 6, y + 2, 3, rect.height - 4);
|
|
|
|
|
}
|
|
|
|
|
if (percentage > 70) {
|
|
|
|
|
renderer.fillRect(x + 10, y + 2, 3, rect.height - 4);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void LyraTheme::drawBatteryRight(const GfxRenderer& renderer, Rect rect, const bool showPercentage) const {
|
|
|
|
|
// Right aligned: percentage on left, icon on right (UI headers)
|
|
|
|
|
const uint16_t percentage = battery.readPercentage();
|
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



## 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>
2026-02-05 17:50:11 +07:00
|
|
|
const int y = rect.y + 6;
|
|
|
|
|
const int battWidth = LyraMetrics::values.batteryWidth;
|
|
|
|
|
|
2026-02-16 14:36:36 +01:00
|
|
|
if (showPercentage) {
|
|
|
|
|
const auto percentageText = std::to_string(percentage) + "%";
|
|
|
|
|
const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, percentageText.c_str());
|
|
|
|
|
// Clear the area where we're going to draw the text to prevent ghosting
|
|
|
|
|
const auto textHeight = renderer.getTextHeight(SMALL_FONT_ID);
|
|
|
|
|
renderer.fillRect(rect.x - textWidth - batteryPercentSpacing, rect.y, textWidth, textHeight, false);
|
|
|
|
|
// Draw text to the left of the icon
|
|
|
|
|
renderer.drawText(SMALL_FONT_ID, rect.x - textWidth - batteryPercentSpacing, rect.y, percentageText.c_str());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Draw icon at rect.x
|
|
|
|
|
const int x = rect.x;
|
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



## 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>
2026-02-05 17:50:11 +07:00
|
|
|
// Top line
|
|
|
|
|
renderer.drawLine(x + 1, y, x + battWidth - 3, y);
|
|
|
|
|
// Bottom line
|
|
|
|
|
renderer.drawLine(x + 1, y + rect.height - 1, x + battWidth - 3, y + rect.height - 1);
|
|
|
|
|
// Left line
|
|
|
|
|
renderer.drawLine(x, y + 1, x, y + rect.height - 2);
|
|
|
|
|
// Battery end
|
|
|
|
|
renderer.drawLine(x + battWidth - 2, y + 1, x + battWidth - 2, y + rect.height - 2);
|
|
|
|
|
renderer.drawPixel(x + battWidth - 1, y + 3);
|
|
|
|
|
renderer.drawPixel(x + battWidth - 1, y + rect.height - 4);
|
|
|
|
|
renderer.drawLine(x + battWidth - 0, y + 4, x + battWidth - 0, y + rect.height - 5);
|
|
|
|
|
|
|
|
|
|
// Draw bars
|
|
|
|
|
if (percentage > 10) {
|
|
|
|
|
renderer.fillRect(x + 2, y + 2, 3, rect.height - 4);
|
|
|
|
|
}
|
|
|
|
|
if (percentage > 40) {
|
|
|
|
|
renderer.fillRect(x + 6, y + 2, 3, rect.height - 4);
|
|
|
|
|
}
|
|
|
|
|
if (percentage > 70) {
|
|
|
|
|
renderer.fillRect(x + 10, y + 2, 3, rect.height - 4);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void LyraTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* title) const {
|
|
|
|
|
renderer.fillRect(rect.x, rect.y, rect.width, rect.height, false);
|
|
|
|
|
|
|
|
|
|
const bool showBatteryPercentage =
|
|
|
|
|
SETTINGS.hideBatteryPercentage != CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_ALWAYS;
|
2026-02-16 14:36:36 +01:00
|
|
|
// Position icon at right edge, drawBatteryRight will place text to the left
|
|
|
|
|
const int batteryX = rect.x + rect.width - 12 - LyraMetrics::values.batteryWidth;
|
|
|
|
|
drawBatteryRight(renderer,
|
|
|
|
|
Rect{batteryX, rect.y + 5, LyraMetrics::values.batteryWidth, LyraMetrics::values.batteryHeight},
|
|
|
|
|
showBatteryPercentage);
|
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



## 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>
2026-02-05 17:50:11 +07:00
|
|
|
|
|
|
|
|
if (title) {
|
|
|
|
|
auto truncatedTitle = renderer.truncatedText(
|
|
|
|
|
UI_12_FONT_ID, title, rect.width - LyraMetrics::values.contentSidePadding * 2, EpdFontFamily::BOLD);
|
|
|
|
|
renderer.drawText(UI_12_FONT_ID, rect.x + LyraMetrics::values.contentSidePadding,
|
|
|
|
|
rect.y + LyraMetrics::values.batteryBarHeight + 3, truncatedTitle.c_str(), true,
|
|
|
|
|
EpdFontFamily::BOLD);
|
|
|
|
|
renderer.drawLine(rect.x, rect.y + rect.height - 3, rect.x + rect.width, rect.y + rect.height - 3, 3, true);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void LyraTheme::drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector<TabInfo>& tabs,
|
|
|
|
|
bool selected) const {
|
|
|
|
|
int currentX = rect.x + LyraMetrics::values.contentSidePadding;
|
|
|
|
|
|
|
|
|
|
if (selected) {
|
|
|
|
|
renderer.fillRectDither(rect.x, rect.y, rect.width, rect.height, Color::LightGray);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const auto& tab : tabs) {
|
|
|
|
|
const int textWidth = renderer.getTextWidth(UI_10_FONT_ID, tab.label, EpdFontFamily::REGULAR);
|
|
|
|
|
|
|
|
|
|
if (tab.selected) {
|
|
|
|
|
if (selected) {
|
|
|
|
|
renderer.fillRoundedRect(currentX, rect.y + 1, textWidth + 2 * hPaddingInSelection, rect.height - 4,
|
|
|
|
|
cornerRadius, Color::Black);
|
|
|
|
|
} else {
|
|
|
|
|
renderer.fillRectDither(currentX, rect.y, textWidth + 2 * hPaddingInSelection, rect.height - 3,
|
|
|
|
|
Color::LightGray);
|
|
|
|
|
renderer.drawLine(currentX, rect.y + rect.height - 3, currentX + textWidth + 2 * hPaddingInSelection,
|
|
|
|
|
rect.y + rect.height - 3, 2, true);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
renderer.drawText(UI_10_FONT_ID, currentX + hPaddingInSelection, rect.y + 6, tab.label, !(tab.selected && selected),
|
|
|
|
|
EpdFontFamily::REGULAR);
|
|
|
|
|
|
|
|
|
|
currentX += textWidth + LyraMetrics::values.tabSpacing + 2 * hPaddingInSelection;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
renderer.drawLine(rect.x, rect.y + rect.height - 1, rect.x + rect.width, rect.y + rect.height - 1, true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void LyraTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex,
|
|
|
|
|
const std::function<std::string(int index)>& rowTitle,
|
|
|
|
|
const std::function<std::string(int index)>& rowSubtitle,
|
|
|
|
|
const std::function<std::string(int index)>& rowIcon,
|
|
|
|
|
const std::function<std::string(int index)>& rowValue) const {
|
|
|
|
|
int rowHeight =
|
|
|
|
|
(rowSubtitle != nullptr) ? LyraMetrics::values.listWithSubtitleRowHeight : LyraMetrics::values.listRowHeight;
|
|
|
|
|
int pageItems = rect.height / rowHeight;
|
|
|
|
|
|
|
|
|
|
const int totalPages = (itemCount + pageItems - 1) / pageItems;
|
|
|
|
|
if (totalPages > 1) {
|
|
|
|
|
const int scrollAreaHeight = rect.height;
|
|
|
|
|
|
|
|
|
|
// Draw scroll bar
|
|
|
|
|
const int scrollBarHeight = (scrollAreaHeight * pageItems) / itemCount;
|
|
|
|
|
const int currentPage = selectedIndex / pageItems;
|
|
|
|
|
const int scrollBarY = rect.y + ((scrollAreaHeight - scrollBarHeight) * currentPage) / (totalPages - 1);
|
|
|
|
|
const int scrollBarX = rect.x + rect.width - LyraMetrics::values.scrollBarRightOffset;
|
|
|
|
|
renderer.drawLine(scrollBarX, rect.y, scrollBarX, rect.y + scrollAreaHeight, true);
|
|
|
|
|
renderer.fillRect(scrollBarX - LyraMetrics::values.scrollBarWidth, scrollBarY, LyraMetrics::values.scrollBarWidth,
|
|
|
|
|
scrollBarHeight, true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Draw selection
|
|
|
|
|
int contentWidth =
|
|
|
|
|
rect.width -
|
|
|
|
|
(totalPages > 1 ? (LyraMetrics::values.scrollBarWidth + LyraMetrics::values.scrollBarRightOffset) : 1);
|
|
|
|
|
if (selectedIndex >= 0) {
|
|
|
|
|
renderer.fillRoundedRect(LyraMetrics::values.contentSidePadding, rect.y + selectedIndex % pageItems * rowHeight,
|
|
|
|
|
contentWidth - LyraMetrics::values.contentSidePadding * 2, rowHeight, cornerRadius,
|
|
|
|
|
Color::LightGray);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Draw all items
|
|
|
|
|
const auto pageStartIndex = selectedIndex / pageItems * pageItems;
|
|
|
|
|
for (int i = pageStartIndex; i < itemCount && i < pageStartIndex + pageItems; i++) {
|
|
|
|
|
const int itemY = rect.y + (i % pageItems) * rowHeight;
|
|
|
|
|
|
|
|
|
|
// Draw name
|
|
|
|
|
int textWidth = contentWidth - LyraMetrics::values.contentSidePadding * 2 - hPaddingInSelection * 2 -
|
|
|
|
|
(rowValue != nullptr ? 60 : 0); // TODO truncate according to value width?
|
|
|
|
|
auto itemName = rowTitle(i);
|
|
|
|
|
auto item = renderer.truncatedText(UI_10_FONT_ID, itemName.c_str(), textWidth);
|
|
|
|
|
renderer.drawText(UI_10_FONT_ID, rect.x + LyraMetrics::values.contentSidePadding + hPaddingInSelection * 2,
|
|
|
|
|
itemY + 6, item.c_str(), true);
|
|
|
|
|
|
|
|
|
|
if (rowSubtitle != nullptr) {
|
|
|
|
|
// Draw subtitle
|
|
|
|
|
std::string subtitleText = rowSubtitle(i);
|
|
|
|
|
auto subtitle = renderer.truncatedText(SMALL_FONT_ID, subtitleText.c_str(), textWidth);
|
|
|
|
|
renderer.drawText(SMALL_FONT_ID, rect.x + LyraMetrics::values.contentSidePadding + hPaddingInSelection * 2,
|
|
|
|
|
itemY + 30, subtitle.c_str(), true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (rowValue != nullptr) {
|
|
|
|
|
// Draw value
|
|
|
|
|
std::string valueText = rowValue(i);
|
|
|
|
|
if (!valueText.empty()) {
|
|
|
|
|
const auto valueTextWidth = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str());
|
|
|
|
|
|
|
|
|
|
if (i == selectedIndex) {
|
|
|
|
|
renderer.fillRoundedRect(
|
|
|
|
|
contentWidth - LyraMetrics::values.contentSidePadding - hPaddingInSelection * 2 - valueTextWidth, itemY,
|
|
|
|
|
valueTextWidth + hPaddingInSelection * 2, rowHeight, cornerRadius, Color::Black);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
renderer.drawText(UI_10_FONT_ID,
|
|
|
|
|
contentWidth - LyraMetrics::values.contentSidePadding - hPaddingInSelection - valueTextWidth,
|
|
|
|
|
itemY + 6, valueText.c_str(), i != selectedIndex);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void LyraTheme::drawButtonHints(GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3,
|
|
|
|
|
const char* btn4) const {
|
|
|
|
|
const GfxRenderer::Orientation orig_orientation = renderer.getOrientation();
|
|
|
|
|
renderer.setOrientation(GfxRenderer::Orientation::Portrait);
|
|
|
|
|
|
|
|
|
|
const int pageHeight = renderer.getScreenHeight();
|
|
|
|
|
constexpr int buttonWidth = 80;
|
|
|
|
|
constexpr int smallButtonHeight = 15;
|
|
|
|
|
constexpr int buttonHeight = LyraMetrics::values.buttonHintsHeight;
|
|
|
|
|
constexpr int buttonY = LyraMetrics::values.buttonHintsHeight; // Distance from bottom
|
|
|
|
|
constexpr int textYOffset = 7; // Distance from top of button to text baseline
|
|
|
|
|
constexpr int buttonPositions[] = {58, 146, 254, 342};
|
|
|
|
|
const char* labels[] = {btn1, btn2, btn3, btn4};
|
|
|
|
|
|
|
|
|
|
for (int i = 0; i < 4; i++) {
|
|
|
|
|
const int x = buttonPositions[i];
|
|
|
|
|
if (labels[i] != nullptr && labels[i][0] != '\0') {
|
2026-02-16 08:31:19 -05:00
|
|
|
// Draw the filled background and border for a FULL-sized button
|
|
|
|
|
renderer.fillRect(x, pageHeight - buttonY, buttonWidth, buttonHeight, false);
|
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



## 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>
2026-02-05 17:50:11 +07:00
|
|
|
renderer.drawRoundedRect(x, pageHeight - buttonY, buttonWidth, buttonHeight, 1, cornerRadius, true, true, false,
|
|
|
|
|
false, true);
|
|
|
|
|
const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, labels[i]);
|
|
|
|
|
const int textX = x + (buttonWidth - 1 - textWidth) / 2;
|
|
|
|
|
renderer.drawText(SMALL_FONT_ID, textX, pageHeight - buttonY + textYOffset, labels[i]);
|
|
|
|
|
} else {
|
2026-02-16 08:31:19 -05:00
|
|
|
// Draw the filled background and border for a SMALL-sized button
|
|
|
|
|
renderer.fillRect(x, pageHeight - smallButtonHeight, buttonWidth, smallButtonHeight, false);
|
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



## 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>
2026-02-05 17:50:11 +07:00
|
|
|
renderer.drawRoundedRect(x, pageHeight - smallButtonHeight, buttonWidth, smallButtonHeight, 1, cornerRadius, true,
|
|
|
|
|
true, false, false, true);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
renderer.setOrientation(orig_orientation);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void LyraTheme::drawSideButtonHints(const GfxRenderer& renderer, const char* topBtn, const char* bottomBtn) const {
|
|
|
|
|
const int screenWidth = renderer.getScreenWidth();
|
|
|
|
|
constexpr int buttonWidth = LyraMetrics::values.sideButtonHintsWidth; // Width on screen (height when rotated)
|
|
|
|
|
constexpr int buttonHeight = 78; // Height on screen (width when rotated)
|
|
|
|
|
// Position for the button group - buttons share a border so they're adjacent
|
|
|
|
|
|
|
|
|
|
const char* labels[] = {topBtn, bottomBtn};
|
|
|
|
|
|
|
|
|
|
// Draw the shared border for both buttons as one unit
|
|
|
|
|
const int x = screenWidth - buttonWidth;
|
|
|
|
|
|
|
|
|
|
// Draw top button outline
|
|
|
|
|
if (topBtn != nullptr && topBtn[0] != '\0') {
|
|
|
|
|
renderer.drawRoundedRect(x, topHintButtonY, buttonWidth, buttonHeight, 1, cornerRadius, true, false, true, false,
|
|
|
|
|
true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Draw bottom button outline
|
|
|
|
|
if (bottomBtn != nullptr && bottomBtn[0] != '\0') {
|
|
|
|
|
renderer.drawRoundedRect(x, topHintButtonY + buttonHeight + 5, buttonWidth, buttonHeight, 1, cornerRadius, true,
|
|
|
|
|
false, true, false, true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Draw text for each button
|
|
|
|
|
for (int i = 0; i < 2; i++) {
|
|
|
|
|
if (labels[i] != nullptr && labels[i][0] != '\0') {
|
|
|
|
|
const int y = topHintButtonY + (i * buttonHeight + 5);
|
|
|
|
|
|
|
|
|
|
// Draw rotated text centered in the button
|
|
|
|
|
const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, labels[i]);
|
|
|
|
|
|
|
|
|
|
renderer.drawTextRotated90CW(SMALL_FONT_ID, x, y + (buttonHeight + textWidth) / 2, labels[i]);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void LyraTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector<RecentBook>& recentBooks,
|
|
|
|
|
const int selectorIndex, bool& coverRendered, bool& coverBufferStored,
|
|
|
|
|
bool& bufferRestored, std::function<bool()> storeCoverBuffer) const {
|
|
|
|
|
const int tileWidth = (rect.width - 2 * LyraMetrics::values.contentSidePadding) / 3;
|
|
|
|
|
const int tileHeight = rect.height;
|
|
|
|
|
const int bookTitleHeight = tileHeight - LyraMetrics::values.homeCoverHeight - hPaddingInSelection;
|
|
|
|
|
const int tileY = rect.y;
|
|
|
|
|
const bool hasContinueReading = !recentBooks.empty();
|
|
|
|
|
|
|
|
|
|
// Draw book card regardless, fill with message based on `hasContinueReading`
|
|
|
|
|
// Draw cover image as background if available (inside the box)
|
|
|
|
|
// Only load from SD on first render, then use stored buffer
|
|
|
|
|
if (hasContinueReading) {
|
|
|
|
|
if (!coverRendered) {
|
|
|
|
|
for (int i = 0; i < std::min(static_cast<int>(recentBooks.size()), LyraMetrics::values.homeRecentBooksCount);
|
|
|
|
|
i++) {
|
|
|
|
|
std::string coverPath = recentBooks[i].coverBmpPath;
|
2026-02-06 14:58:32 +07:00
|
|
|
int tileX = LyraMetrics::values.contentSidePadding + tileWidth * i;
|
2026-02-16 08:33:43 -05:00
|
|
|
renderer.drawRect(tileX + hPaddingInSelection, tileY + hPaddingInSelection, tileWidth - 2 * hPaddingInSelection,
|
|
|
|
|
LyraMetrics::values.homeCoverHeight);
|
|
|
|
|
if (!coverPath.empty()) {
|
2026-02-06 14:58:32 +07:00
|
|
|
const std::string coverBmpPath = UITheme::getCoverThumbPath(coverPath, LyraMetrics::values.homeCoverHeight);
|
|
|
|
|
|
|
|
|
|
// First time: load cover from SD and render
|
|
|
|
|
FsFile file;
|
2026-02-08 21:29:14 +01:00
|
|
|
if (Storage.openFileForRead("HOME", coverBmpPath, file)) {
|
2026-02-06 14:58:32 +07:00
|
|
|
Bitmap bitmap(file);
|
|
|
|
|
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
|
|
|
|
|
float coverHeight = static_cast<float>(bitmap.getHeight());
|
|
|
|
|
float coverWidth = static_cast<float>(bitmap.getWidth());
|
|
|
|
|
float ratio = coverWidth / coverHeight;
|
|
|
|
|
const float tileRatio = static_cast<float>(tileWidth - 2 * hPaddingInSelection) /
|
|
|
|
|
static_cast<float>(LyraMetrics::values.homeCoverHeight);
|
|
|
|
|
float cropX = 1.0f - (tileRatio / ratio);
|
|
|
|
|
renderer.drawBitmap(bitmap, tileX + hPaddingInSelection, tileY + hPaddingInSelection,
|
|
|
|
|
tileWidth - 2 * hPaddingInSelection, LyraMetrics::values.homeCoverHeight, cropX);
|
|
|
|
|
}
|
|
|
|
|
file.close();
|
|
|
|
|
}
|
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



## 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>
2026-02-05 17:50:11 +07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
coverBufferStored = storeCoverBuffer();
|
|
|
|
|
coverRendered = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (int i = 0; i < std::min(static_cast<int>(recentBooks.size()), LyraMetrics::values.homeRecentBooksCount); i++) {
|
|
|
|
|
bool bookSelected = (selectorIndex == i);
|
|
|
|
|
|
|
|
|
|
int tileX = LyraMetrics::values.contentSidePadding + tileWidth * i;
|
|
|
|
|
auto title =
|
|
|
|
|
renderer.truncatedText(UI_10_FONT_ID, recentBooks[i].title.c_str(), tileWidth - 2 * hPaddingInSelection);
|
|
|
|
|
|
|
|
|
|
if (bookSelected) {
|
|
|
|
|
// Draw selection box
|
|
|
|
|
renderer.fillRoundedRect(tileX, tileY, tileWidth, hPaddingInSelection, cornerRadius, true, true, false, false,
|
|
|
|
|
Color::LightGray);
|
|
|
|
|
renderer.fillRectDither(tileX, tileY + hPaddingInSelection, hPaddingInSelection,
|
|
|
|
|
LyraMetrics::values.homeCoverHeight, Color::LightGray);
|
|
|
|
|
renderer.fillRectDither(tileX + tileWidth - hPaddingInSelection, tileY + hPaddingInSelection,
|
|
|
|
|
hPaddingInSelection, LyraMetrics::values.homeCoverHeight, Color::LightGray);
|
|
|
|
|
renderer.fillRoundedRect(tileX, tileY + LyraMetrics::values.homeCoverHeight + hPaddingInSelection, tileWidth,
|
|
|
|
|
bookTitleHeight, cornerRadius, false, false, true, true, Color::LightGray);
|
|
|
|
|
}
|
|
|
|
|
renderer.drawText(UI_10_FONT_ID, tileX + hPaddingInSelection,
|
|
|
|
|
tileY + tileHeight - bookTitleHeight + hPaddingInSelection + 5, title.c_str(), true);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void LyraTheme::drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount, int selectedIndex,
|
|
|
|
|
const std::function<std::string(int index)>& buttonLabel,
|
|
|
|
|
const std::function<std::string(int index)>& rowIcon) const {
|
|
|
|
|
for (int i = 0; i < buttonCount; ++i) {
|
|
|
|
|
int tileWidth = (rect.width - LyraMetrics::values.contentSidePadding * 2 - LyraMetrics::values.menuSpacing) / 2;
|
|
|
|
|
Rect tileRect =
|
|
|
|
|
Rect{rect.x + LyraMetrics::values.contentSidePadding + (LyraMetrics::values.menuSpacing + tileWidth) * (i % 2),
|
|
|
|
|
rect.y + static_cast<int>(i / 2) * (LyraMetrics::values.menuRowHeight + LyraMetrics::values.menuSpacing),
|
|
|
|
|
tileWidth, LyraMetrics::values.menuRowHeight};
|
|
|
|
|
|
|
|
|
|
const bool selected = selectedIndex == i;
|
|
|
|
|
|
|
|
|
|
if (selected) {
|
|
|
|
|
renderer.fillRoundedRect(tileRect.x, tileRect.y, tileRect.width, tileRect.height, cornerRadius, Color::LightGray);
|
|
|
|
|
}
|
|
|
|
|
|
feat: User-Interface I18n System (#728)
## Summary
**What is the goal of this PR?**
This PR introduces Internationalization (i18n) support, enabling users
to switch the UI language dynamically.
**What changes are included?**
- Core Logic: Added I18n class (`lib/I18n/I18n.h/cpp`) to manage
language state and string retrieval.
- Data Structures:
- `lib/I18n/I18nStrings.h/cpp`: Static string arrays for each supported
language.
- `lib/I18n/I18nKeys.h`: Enum definitions for type-safe string access.
- `lib/I18n/translations.csv`: single source of truth.
- Documentation: Added `docs/i18n.md` detailing the workflow for
developers and translators.
- New Settings activity:
`src/activities/settings/LanguageSelectActivity.h/cpp`
## Additional Context
This implementation (building on concepts from #505) prioritizes
performance and memory efficiency.
The core approach is to store all localized strings for each language in
dedicated arrays and access them via enums. This provides O(1) access
with zero runtime overhead, and avoids the heap allocations, hashing,
and collision handling required by `std::map` or `std::unordered_map`.
The main trade-off is that enums and string arrays must remain perfectly
synchronized—any mismatch would result in incorrect strings being
displayed in the UI.
To eliminate this risk, I added a Python script that automatically
generates `I18nStrings.h/.cpp` and `I18nKeys.h` from a CSV file, which
will serve as the single source of truth for all translations. The full
design and workflow are documented in `docs/i18n.md`.
### Next Steps
- [x] Python script `generate_i18n.py` to auto-generate C++ files from
CSV
- [x] Populate translations.csv with initial translations.
Currently available translations: English, Español, Français, Deutsch,
Čeština, Português (Brasil), Русский, Svenska.
Thanks, community!
**Status:** EDIT: ready to be merged.
As a proof of concept, the SPANISH strings currently mirror the English
ones, but are fully uppercased.
---
### AI Usage
Did you use AI tools to help write this code? _**< PARTIALLY >**_
I used AI for the black work of replacing strings with I18n references
across the project, and for generating the documentation. EDIT: also
some help with merging changes from master.
---------
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
Co-authored-by: yeyeto2788 <juanernestobiondi@gmail.com>
2026-02-16 15:28:42 +02:00
|
|
|
std::string labelStr = buttonLabel(i);
|
|
|
|
|
const char* label = labelStr.c_str();
|
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



## 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>
2026-02-05 17:50:11 +07:00
|
|
|
const int textX = tileRect.x + 16;
|
|
|
|
|
const int lineHeight = renderer.getLineHeight(UI_12_FONT_ID);
|
|
|
|
|
const int textY = tileRect.y + (LyraMetrics::values.menuRowHeight - lineHeight) / 2;
|
|
|
|
|
|
|
|
|
|
// Invert text when the tile is selected, to contrast with the filled background
|
|
|
|
|
renderer.drawText(UI_12_FONT_ID, textX, textY, label, true);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Rect LyraTheme::drawPopup(const GfxRenderer& renderer, const char* message) const {
|
|
|
|
|
constexpr int margin = 15;
|
|
|
|
|
constexpr int y = 60;
|
|
|
|
|
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, message, EpdFontFamily::REGULAR);
|
|
|
|
|
const int textHeight = renderer.getLineHeight(UI_12_FONT_ID);
|
|
|
|
|
const int w = textWidth + margin * 2;
|
|
|
|
|
const int h = textHeight + margin * 2;
|
|
|
|
|
const int x = (renderer.getScreenWidth() - w) / 2;
|
|
|
|
|
|
|
|
|
|
renderer.fillRect(x - 5, y - 5, w + 10, h + 10, false);
|
|
|
|
|
renderer.drawRect(x, y, w, h, true);
|
|
|
|
|
|
|
|
|
|
const int textX = x + (w - textWidth) / 2;
|
|
|
|
|
const int textY = y + margin - 2;
|
|
|
|
|
renderer.drawText(UI_12_FONT_ID, textX, textY, message, true, EpdFontFamily::REGULAR);
|
|
|
|
|
renderer.displayBuffer();
|
|
|
|
|
return Rect{x, y, w, h};
|
|
|
|
|
}
|