2026-10-07 20:35:26 +02:00

806 lines
30 KiB
C++

#include <Tactility/app/fileselection/FileSelection.h>
#include <Tactility/app/selectiondialog/SelectionDialog.h>
#include <Tactility/file/File.h>
#include <Tactility/lvgl/Fonts.h>
#include <Tactility/lvgl/Lvgl.h>
#include <Tactility/lvgl/Theme.h>
#include <Tactility/settings/AppearanceSettings.h>
#include <app/event.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <app/scheduler.h>
#include <app/stream.h>
#include <lvgl_window_manager/window_manager.h>
#include <tactility/check.h>
#include <tactility/concurrent/task_event_group.h>
#include <tactility/concurrent/thread.h>
#include <tactility/log.h>
#include <lvgl.h>
#include <lvgl/lvgl.h>
#include <lvgl/theme.h>
#include <lvgl/widgets/card.h>
#include <lvgl/widgets/chip.h>
#include <lvgl/widgets/toolbar.h>
#include <atomic>
#include <format>
#include <string>
namespace tt::app::appearancesettings {
constexpr auto* TAG = "AppearanceSettings";
extern const ::AppManifest manifest;
namespace {
constexpr uint16_t MIN_FONT_SIZE = 10;
constexpr uint16_t MAX_FONT_SIZE = 32;
constexpr size_t APPLY_THREAD_STACK_SIZE = 8192;
enum class FontSlot {
Regular,
Mono
};
struct NamedColor {
const char* name;
lv_palette_t palette;
};
constexpr NamedColor PALETTE_COLORS[] = {
{ "Red", LV_PALETTE_RED },
{ "Pink", LV_PALETTE_PINK },
{ "Purple", LV_PALETTE_PURPLE },
{ "Deep purple", LV_PALETTE_DEEP_PURPLE },
{ "Indigo", LV_PALETTE_INDIGO },
{ "Blue", LV_PALETTE_BLUE },
{ "Light blue", LV_PALETTE_LIGHT_BLUE },
{ "Cyan", LV_PALETTE_CYAN },
{ "Teal", LV_PALETTE_TEAL },
{ "Green", LV_PALETTE_GREEN },
{ "Light green", LV_PALETTE_LIGHT_GREEN },
{ "Lime", LV_PALETTE_LIME },
{ "Yellow", LV_PALETTE_YELLOW },
{ "Amber", LV_PALETTE_AMBER },
{ "Orange", LV_PALETTE_ORANGE },
{ "Deep orange", LV_PALETTE_DEEP_ORANGE },
{ "Brown", LV_PALETTE_BROWN },
{ "Blue grey", LV_PALETTE_BLUE_GREY },
{ "Grey", LV_PALETTE_GREY },
};
constexpr size_t COLOR_COUNT = 3;
constexpr const char* COLOR_TITLES[COLOR_COUNT] = { "Primary color", "Secondary color", "Error color" };
struct ColorRowWidgets {
lv_obj_t* row = nullptr;
lv_obj_t* swatch = nullptr;
};
struct FontRowWidgets {
lv_obj_t* defaultButton = nullptr;
lv_obj_t* fileLabel = nullptr;
lv_obj_t* warningLabel = nullptr;
};
struct Context {
uint32_t appInstanceId = 0;
TaskEventGroup* eventGroup = nullptr;
uint32_t selectRegularBit = 0;
uint32_t selectMonoBit = 0;
uint32_t selectColorBit = 0;
uint32_t applyBit = 0;
uint32_t cacheUpdatedBit = 0;
settings::appearance::AppearanceSettings savedSettings;
/** Accessed with the LVGL lock held */
settings::appearance::AppearanceSettings pendingSettings;
/** Characters in the pending font files, accessed with the LVGL lock held */
size_t regularCharacterCount = 0;
size_t monoCharacterCount = 0;
bool applyFailed = false;
std::atomic<bool> applying = false;
Thread* applyThread = nullptr;
lvgl::FontConfiguration applyConfiguration;
std::atomic<bool> cacheUpdateSucceeded = false;
uint32_t selectLaunchId = 0;
/** The colour that the selection dialog is for */
size_t selectColorIndex = 0;
uint32_t colorSelectLaunchId = 0;
FontSlot selectSlot = FontSlot::Regular;
AppStream selectStream {};
uint8_t selectBuffer[256] {};
// Valid while the window is shown
lv_obj_t* applyButton = nullptr;
lv_obj_t* spinner = nullptr;
FontRowWidgets regularRow;
FontRowWidgets monoRow;
lv_obj_t* errorLabel = nullptr;
lv_obj_t* lightChip = nullptr;
lv_obj_t* darkChip = nullptr;
lv_obj_t* regularThemeChip = nullptr;
lv_obj_t* monoThemeChip = nullptr;
ColorRowWidgets colorRows[COLOR_COUNT];
/** The display can only show the mono theme */
bool isMonoDisplay = false;
};
lvgl::FontConfiguration toFontConfiguration(const settings::appearance::AppearanceSettings& settings) {
return {
.defaultSize = settings.fontSize != 0 ? settings.fontSize : static_cast<uint16_t>(TT_FONT_DEFAULT_SIZE),
.regularFontPath = settings.regularFontPath,
.monoFontPath = settings.monoFontPath,
};
}
std::string& getFontPath(settings::appearance::AppearanceSettings& settings, FontSlot slot) {
return slot == FontSlot::Regular ? settings.regularFontPath : settings.monoFontPath;
}
size_t& getCharacterCount(Context* ctx, FontSlot slot) {
return slot == FontSlot::Regular ? ctx->regularCharacterCount : ctx->monoCharacterCount;
}
size_t getCharacterCount(const std::string& path) {
return path.empty() ? 0 : lvgl::getTtfCharacterCount(path);
}
void setHidden(lv_obj_t* object, bool hidden) {
if (hidden) {
lv_obj_add_flag(object, LV_OBJ_FLAG_HIDDEN);
} else {
lv_obj_remove_flag(object, LV_OBJ_FLAG_HIDDEN);
}
}
void updateFontRow(const FontRowWidgets& row, const std::string& path, size_t characterCount) {
lv_label_set_text(row.fileLabel, path.empty() ? "System font" : file::getLastPathSegment(path).c_str());
setHidden(row.defaultButton, path.empty());
const bool large = !path.empty() && characterCount > lvgl::LARGE_FONT_CHARACTER_COUNT;
if (large) {
lv_label_set_text(row.warningLabel, std::format("Contains {} characters: creating the fonts can take long and use a lot of memory.", characterCount).c_str());
}
setHidden(row.warningLabel, !large);
}
bool isDeviceDefaultDark() {
LvglThemeSettings defaults;
lvgl_theme_get_default_settings(&defaults);
return defaults.is_dark;
}
bool isDark(const settings::appearance::AppearanceSettings& settings) {
switch (settings.themeMode) {
case settings::appearance::ThemeMode::Light:
return false;
case settings::appearance::ThemeMode::Dark:
return true;
default:
return isDeviceDefaultDark();
}
}
void setChecked(lv_obj_t* object, bool checked) {
if (checked) {
lv_obj_add_state(object, LV_STATE_CHECKED);
} else {
lv_obj_remove_state(object, LV_STATE_CHECKED);
}
}
void updateColorRow(Context* ctx, size_t index);
void updateThemeWidgets(Context* ctx) {
const bool dark = isDark(ctx->pendingSettings);
setChecked(ctx->lightChip, !dark);
setChecked(ctx->darkChip, dark);
if (ctx->regularThemeChip != nullptr) {
setChecked(ctx->regularThemeChip, !ctx->pendingSettings.monoTheme);
setChecked(ctx->monoThemeChip, ctx->pendingSettings.monoTheme);
}
// The mono theme doesn't use colours
for (size_t i = 0; i < COLOR_COUNT; i++) {
if (ctx->colorRows[i].row != nullptr) {
setHidden(ctx->colorRows[i].row, ctx->pendingSettings.monoTheme);
updateColorRow(ctx, i);
}
}
}
/** Updates the widgets to the pending settings. Requires the LVGL lock. */
void updateWidgets(Context* ctx) {
if (ctx->applyButton == nullptr) {
return;
}
const bool applying = ctx->applying;
setHidden(ctx->applyButton, applying || ctx->pendingSettings == ctx->savedSettings);
setHidden(ctx->spinner, !applying);
updateFontRow(ctx->regularRow, ctx->pendingSettings.regularFontPath, ctx->regularCharacterCount);
updateFontRow(ctx->monoRow, ctx->pendingSettings.monoFontPath, ctx->monoCharacterCount);
setHidden(ctx->errorLabel, !ctx->applyFailed);
updateThemeWidgets(ctx);
}
void setDark(Context* ctx, bool dark) {
// The device default is kept when it matches, so it doesn't count as a change
if (dark == isDeviceDefaultDark()) {
ctx->pendingSettings.themeMode = settings::appearance::ThemeMode::DeviceDefault;
} else {
ctx->pendingSettings.themeMode = dark ? settings::appearance::ThemeMode::Dark : settings::appearance::ThemeMode::Light;
}
updateWidgets(ctx);
}
void onLightPressed(lv_event_t* event) {
setDark(static_cast<Context*>(lv_event_get_user_data(event)), false);
}
void onDarkPressed(lv_event_t* event) {
setDark(static_cast<Context*>(lv_event_get_user_data(event)), true);
}
void onRegularThemePressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
ctx->pendingSettings.monoTheme = false;
updateWidgets(ctx);
}
void onMonoThemePressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
ctx->pendingSettings.monoTheme = true;
updateWidgets(ctx);
}
void onAnimationsChanged(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
auto* checkbox = static_cast<lv_obj_t*>(lv_event_get_target(event));
ctx->pendingSettings.animationsEnabled = lv_obj_has_state(checkbox, LV_STATE_CHECKED);
updateWidgets(ctx);
}
uint32_t getPaletteColor(lv_palette_t palette) {
return lv_color_to_u32(lv_palette_main(palette)) & 0xFFFFFF;
}
constexpr size_t PALETTE_COLOR_COUNT = sizeof(PALETTE_COLORS) / sizeof(PALETTE_COLORS[0]);
std::optional<uint32_t>& getColor(settings::appearance::AppearanceSettings& settings, size_t index) {
switch (index) {
case 0:
return settings.primaryColor;
case 1:
return settings.secondaryColor;
default:
return settings.errorColor;
}
}
uint32_t getDefaultColor(size_t index) {
LvglThemeSettings defaults;
lvgl_theme_get_default_settings(&defaults);
const lv_color_t colors[COLOR_COUNT] = { defaults.color_primary, defaults.color_secondary, defaults.color_error };
return lv_color_to_u32(colors[index]) & 0xFFFFFF;
}
void updateColorRow(Context* ctx, size_t index) {
const auto& widgets = ctx->colorRows[index];
const auto& color = getColor(ctx->pendingSettings, index);
lv_obj_set_style_bg_color(widgets.swatch, lv_color_hex(color.value_or(getDefaultColor(index))), LV_STATE_DEFAULT);
}
void onColorButtonPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
auto* button = static_cast<lv_obj_t*>(lv_event_get_target(event));
ctx->selectColorIndex = reinterpret_cast<uintptr_t>(lv_obj_get_user_data(button));
task_event_group_signal(ctx->eventGroup, ctx->selectColorBit);
}
void onBackPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
app_event_emit_close(ctx->appInstanceId);
}
void onApplyPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
task_event_group_signal(ctx->eventGroup, ctx->applyBit);
}
void onFontSizeChanged(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
auto* spinbox = static_cast<lv_obj_t*>(lv_event_get_target(event));
const auto size = static_cast<uint16_t>(lv_spinbox_get_value(spinbox));
// The device's default size is stored as "unset", so it follows the device default
ctx->pendingSettings.fontSize = size == TT_FONT_DEFAULT_SIZE ? 0 : size;
updateWidgets(ctx);
}
void onFontSizeDecrementPressed(lv_event_t* event) {
lv_spinbox_decrement(static_cast<lv_obj_t*>(lv_event_get_user_data(event)));
}
void onFontSizeIncrementPressed(lv_event_t* event) {
lv_spinbox_increment(static_cast<lv_obj_t*>(lv_event_get_user_data(event)));
}
lv_obj_t* createStepButton(lv_obj_t* parent, const char* symbol, lv_event_cb_t callback, lv_obj_t* spinbox) {
auto* button = lv_button_create(parent);
auto* label = lv_label_create(button);
lv_label_set_text(label, symbol);
lv_obj_center(label);
lv_obj_add_event_cb(button, callback, LV_EVENT_SHORT_CLICKED, spinbox);
lv_obj_add_event_cb(button, callback, LV_EVENT_LONG_PRESSED_REPEAT, spinbox);
return button;
}
void onSelectRegularPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
task_event_group_signal(ctx->eventGroup, ctx->selectRegularBit);
}
void onSelectMonoPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
task_event_group_signal(ctx->eventGroup, ctx->selectMonoBit);
}
void onDefaultRegularPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
ctx->pendingSettings.regularFontPath.clear();
ctx->regularCharacterCount = 0;
updateWidgets(ctx);
}
void onDefaultMonoPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
ctx->pendingSettings.monoFontPath.clear();
ctx->monoCharacterCount = 0;
updateWidgets(ctx);
}
/** An unstyled row, so it doesn't paint over the card. Its items are spaced like the card's content. */
lv_obj_t* createRow(lv_obj_t* parent) {
auto* row = lv_obj_create(parent);
lv_obj_remove_style_all(row);
lv_obj_set_size(row, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(row, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_column(row, lv_obj_get_style_pad_column(parent, LV_PART_MAIN), LV_STATE_DEFAULT);
// Room for the focus outline of the buttons, which is clipped to the row in compact mode
lv_obj_set_style_pad_ver(row, 3, LV_STATE_DEFAULT);
lv_obj_set_style_pad_hor(row, 3, LV_STATE_DEFAULT);
lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE);
return row;
}
/** A title above a card, and the card */
lv_obj_t* createSection(lv_obj_t* parent, const char* title) {
auto* title_label = lv_label_create(parent);
lv_label_set_text(title_label, title);
auto* card = lvgl_card_create(parent);
lv_obj_set_size(card, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN);
return card;
}
lv_obj_t* createLabeledRow(lv_obj_t* parent, const char* text) {
auto* row = createRow(parent);
auto* label = lv_label_create(row);
lv_label_set_text(label, text);
lv_obj_set_flex_grow(label, 1);
return row;
}
lv_obj_t* createChip(lv_obj_t* parent, const char* text, lv_event_cb_t callback, Context* ctx) {
auto* chip = lvgl_chip_create(parent);
auto* label = lv_label_create(chip);
lv_label_set_text(label, text);
lv_obj_add_event_cb(chip, callback, LV_EVENT_SHORT_CLICKED, ctx);
return chip;
}
ColorRowWidgets createColorRow(lv_obj_t* parent, size_t index, Context* ctx) {
ColorRowWidgets widgets;
// "Title [swatch] [Change]"
widgets.row = createLabeledRow(parent, COLOR_TITLES[index]);
// The colour preview: its colour is the setting, its border uses the theme's text colour
widgets.swatch = lv_obj_create(widgets.row);
lv_obj_remove_style_all(widgets.swatch);
lv_obj_remove_flag(widgets.swatch, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_remove_flag(widgets.swatch, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_style_bg_opa(widgets.swatch, LV_OPA_COVER, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(widgets.swatch, 1, LV_STATE_DEFAULT);
lv_obj_set_style_border_color(widgets.swatch, lv_obj_get_style_text_color(widgets.swatch, LV_PART_MAIN), LV_STATE_DEFAULT);
lv_obj_set_style_radius(widgets.swatch, LV_DPX(4), LV_STATE_DEFAULT);
auto* button = lv_button_create(widgets.row);
auto* button_label = lv_label_create(button);
lv_label_set_text(button_label, "Change");
lv_obj_set_user_data(button, reinterpret_cast<void*>(static_cast<uintptr_t>(index)));
lv_obj_add_event_cb(button, onColorButtonPressed, LV_EVENT_SHORT_CLICKED, ctx);
// As high as the button
lv_obj_update_layout(button);
const int32_t swatch_size = lv_obj_get_height(button);
lv_obj_set_size(widgets.swatch, swatch_size, swatch_size);
return widgets;
}
void createThemeCard(lv_obj_t* parent, Context* ctx) {
auto* card = createSection(parent, "Theme");
auto* mode_row = createLabeledRow(card, "Mode");
ctx->lightChip = createChip(mode_row, "Light", onLightPressed, ctx);
ctx->darkChip = createChip(mode_row, "Dark", onDarkPressed, ctx);
// Monochrome and greyscale displays always use the mono theme, and it doesn't use colours
if (!ctx->isMonoDisplay) {
auto* style_row = createLabeledRow(card, "Style");
ctx->regularThemeChip = createChip(style_row, "Regular", onRegularThemePressed, ctx);
ctx->monoThemeChip = createChip(style_row, "Mono", onMonoThemePressed, ctx);
}
auto* animations_checkbox = lv_checkbox_create(card);
lv_checkbox_set_text(animations_checkbox, "Animations");
setChecked(animations_checkbox, ctx->pendingSettings.animationsEnabled);
lv_obj_add_event_cb(animations_checkbox, onAnimationsChanged, LV_EVENT_VALUE_CHANGED, ctx);
if (!ctx->isMonoDisplay) {
for (size_t i = 0; i < COLOR_COUNT; i++) {
ctx->colorRows[i] = createColorRow(card, i, ctx);
}
}
}
lv_obj_t* createButton(lv_obj_t* parent, const char* text, lv_event_cb_t callback, Context* ctx) {
auto* button = lv_button_create(parent);
auto* label = lv_label_create(button);
lv_label_set_text(label, text);
lv_obj_add_event_cb(button, callback, LV_EVENT_SHORT_CLICKED, ctx);
return button;
}
FontRowWidgets createFontRow(lv_obj_t* parent, const char* title, lv_event_cb_t onSelect, lv_event_cb_t onDefault, Context* ctx) {
FontRowWidgets widgets;
// "Title [Default]"
auto* title_row = createRow(parent);
auto* label = lv_label_create(title_row);
lv_label_set_text(label, title);
lv_obj_set_flex_grow(label, 1);
lv_obj_set_style_pad_ver(label, 0, LV_STATE_DEFAULT);
widgets.defaultButton = createButton(title_row, "Default", onDefault, ctx);
// " file.ttf [Select]"
auto* file_row = createRow(parent);
widgets.fileLabel = lv_label_create(file_row);
lv_obj_set_flex_grow(widgets.fileLabel, 1);
lv_label_set_long_mode(widgets.fileLabel, LV_LABEL_LONG_MODE_DOTS);
lv_obj_set_style_pad_left(widgets.fileLabel, 12, LV_STATE_DEFAULT);
createButton(file_row, "Select", onSelect, ctx);
widgets.warningLabel = lv_label_create(parent);
lv_obj_set_width(widgets.warningLabel, LV_PCT(100));
lv_label_set_long_mode(widgets.warningLabel, LV_LABEL_LONG_MODE_WRAP);
lv_obj_set_style_text_color(widgets.warningLabel, lv_palette_main(LV_PALETTE_ORANGE), LV_STATE_DEFAULT);
return widgets;
}
void createWidgets(lv_obj_t* parent, void* userData) {
auto* ctx = static_cast<Context*>(userData);
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
auto* toolbar = lvgl_toolbar_create(parent, "Appearance");
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
ctx->spinner = lvgl_toolbar_add_spinner_action(toolbar);
ctx->applyButton = lvgl_toolbar_add_image_button_action(toolbar, LV_SYMBOL_OK, onApplyPressed, ctx);
auto* content = lv_obj_create(parent);
lv_obj_set_width(content, LV_PCT(100));
lv_obj_set_flex_grow(content, 1);
lv_obj_set_flex_flow(content, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_border_width(content, 0, LV_STATE_DEFAULT);
const auto color_format = lv_display_get_color_format(lv_obj_get_display(parent));
ctx->isMonoDisplay = color_format == LV_COLOR_FORMAT_I1 || color_format == LV_COLOR_FORMAT_L8;
createThemeCard(content, ctx);
auto* fonts_card = createSection(content, "Fonts");
auto* font_size_row = createRow(fonts_card);
auto* font_size_label = lv_label_create(font_size_row);
lv_label_set_text(font_size_label, "Font size");
lv_obj_set_flex_grow(font_size_label, 1);
// [-] [size] [+]
auto* font_size_spinbox = lv_spinbox_create(font_size_row);
lv_spinbox_set_range(font_size_spinbox, MIN_FONT_SIZE, MAX_FONT_SIZE);
lv_spinbox_set_digit_format(font_size_spinbox, 2, 0);
lv_spinbox_set_step(font_size_spinbox, 1);
// Wide enough for the digits and the room that the text field keeps for its cursor, so it never scrolls the digits out of view.
// The size is set before the value, because setting the value scrolls to the cursor using the current size.
const lv_font_t* font_size_font = lv_obj_get_style_text_font(font_size_spinbox, LV_PART_MAIN);
lv_point_t digits_size;
lv_text_get_size(&digits_size, "00", font_size_font, 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE);
const int32_t font_size_content_width = digits_size.x + lv_font_get_line_height(font_size_font);
lv_obj_set_width(font_size_spinbox, font_size_content_width +
lv_obj_get_style_pad_left(font_size_spinbox, LV_PART_MAIN) + lv_obj_get_style_pad_right(font_size_spinbox, LV_PART_MAIN) +
2 * lv_obj_get_style_border_width(font_size_spinbox, LV_PART_MAIN));
lv_obj_set_style_text_align(font_size_spinbox, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN);
const uint16_t font_size = ctx->pendingSettings.fontSize != 0 ? ctx->pendingSettings.fontSize : static_cast<uint16_t>(TT_FONT_DEFAULT_SIZE);
lv_spinbox_set_value(font_size_spinbox, font_size);
// The value is changed with the buttons, so the digit cursor isn't shown
lv_obj_set_style_bg_opa(font_size_spinbox, LV_OPA_TRANSP, LV_PART_CURSOR);
lv_obj_add_event_cb(font_size_spinbox, onFontSizeChanged, LV_EVENT_VALUE_CHANGED, ctx);
auto* decrement_button = createStepButton(font_size_row, LV_SYMBOL_MINUS, onFontSizeDecrementPressed, font_size_spinbox);
lv_obj_move_to_index(decrement_button, lv_obj_get_index(font_size_spinbox));
createStepButton(font_size_row, LV_SYMBOL_PLUS, onFontSizeIncrementPressed, font_size_spinbox);
ctx->regularRow = createFontRow(fonts_card, "Regular font", onSelectRegularPressed, onDefaultRegularPressed, ctx);
ctx->monoRow = createFontRow(fonts_card, "Mono font", onSelectMonoPressed, onDefaultMonoPressed, ctx);
ctx->errorLabel = lv_label_create(fonts_card);
lv_obj_set_width(ctx->errorLabel, LV_PCT(100));
lv_label_set_long_mode(ctx->errorLabel, LV_LABEL_LONG_MODE_WRAP);
lv_label_set_text(ctx->errorLabel, "Failed to create the fonts. Check that the selected files are TrueType fonts.");
lv_obj_set_style_text_color(ctx->errorLabel, lv_palette_main(LV_PALETTE_RED), LV_STATE_DEFAULT);
updateWidgets(ctx);
}
void destroyWidgets(void* userData) {
auto* ctx = static_cast<Context*>(userData);
ctx->applyButton = nullptr;
ctx->spinner = nullptr;
ctx->regularRow = {};
ctx->monoRow = {};
ctx->errorLabel = nullptr;
ctx->lightChip = nullptr;
ctx->darkChip = nullptr;
ctx->regularThemeChip = nullptr;
ctx->monoThemeChip = nullptr;
for (auto& row : ctx->colorRows) {
row = {};
}
}
int32_t applyThreadMain(void* context) {
auto* ctx = static_cast<Context*>(context);
ctx->cacheUpdateSucceeded = lvgl::updateFontCache(ctx->applyConfiguration);
task_event_group_signal(ctx->eventGroup, ctx->cacheUpdatedBit);
return 0;
}
/** Ends an apply without a font error, keeping the changes pending */
void cancelApply(Context* ctx) {
lvgl_lock();
ctx->applying = false;
updateWidgets(ctx);
lvgl_unlock();
}
void finishApply(Context* ctx);
void startApply(Context* ctx) {
if (ctx->applying || ctx->applyThread != nullptr) {
return;
}
lvgl_lock();
ctx->applyConfiguration = toFontConfiguration(ctx->pendingSettings);
const bool fonts_changed = ctx->applyConfiguration != toFontConfiguration(ctx->savedSettings);
ctx->applying = true;
ctx->applyFailed = false;
updateWidgets(ctx);
lvgl_unlock();
// Theme changes don't need new fonts
if (!fonts_changed) {
ctx->cacheUpdateSucceeded = true;
finishApply(ctx);
return;
}
ctx->applyThread = thread_alloc();
if (ctx->applyThread == nullptr) {
LOG_E(TAG, "Failed to create thread");
cancelApply(ctx);
return;
}
thread_set_name(ctx->applyThread, "font_cache");
thread_set_stack_size(ctx->applyThread, APPLY_THREAD_STACK_SIZE);
thread_set_main_function(ctx->applyThread, applyThreadMain, ctx);
if (thread_start(ctx->applyThread) != ERROR_NONE) {
LOG_E(TAG, "Failed to start thread");
thread_free(ctx->applyThread);
ctx->applyThread = nullptr;
cancelApply(ctx);
}
}
void joinApplyThread(Context* ctx) {
if (ctx->applyThread != nullptr) {
thread_join(ctx->applyThread, portMAX_DELAY, pdMS_TO_TICKS(10));
thread_free(ctx->applyThread);
ctx->applyThread = nullptr;
}
}
void finishApply(Context* ctx) {
joinApplyThread(ctx);
if (!ctx->cacheUpdateSucceeded) {
LOG_E(TAG, "Failed to update the font cache");
lvgl_lock();
ctx->applying = false;
ctx->applyFailed = true;
updateWidgets(ctx);
lvgl_unlock();
return;
}
lvgl_lock();
const auto applied_settings = ctx->pendingSettings;
lvgl_unlock();
if (!settings::appearance::save(applied_settings)) {
// Fonts that aren't saved would revert on the next boot: keep the changes pending for a retry
LOG_E(TAG, "Failed to save settings");
cancelApply(ctx);
return;
}
lvgl_lock();
ctx->savedSettings = applied_settings;
ctx->applying = false;
lvgl_unlock();
// The window is rebuilt from the saved settings when LVGL starts again
LOG_I(TAG, "Restarting LVGL with the new settings");
lvgl::stop();
lvgl::configureTheme(applied_settings);
lvgl::loadFonts(ctx->applyConfiguration);
lvgl::start();
}
void startFileSelection(Context* ctx, FontSlot slot) {
if (ctx->selectLaunchId != 0 || ctx->applying) {
return;
}
ctx->selectSlot = slot;
ctx->selectLaunchId = fileselection::startForExistingFile(ctx->appInstanceId, ctx->selectStream, ctx->selectBuffer, sizeof(ctx->selectBuffer), ctx->eventGroup);
}
void startColorSelection(Context* ctx) {
if (ctx->colorSelectLaunchId != 0 || ctx->applying) {
return;
}
std::vector<std::string> items;
items.reserve(PALETTE_COLOR_COUNT + 1);
items.emplace_back("Default");
for (const auto& named_color : PALETTE_COLORS) {
items.emplace_back(named_color.name);
}
ctx->colorSelectLaunchId = selectiondialog::start(ctx->appInstanceId, COLOR_TITLES[ctx->selectColorIndex], items);
}
void onColorSelectionResult(Context* ctx, const AppEvent& event) {
ctx->colorSelectLaunchId = 0;
// Index 0 is "Default", the others are the palette colours. Other results mean that the dialog was dismissed.
const int32_t selected = event.result.result;
if (selected >= 0 && selected <= static_cast<int32_t>(PALETTE_COLOR_COUNT)) {
lvgl_lock();
auto& color = getColor(ctx->pendingSettings, ctx->selectColorIndex);
if (selected == 0) {
color.reset();
} else {
color = getPaletteColor(PALETTE_COLORS[selected - 1].palette);
}
updateWidgets(ctx);
lvgl_unlock();
}
app_manager_stop(event.result.launch_id);
}
void onFileSelectionResult(Context* ctx, const AppEvent& event) {
ctx->selectLaunchId = 0;
if (event.result.result == 0 /* Ok */) {
char path[sizeof(ctx->selectBuffer)];
const size_t length = app_stream_read(&ctx->selectStream, path, sizeof(path));
app_stream_unsubscribe(&ctx->selectStream);
if (length > 0) {
const auto selected_path = std::string(path, length);
const size_t character_count = getCharacterCount(selected_path);
lvgl_lock();
getFontPath(ctx->pendingSettings, ctx->selectSlot) = selected_path;
getCharacterCount(ctx, ctx->selectSlot) = character_count;
updateWidgets(ctx);
lvgl_unlock();
}
} else {
app_stream_unsubscribe(&ctx->selectStream);
}
app_manager_stop(event.result.launch_id);
}
int32_t appMain(int argc, char* argv[]) {
Context ctx;
ctx.appInstanceId = app_scheduler_current_app_id();
ctx.savedSettings = settings::appearance::loadOrGetDefault();
ctx.pendingSettings = ctx.savedSettings;
ctx.regularCharacterCount = getCharacterCount(ctx.pendingSettings.regularFontPath);
ctx.monoCharacterCount = getCharacterCount(ctx.pendingSettings.monoFontPath);
TaskEventGroup event_group {};
task_event_group_construct(&event_group);
ctx.eventGroup = &event_group;
check(task_event_group_claim_bit(&event_group, &ctx.selectRegularBit) == ERROR_NONE);
check(task_event_group_claim_bit(&event_group, &ctx.selectMonoBit) == ERROR_NONE);
check(task_event_group_claim_bit(&event_group, &ctx.selectColorBit) == ERROR_NONE);
check(task_event_group_claim_bit(&event_group, &ctx.applyBit) == ERROR_NONE);
check(task_event_group_claim_bit(&event_group, &ctx.cacheUpdatedBit) == ERROR_NONE);
AppEventSubscription sub {};
check(app_event_subscribe(&sub, &event_group) == ERROR_NONE);
WindowId window = window_manager_create_ext(ctx.appInstanceId, createWidgets, destroyWidgets, &ctx);
bool should_close = false;
while (!should_close) {
uint32_t flags = 0;
task_event_group_wait_any(&event_group, &flags, portMAX_DELAY);
if (flags & ctx.selectRegularBit) {
startFileSelection(&ctx, FontSlot::Regular);
}
if (flags & ctx.selectMonoBit) {
startFileSelection(&ctx, FontSlot::Mono);
}
if (flags & ctx.selectColorBit) {
startColorSelection(&ctx);
}
if (flags & ctx.applyBit) {
startApply(&ctx);
}
if (flags & ctx.cacheUpdatedBit) {
finishApply(&ctx);
}
AppEvent event {};
while (app_event_poll(&sub, &event) == ERROR_NONE) {
if (event.type == APP_EVENT_CLOSE) {
should_close = true;
} else if (event.type == APP_EVENT_RESULT && event.result.launch_id == ctx.selectLaunchId) {
onFileSelectionResult(&ctx, event);
} else if (event.type == APP_EVENT_RESULT && event.result.launch_id == ctx.colorSelectLaunchId) {
onColorSelectionResult(&ctx, event);
}
}
}
// The thread uses the context
joinApplyThread(&ctx);
window_manager_remove(window);
check(app_event_unsubscribe(&sub) == ERROR_NONE);
task_event_group_destruct(&event_group);
return 0;
}
} // namespace
extern const ::AppManifest manifest = {
.id = "tactility.appearance",
.name = "Appearance",
.category = APP_CATEGORY_SETTINGS,
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) },
.flags = 0,
.stack = {}
};
} // namespace tt::app::appearancesettings