App UX/UI updates and more (#678)

- Refactored many apps' UI/UX
- Improve material themes
- Fix for Tab5 flickering on v1
- Extend dts parser for i2c address/reg support in code
- Fix for I2C Scanner glitch where it would show devices that aren't connected
- Add name labels to I2C Scanner's scanned devices
This commit is contained in:
Ken Van Hoeylandt 2026-10-08 22:02:18 +02:00 committed by GitHub
parent 0f12fcce53
commit 24ef79070a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
64 changed files with 2459 additions and 1373 deletions

View File

@ -2,6 +2,7 @@ import os.path
from textwrap import dedent from textwrap import dedent
from source.models import * from source.models import *
from .exception import DevicetreeException from .exception import DevicetreeException
from .printing import print_warning
def write_include(file, include: IncludeC, verbose: bool): def write_include(file, include: IncludeC, verbose: bool):
if verbose: if verbose:
@ -59,6 +60,35 @@ def find_phandle(devices: list[Device], phandle: str):
return f"&{get_device_node_name_safe(device)}" return f"&{get_device_node_name_safe(device)}"
raise DevicetreeException(f"phandle '{phandle}' not found in devicetree") raise DevicetreeException(f"phandle '{phandle}' not found in devicetree")
def parse_number(text: str):
try:
return int(text, 0)
except ValueError:
return None
def get_device_address(device: Device) -> str:
"""The node's unit address (e.g. "display@0"), or else the first value of its "reg" property (e.g. an I2C address)."""
reg_property = find_device_property(device, "reg")
reg_value = None
if reg_property is not None:
if reg_property.type == "value":
reg_value = reg_property.value
elif reg_property.type == "values" and len(reg_property.value) > 0:
reg_value = str(reg_property.value[0])
if device.node_address is None:
return reg_value if reg_value is not None else "0"
# Unit addresses are hexadecimal, like in Linux
try:
unit_address = int(device.node_address, 16)
except ValueError:
raise DevicetreeException(f"Unit address of {device.node_name}@{device.node_address} isn't a hexadecimal number")
# Like Linux's dtc, the unit address should match the first "reg" value
if reg_value is not None:
reg_number = parse_number(reg_value)
if reg_number is not None and unit_address != reg_number:
print_warning(f"{device.node_name}@{device.node_address} has a different reg value: {reg_value}")
return str(unit_address)
def property_to_string(property: DeviceProperty, devices: list[Device]) -> str: def property_to_string(property: DeviceProperty, devices: list[Device]) -> str:
type = property.type type = property.type
if type == "value" or type == "int": if type == "value" or type == "int":
@ -277,7 +307,7 @@ def write_device_structs(file, device: Device, parent_device: Device, bindings:
# Write config struct # Write config struct
write_config(file, device, bindings, devices, type_name) write_config(file, device, bindings, devices, type_name)
# Write device struct # Write device struct
address_value = device.node_address if device.node_address is not None else "0" address_value = get_device_address(device)
file.write(f"static struct Device {node_name}" " = {\n") file.write(f"static struct Device {node_name}" " = {\n")
file.write(f"\t.address = {address_value},\n") file.write(f"\t.address = {address_value},\n")
file.write(f"\t.name = \"{device.node_name}\",\n") # Use original name file.write(f"\t.name = \"{device.node_name}\",\n") # Use original name

View File

@ -295,6 +295,86 @@ def test_array_property_defaults_to_null_when_absent():
print("PASSED") print("PASSED")
return True return True
def write_reg_config(tmp_dir, node):
config_dir = os.path.join(tmp_dir, "reg_data")
bindings_dir = os.path.join(config_dir, "bindings")
os.makedirs(bindings_dir)
with open(os.path.join(config_dir, "module.yaml"), "w") as f:
f.write("dts: test.dts\nbindings: bindings")
with open(os.path.join(config_dir, "test.dts"), "w") as f:
f.write(f"""/dts-v1/;
/ {{
compatible = "test,root";
model = "Test Model";
{node} {{
compatible = "test,reg-device";
reg = <0x5D>;
}};
}};
""")
with open(os.path.join(bindings_dir, "test,root.yaml"), "w") as f:
f.write("description: Test root binding\ncompatible: \"test,root\"\nproperties:\n model:\n type: string\n")
with open(os.path.join(bindings_dir, "test,reg-device.yaml"), "w") as f:
f.write("description: Test reg binding\ncompatible: \"test,reg-device\"\nproperties:\n reg:\n type: int\n")
return config_dir
def test_reg_is_address_without_unit_address():
print("Running test_reg_is_address_without_unit_address...")
with tempfile.TemporaryDirectory() as tmp_dir:
config_dir = write_reg_config(tmp_dir, "touch")
output_dir = os.path.join(tmp_dir, "output")
os.makedirs(output_dir)
result = run_compiler(config_dir, output_dir)
if result.returncode != 0:
print(f"FAILED: Compilation should have succeeded: {result.stderr} {result.stdout}")
return False
with open(os.path.join(output_dir, "devicetree.c")) as f:
generated = f.read()
if ".address = 0x5D," not in generated:
print(f"FAILED: Expected the reg value as address:\n{generated}")
return False
print("PASSED")
return True
def test_unit_address_mismatch_warns():
print("Running test_unit_address_mismatch_warns...")
with tempfile.TemporaryDirectory() as tmp_dir:
config_dir = write_reg_config(tmp_dir, "touch@10")
output_dir = os.path.join(tmp_dir, "output")
os.makedirs(output_dir)
result = run_compiler(config_dir, output_dir)
if result.returncode != 0:
print(f"FAILED: Compilation should have succeeded: {result.stderr} {result.stdout}")
return False
if "different reg value" not in result.stdout:
print(f"FAILED: Expected a warning, got: {result.stdout}")
return False
with open(os.path.join(output_dir, "devicetree.c")) as f:
generated = f.read()
if ".address = 16," not in generated:
print(f"FAILED: Expected the unit address as address:\n{generated}")
return False
print("PASSED")
return True
def test_compile_missing_config(): def test_compile_missing_config():
print("Running test_compile_missing_config...") print("Running test_compile_missing_config...")
with tempfile.TemporaryDirectory() as output_dir: with tempfile.TemporaryDirectory() as output_dir:
@ -322,7 +402,9 @@ if __name__ == "__main__":
test_minmax_out_of_range_default_fails, test_minmax_out_of_range_default_fails,
test_minmax_symbolic_value_skips_validation, test_minmax_symbolic_value_skips_validation,
test_array_property_generates_static_array_and_length, test_array_property_generates_static_array_and_length,
test_array_property_defaults_to_null_when_absent test_array_property_defaults_to_null_when_absent,
test_reg_is_address_without_unit_address,
test_unit_address_mismatch_warns
] ]
failed = 0 failed = 0

View File

@ -113,7 +113,7 @@ void tab5_create_devices_v1(Device* i2c0) {
.dsi_bus_id = 0, .dsi_bus_id = 0,
.num_data_lanes = 2, .num_data_lanes = 2,
.lane_bit_rate_mbps = 1000, .lane_bit_rate_mbps = 1000,
.dpi_clock_freq_mhz = 60, .dpi_clock_freq_mhz = 80,
.hsync_pulse_width = 40, .hsync_pulse_width = 40,
.hsync_back_porch = 140, .hsync_back_porch = 140,
.hsync_front_porch = 40, .hsync_front_porch = 40,

View File

@ -7,6 +7,7 @@
## Higher Priority ## Higher Priority
- Wi-Fi and Bluetooth settings app: scanning should start and gather results, then it should stop refreshing the scan results automatically. When scanning is finished, a paginated view should be shown with the results (as many as fit on the screen, but not more). There should be an icon on the toolbar to refresh scanning (looking glass? something else?) and this icon is only visible when the radio is enabled. When radio is enabled by the user, automatically start a scan. If the radio was already enabled and the app starts, start scanning.
- Update to LVGL 9.6.x (ESP-IDF and POSIX) - Update to LVGL 9.6.x (ESP-IDF and POSIX)
- Terminal app: on devices with pointer device: LVGL rendering mode should hide the toolbar by default, but tapping the terminal should toggle the visibility of the toolbar. - Terminal app: on devices with pointer device: LVGL rendering mode should hide the toolbar by default, but tapping the terminal should toggle the visibility of the toolbar.
- Terminal app: should always run in non-LVGL mode if no PSRAM is present. - Terminal app: should always run in non-LVGL mode if no PSRAM is present.

View File

@ -19,6 +19,7 @@ ESP_PLATFORM` in `source/module.cpp`; symbols outside that gate (e.g. `__cxa_pur
- `operator new(unsigned int)` / `operator delete(void*, unsigned int)` (`_Znwj` / `_ZdlPvj`) - `operator new(unsigned int)` / `operator delete(void*, unsigned int)` (`_Znwj` / `_ZdlPvj`)
- `operator new[](unsigned int)` / `operator delete[](void*, unsigned int)` (`_Znaj` / `_ZdaPvj`) - `operator new[](unsigned int)` / `operator delete[](void*, unsigned int)` (`_Znaj` / `_ZdaPvj`)
- `operator new[](unsigned int, std::nothrow_t const&)` (`_ZnajRKSt9nothrow_t`)
- `operator delete(void*)` / `operator delete[](void*)` (`_ZdlPv` / `_ZdaPv`) - the unsized forms, - `operator delete(void*)` / `operator delete[](void*)` (`_ZdlPv` / `_ZdaPv`) - the unsized forms,
used instead of the above when the compiler determines no size is needed used instead of the above when the compiler determines no size is needed
- `std::nothrow` - `std::nothrow`

View File

@ -31,6 +31,7 @@ extern "C" {
extern void* _Znwj(uint32_t size); // operator new(unsigned int) extern void* _Znwj(uint32_t size); // operator new(unsigned int)
extern void _ZdlPvj(void* p, uint64_t size); // operator delete(void*, unsigned int) extern void _ZdlPvj(void* p, uint64_t size); // operator delete(void*, unsigned int)
extern void* _Znaj(uint32_t size); // operator new[](unsigned int) extern void* _Znaj(uint32_t size); // operator new[](unsigned int)
extern void* _ZnajRKSt9nothrow_t(uint32_t size, const std::nothrow_t& tag); // operator new[](unsigned int, std::nothrow_t const&)
extern void _ZdaPvj(void* p, uint64_t size); // operator delete[](void*, unsigned int) extern void _ZdaPvj(void* p, uint64_t size); // operator delete[](void*, unsigned int)
// Unsized forms: the compiler picks these over the sized ones above depending on context // Unsized forms: the compiler picks these over the sized ones above depending on context
// (e.g. trivially-destructible types needing no array cookie), so both must be exported. // (e.g. trivially-destructible types needing no array cookie), so both must be exported.
@ -572,6 +573,7 @@ static const ModuleSymbol SYMBOLS[] = {
DEFINE_MODULE_SYMBOL(_Znwj), // operator new(unsigned int) DEFINE_MODULE_SYMBOL(_Znwj), // operator new(unsigned int)
DEFINE_MODULE_SYMBOL(_ZdlPvj), // operator delete(void*, unsigned int) DEFINE_MODULE_SYMBOL(_ZdlPvj), // operator delete(void*, unsigned int)
DEFINE_MODULE_SYMBOL(_Znaj), // operator new[](unsigned int) DEFINE_MODULE_SYMBOL(_Znaj), // operator new[](unsigned int)
DEFINE_MODULE_SYMBOL(_ZnajRKSt9nothrow_t), // operator new[](unsigned int, std::nothrow_t const&)
DEFINE_MODULE_SYMBOL(_ZdaPvj), // operator delete[](void*, unsigned int) DEFINE_MODULE_SYMBOL(_ZdaPvj), // operator delete[](void*, unsigned int)
DEFINE_MODULE_SYMBOL(_ZdlPv), // operator delete(void*) DEFINE_MODULE_SYMBOL(_ZdlPv), // operator delete(void*)
DEFINE_MODULE_SYMBOL(_ZdaPv), // operator delete[](void*) DEFINE_MODULE_SYMBOL(_ZdaPv), // operator delete[](void*)

View File

@ -149,8 +149,10 @@ shared_symbol_code_point_names = [
"download", "download",
"forum", # Chat app "forum", # Chat app
"gamepad", "gamepad",
"hard_disk", # System Info
"help", # Diceware help "help", # Diceware help
"hub", # App Hub "hub", # App Hub
"info", # System Info
"image", # Screenshot app "image", # Screenshot app
"keyboard_arrow_up", "keyboard_arrow_up",
"lightbulb", "lightbulb",
@ -158,6 +160,7 @@ shared_symbol_code_point_names = [
"lists", # Chat app toolbar "lists", # Chat app toolbar
"mail", "mail",
"menu", "menu",
"memory", # System Info
"mop", "mop",
"more_vert", "more_vert",
"music_note", "music_note",
@ -166,6 +169,7 @@ shared_symbol_code_point_names = [
"power_settings_new", # Power off for T-Lora Pager "power_settings_new", # Power off for T-Lora Pager
"refresh", # e.g. App Hub reload button "refresh", # e.g. App Hub reload button
"search", "search",
"select_window_2", # System Info
"settings", "settings",
"toolbar", # Apps without custom icon "toolbar", # Apps without custom icon
"navigation", # GPS (settings) app "navigation", # GPS (settings) app
@ -177,6 +181,7 @@ shared_symbol_code_point_names = [
"logo_dev", "logo_dev",
"camera", "camera",
"lightstrip", "lightstrip",
"phone_android", # Display settings: orientation
] ]
# Get more from https://fonts.google.com/icons?icon.set=Material+Symbols&icon.style=Rounded # Get more from https://fonts.google.com/icons?icon.set=Material+Symbols&icon.style=Rounded

View File

@ -9,7 +9,7 @@ extern "C" {
#endif #endif
/** Changes whenever the icon TTF subset, its codepoints file or the icon name lists change */ /** Changes whenever the icon TTF subset, its codepoints file or the icon name lists change */
#define LVGL_ICON_FONT_VERSION 0x1B608834u #define LVGL_ICON_FONT_VERSION 0xC28E1A92u
// Icon names per icon font, resolved through the .codepoints file next to the TTF // Icon names per icon font, resolved through the .codepoints file next to the TTF
extern const char* const lvgl_icon_shared_names[]; extern const char* const lvgl_icon_shared_names[];

View File

@ -22,8 +22,10 @@
#define LVGL_ICON_SHARED_DOWNLOAD "\xEF\x82\x90" #define LVGL_ICON_SHARED_DOWNLOAD "\xEF\x82\x90"
#define LVGL_ICON_SHARED_FORUM "\xEE\xA2\xAF" #define LVGL_ICON_SHARED_FORUM "\xEE\xA2\xAF"
#define LVGL_ICON_SHARED_GAMEPAD "\xEE\x8C\x8F" #define LVGL_ICON_SHARED_GAMEPAD "\xEE\x8C\x8F"
#define LVGL_ICON_SHARED_HARD_DISK "\xEF\x8F\x9A"
#define LVGL_ICON_SHARED_HELP "\xEE\xA3\xBD" #define LVGL_ICON_SHARED_HELP "\xEE\xA3\xBD"
#define LVGL_ICON_SHARED_HUB "\xEE\xA7\xB4" #define LVGL_ICON_SHARED_HUB "\xEE\xA7\xB4"
#define LVGL_ICON_SHARED_INFO "\xEE\xA2\x8E"
#define LVGL_ICON_SHARED_IMAGE "\xEE\x8F\xB4" #define LVGL_ICON_SHARED_IMAGE "\xEE\x8F\xB4"
#define LVGL_ICON_SHARED_KEYBOARD_ARROW_UP "\xEE\x8C\x96" #define LVGL_ICON_SHARED_KEYBOARD_ARROW_UP "\xEE\x8C\x96"
#define LVGL_ICON_SHARED_LIGHTBULB "\xEE\xA4\x8F" #define LVGL_ICON_SHARED_LIGHTBULB "\xEE\xA4\x8F"
@ -31,6 +33,7 @@
#define LVGL_ICON_SHARED_LISTS "\xEE\xA6\xB9" #define LVGL_ICON_SHARED_LISTS "\xEE\xA6\xB9"
#define LVGL_ICON_SHARED_MAIL "\xEE\x85\x99" #define LVGL_ICON_SHARED_MAIL "\xEE\x85\x99"
#define LVGL_ICON_SHARED_MENU "\xEE\x97\x92" #define LVGL_ICON_SHARED_MENU "\xEE\x97\x92"
#define LVGL_ICON_SHARED_MEMORY "\xEE\x8C\xA2"
#define LVGL_ICON_SHARED_MOP "\xEE\x8A\x8D" #define LVGL_ICON_SHARED_MOP "\xEE\x8A\x8D"
#define LVGL_ICON_SHARED_MORE_VERT "\xEE\x97\x94" #define LVGL_ICON_SHARED_MORE_VERT "\xEE\x97\x94"
#define LVGL_ICON_SHARED_MUSIC_NOTE "\xEE\x90\x85" #define LVGL_ICON_SHARED_MUSIC_NOTE "\xEE\x90\x85"
@ -39,6 +42,7 @@
#define LVGL_ICON_SHARED_POWER_SETTINGS_NEW "\xEF\xA3\x87" #define LVGL_ICON_SHARED_POWER_SETTINGS_NEW "\xEF\xA3\x87"
#define LVGL_ICON_SHARED_REFRESH "\xEE\x97\x95" #define LVGL_ICON_SHARED_REFRESH "\xEE\x97\x95"
#define LVGL_ICON_SHARED_SEARCH "\xEE\xBD\xBA" #define LVGL_ICON_SHARED_SEARCH "\xEE\xBD\xBA"
#define LVGL_ICON_SHARED_SELECT_WINDOW_2 "\xEF\x93\x88"
#define LVGL_ICON_SHARED_SETTINGS "\xEE\xA2\xB8" #define LVGL_ICON_SHARED_SETTINGS "\xEE\xA2\xB8"
#define LVGL_ICON_SHARED_TOOLBAR "\xEE\xA7\xB7" #define LVGL_ICON_SHARED_TOOLBAR "\xEE\xA7\xB7"
#define LVGL_ICON_SHARED_NAVIGATION "\xEE\x95\x9D" #define LVGL_ICON_SHARED_NAVIGATION "\xEE\x95\x9D"
@ -50,3 +54,4 @@
#define LVGL_ICON_SHARED_LOGO_DEV "\xEE\xAB\x96" #define LVGL_ICON_SHARED_LOGO_DEV "\xEE\xAB\x96"
#define LVGL_ICON_SHARED_CAMERA "\xEE\x8E\xAF" #define LVGL_ICON_SHARED_CAMERA "\xEE\x8E\xAF"
#define LVGL_ICON_SHARED_LIGHTSTRIP "\xF3\xBF\xBD\xB5" #define LVGL_ICON_SHARED_LIGHTSTRIP "\xF3\xBF\xBD\xB5"
#define LVGL_ICON_SHARED_PHONE_ANDROID "\xEF\x8B\x9B"

View File

@ -49,11 +49,11 @@ void lvgl_unlock(void);
bool lvgl_is_running(void); bool lvgl_is_running(void);
/** /**
* @brief Gets the desired UI density for the target hardware. * @brief Gets the UI density of the theme settings.
* The density is defined in the `device.properties` of a hardware device. * @deprecated Use lvgl_theme_is_compact() from <lvgl/theme.h>
* This setting is read by CMakeLists.txt and passed as a target compile definition of the LVGL module.
* @return the UI density * @return the UI density
*/ */
__attribute__((deprecated("Use lvgl_theme_is_compact()")))
enum UiDensity lvgl_get_ui_density(void); enum UiDensity lvgl_get_ui_density(void);
#ifdef __cplusplus #ifdef __cplusplus

View File

@ -17,6 +17,11 @@ typedef struct {
lv_color_t color_primary; lv_color_t color_primary;
lv_color_t color_secondary; lv_color_t color_secondary;
lv_color_t color_error; lv_color_t color_error;
/** Smaller spacing and controls, for small screens (see lvgl_theme_is_compact()) */
bool is_compact;
/** Use color_surface as the surface (screen background) colour, instead of the theme's own. Not used by the monochrome theme. */
bool surface_override;
lv_color_t color_surface;
} LvglThemeSettings; } LvglThemeSettings;
/** /**
@ -37,6 +42,13 @@ void lvgl_theme_get_default_settings(LvglThemeSettings* settings);
*/ */
bool lvgl_theme_is_mono(void); bool lvgl_theme_is_mono(void);
/**
* @brief Checks whether the theme settings use the compact UI density: smaller spacing and controls, for small screens.
* The device's default density is defined in its `device.properties`.
* @return true when the UI is compact
*/
bool lvgl_theme_is_compact(void);
/** /**
* @brief Sets the theme settings. They are used for displays that are added afterwards, e.g. when LVGL (re)starts. * @brief Sets the theme settings. They are used for displays that are added afterwards, e.g. when LVGL (re)starts.
* @param[in] settings the theme settings * @param[in] settings the theme settings

View File

@ -19,15 +19,115 @@ static void on_child_created(lv_event_t* event) {
} }
} }
// Matches which children grid navigation can focus
static bool is_focusable(lv_obj_t* obj) {
return !lv_obj_has_flag(obj, LV_OBJ_FLAG_HIDDEN) && lv_obj_has_flag(obj, static_cast<lv_obj_flag_t>(LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_CLICK_FOCUSABLE));
}
// Finds the next focusable child after (or before) the given index
static lv_obj_t* find_focusable_child(lv_obj_t* container, int32_t index, bool next) {
const auto child_count = static_cast<int32_t>(lv_obj_get_child_count(container));
for (index += next ? 1 : -1; index >= 0 && index < child_count; index += next ? 1 : -1) {
auto* child = lv_obj_get_child(container, index);
if (is_focusable(child)) {
return child;
}
}
return nullptr;
}
static int32_t get_center_x(lv_obj_t* obj) {
lv_area_t area;
lv_obj_get_coords(obj, &area);
return area.x1 + lv_area_get_width(&area) / 2;
}
static int32_t get_center_y(lv_obj_t* obj) {
lv_area_t area;
lv_obj_get_coords(obj, &area);
return area.y1 + lv_area_get_height(&area) / 2;
}
// Finds the child in the top (or bottom) row that's horizontally closest to the reference child.
// The reference child is returned when it's in that row already.
static lv_obj_t* find_edge_row_child(lv_obj_t* container, bool top, lv_obj_t* reference) {
const uint32_t child_count = lv_obj_get_child_count(container);
bool found = false;
int32_t edge_y = 0;
for (uint32_t i = 0; i < child_count; i++) {
auto* child = lv_obj_get_child(container, static_cast<int32_t>(i));
if (is_focusable(child)) {
const int32_t y = get_center_y(child);
if (!found || (top ? y < edge_y : y > edge_y)) {
edge_y = y;
found = true;
}
}
}
if (!found) {
return nullptr;
}
if (reference != nullptr && get_center_y(reference) == edge_y) {
return reference;
}
const int32_t reference_x = reference != nullptr ? get_center_x(reference) : 0;
lv_obj_t* closest = nullptr;
int32_t closest_distance = 0;
for (uint32_t i = 0; i < child_count; i++) {
auto* child = lv_obj_get_child(container, static_cast<int32_t>(i));
if (is_focusable(child) && get_center_y(child) == edge_y) {
const int32_t distance = LV_ABS(get_center_x(child) - reference_x);
if (closest == nullptr || distance < closest_distance) {
closest = child;
closest_distance = distance;
}
}
}
return closest;
}
// Grid navigation only scrolls the container itself, which isn't scrollable when e.g. it's part of a scrolling page
static void scroll_to_focused_child(lv_obj_t* container) {
lv_obj_t* child = lvgl_grid_navigation_get_focused(container);
if (child != nullptr) {
lv_obj_scroll_to_view_recursive(child, LV_ANIM_ON);
}
}
static void on_container_key(lv_event_t* event) {
scroll_to_focused_child(lv_event_get_current_target_obj(event));
}
// Grid navigation shows the focused child as selected whenever the container gets the focus. That's only // Grid navigation shows the focused child as selected whenever the container gets the focus. That's only
// right when a key moved the focus there, not when it moved because e.g. the previously focused widget was deleted. // right when a key moved the focus there, not when it moved because e.g. the previously focused widget was deleted.
static void on_container_focused(lv_event_t* event) { static void on_container_focused(lv_event_t* event) {
lv_indev_t* indev = lv_indev_active(); lv_indev_t* indev = lv_indev_active();
const lv_indev_type_t type = indev != nullptr ? lv_indev_get_type(indev) : LV_INDEV_TYPE_NONE; const lv_indev_type_t type = indev != nullptr ? lv_indev_get_type(indev) : LV_INDEV_TYPE_NONE;
if (type == LV_INDEV_TYPE_KEYPAD || type == LV_INDEV_TYPE_ENCODER) { lv_obj_t* container = lv_event_get_current_target_obj(event);
if (type == LV_INDEV_TYPE_KEYPAD) {
// Grid navigation restores the child that was focused last, but keys enter at the side they came from:
// up and down enter at the bottom or top row, the other keys at the last or first child
const uint32_t key = lv_indev_get_key(indev);
lv_obj_t* focused = lvgl_grid_navigation_get_focused(container);
lv_obj_t* child = nullptr;
if (key == LV_KEY_DOWN || key == LV_KEY_UP) {
child = find_edge_row_child(container, key == LV_KEY_DOWN, focused);
} else if (key == LV_KEY_NEXT || key == LV_KEY_RIGHT) {
child = find_focusable_child(container, -1, true);
} else if (key == LV_KEY_PREV || key == LV_KEY_LEFT) {
child = find_focusable_child(container, static_cast<int32_t>(lv_obj_get_child_count(container)), false);
}
if (child != nullptr && child != focused) {
lv_gridnav_set_focused(container, child, LV_ANIM_ON);
}
scroll_to_focused_child(container);
return; return;
} }
lv_obj_t* child = lvgl_grid_navigation_get_focused(lv_event_get_current_target_obj(event)); if (type == LV_INDEV_TYPE_ENCODER) {
scroll_to_focused_child(container);
return;
}
lv_obj_t* child = lvgl_grid_navigation_get_focused(container);
if (child != nullptr) { if (child != nullptr) {
lv_obj_remove_state(child, LV_STATE_FOCUS_KEY); lv_obj_remove_state(child, LV_STATE_FOCUS_KEY);
} }
@ -37,6 +137,7 @@ void lvgl_grid_navigation_add(lv_obj_t* container) {
lv_gridnav_add(container, LV_GRIDNAV_CTRL_NONE); lv_gridnav_add(container, LV_GRIDNAV_CTRL_NONE);
// Added after grid navigation's own handler, so it runs after it // Added after grid navigation's own handler, so it runs after it
lv_obj_add_event_cb(container, on_container_focused, LV_EVENT_FOCUSED, nullptr); lv_obj_add_event_cb(container, on_container_focused, LV_EVENT_FOCUSED, nullptr);
lv_obj_add_event_cb(container, on_container_key, LV_EVENT_KEY, nullptr);
lv_obj_add_flag(container, GRID_NAVIGATION_FLAG); lv_obj_add_flag(container, GRID_NAVIGATION_FLAG);
const uint32_t child_count = lv_obj_get_child_count(container); const uint32_t child_count = lv_obj_get_child_count(container);
@ -62,6 +163,7 @@ void lvgl_grid_navigation_remove(lv_obj_t* container) {
lv_gridnav_remove(container); lv_gridnav_remove(container);
lv_obj_remove_event_cb(container, on_child_created); lv_obj_remove_event_cb(container, on_child_created);
lv_obj_remove_event_cb(container, on_container_focused); lv_obj_remove_event_cb(container, on_container_focused);
lv_obj_remove_event_cb(container, on_container_key);
lv_obj_remove_flag(container, GRID_NAVIGATION_FLAG); lv_obj_remove_flag(container, GRID_NAVIGATION_FLAG);
if (group == nullptr) { if (group == nullptr) {
@ -80,23 +182,6 @@ void lvgl_grid_navigation_remove(lv_obj_t* container) {
} }
} }
// Matches which children grid navigation can focus
static bool is_focusable(lv_obj_t* obj) {
return !lv_obj_has_flag(obj, LV_OBJ_FLAG_HIDDEN) && lv_obj_has_flag(obj, static_cast<lv_obj_flag_t>(LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_CLICK_FOCUSABLE));
}
// Finds the next focusable child after (or before) the given index
static lv_obj_t* find_focusable_child(lv_obj_t* container, int32_t index, bool next) {
const auto child_count = static_cast<int32_t>(lv_obj_get_child_count(container));
for (index += next ? 1 : -1; index >= 0 && index < child_count; index += next ? 1 : -1) {
auto* child = lv_obj_get_child(container, index);
if (is_focusable(child)) {
return child;
}
}
return nullptr;
}
void lvgl_grid_navigation_step(lv_group_t* group, bool next) { void lvgl_grid_navigation_step(lv_group_t* group, bool next) {
lv_obj_t* focused = lv_group_get_focused(group); lv_obj_t* focused = lv_group_get_focused(group);
if (lvgl_grid_navigation_is_container(focused)) { if (lvgl_grid_navigation_is_container(focused)) {
@ -105,6 +190,7 @@ void lvgl_grid_navigation_step(lv_group_t* group, bool next) {
lv_obj_t* child = find_focusable_child(focused, index, next); lv_obj_t* child = find_focusable_child(focused, index, next);
if (child != nullptr) { if (child != nullptr) {
lv_gridnav_set_focused(focused, child, LV_ANIM_ON); lv_gridnav_set_focused(focused, child, LV_ANIM_ON);
scroll_to_focused_child(focused);
return; return;
} }
} }
@ -123,6 +209,7 @@ void lvgl_grid_navigation_step(lv_group_t* group, bool next) {
lv_obj_t* child = find_focusable_child(entered, start, next); lv_obj_t* child = find_focusable_child(entered, start, next);
if (child != nullptr) { if (child != nullptr) {
lv_gridnav_set_focused(entered, child, LV_ANIM_ON); lv_gridnav_set_focused(entered, child, LV_ANIM_ON);
scroll_to_focused_child(entered);
} }
} }
} }

View File

@ -24,8 +24,10 @@ const char* const lvgl_icon_shared_names[] = {
"download", "download",
"forum", "forum",
"gamepad", "gamepad",
"hard_disk",
"help", "help",
"hub", "hub",
"info",
"image", "image",
"keyboard_arrow_up", "keyboard_arrow_up",
"lightbulb", "lightbulb",
@ -33,6 +35,7 @@ const char* const lvgl_icon_shared_names[] = {
"lists", "lists",
"mail", "mail",
"menu", "menu",
"memory",
"mop", "mop",
"more_vert", "more_vert",
"music_note", "music_note",
@ -41,6 +44,7 @@ const char* const lvgl_icon_shared_names[] = {
"power_settings_new", "power_settings_new",
"refresh", "refresh",
"search", "search",
"select_window_2",
"settings", "settings",
"toolbar", "toolbar",
"navigation", "navigation",
@ -52,6 +56,7 @@ const char* const lvgl_icon_shared_names[] = {
"logo_dev", "logo_dev",
"camera", "camera",
"lightstrip", "lightstrip",
"phone_android",
}; };
const size_t lvgl_icon_shared_name_count = sizeof(lvgl_icon_shared_names) / sizeof(lvgl_icon_shared_names[0]); const size_t lvgl_icon_shared_name_count = sizeof(lvgl_icon_shared_names) / sizeof(lvgl_icon_shared_names[0]);

View File

@ -1,6 +1,7 @@
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
#include <lvgl/lvgl.h> #include <lvgl/lvgl.h>
#include <lvgl/theme.h>
enum UiDensity lvgl_get_ui_density(void) { enum UiDensity lvgl_get_ui_density(void) {
return TT_LVGL_UI_DENSITY; return lvgl_theme_is_compact() ? LVGL_UI_DENSITY_COMPACT : LVGL_UI_DENSITY_DEFAULT;
} }

View File

@ -81,6 +81,7 @@ const struct ModuleSymbol lvgl_module_symbols[] = {
DEFINE_MODULE_SYMBOL(lvgl_theme_get_default_settings), DEFINE_MODULE_SYMBOL(lvgl_theme_get_default_settings),
DEFINE_MODULE_SYMBOL(lvgl_theme_set_settings), DEFINE_MODULE_SYMBOL(lvgl_theme_set_settings),
DEFINE_MODULE_SYMBOL(lvgl_theme_is_mono), DEFINE_MODULE_SYMBOL(lvgl_theme_is_mono),
DEFINE_MODULE_SYMBOL(lvgl_theme_is_compact),
// lvgl_badge // lvgl_badge
DEFINE_MODULE_SYMBOL(lvgl_badge_create), DEFINE_MODULE_SYMBOL(lvgl_badge_create),
DEFINE_MODULE_SYMBOL(lvgl_page_indicator_create), DEFINE_MODULE_SYMBOL(lvgl_page_indicator_create),

View File

@ -29,6 +29,9 @@ void lvgl_theme_get_default_settings(LvglThemeSettings* settings) {
settings->color_primary = material_config.color_primary; settings->color_primary = material_config.color_primary;
settings->color_secondary = material_config.color_secondary; settings->color_secondary = material_config.color_secondary;
settings->color_error = material_config.color_error; settings->color_error = material_config.color_error;
settings->is_compact = TT_LVGL_UI_DENSITY == LVGL_UI_DENSITY_COMPACT;
settings->surface_override = material_config.surface_override;
settings->color_surface = material_config.color_surface;
} }
void lvgl_theme_get_settings(LvglThemeSettings* settings) { void lvgl_theme_get_settings(LvglThemeSettings* settings) {
@ -48,6 +51,12 @@ bool lvgl_theme_is_mono() {
return active_theme == ActiveTheme::MaterialMono; return active_theme == ActiveTheme::MaterialMono;
} }
bool lvgl_theme_is_compact() {
LvglThemeSettings settings;
lvgl_theme_get_settings(&settings);
return settings.is_compact;
}
static lv_theme_t* get_active_theme() { static lv_theme_t* get_active_theme() {
switch (active_theme) { switch (active_theme) {
case ActiveTheme::Material: case ActiveTheme::Material:
@ -76,7 +85,7 @@ void lvgl_theme_deinit() {
lv_theme_t* lvgl_theme_init_for_display(lv_display_t* display, bool isMonoDisplay, bool supportsAnimations) { lv_theme_t* lvgl_theme_init_for_display(lv_display_t* display, bool isMonoDisplay, bool supportsAnimations) {
LvglThemeSettings settings; LvglThemeSettings settings;
lvgl_theme_get_settings(&settings); lvgl_theme_get_settings(&settings);
const bool is_compact = lvgl_get_ui_density() == LVGL_UI_DENSITY_COMPACT; const bool is_compact = settings.is_compact;
const bool animations_enabled = settings.animations_enabled && supportsAnimations; const bool animations_enabled = settings.animations_enabled && supportsAnimations;
const ActiveTheme required_theme = (isMonoDisplay || settings.is_mono) ? ActiveTheme::MaterialMono : ActiveTheme::Material; const ActiveTheme required_theme = (isMonoDisplay || settings.is_mono) ? ActiveTheme::MaterialMono : ActiveTheme::Material;
@ -104,6 +113,8 @@ lv_theme_t* lvgl_theme_init_for_display(lv_display_t* display, bool isMonoDispla
config.color_primary = settings.color_primary; config.color_primary = settings.color_primary;
config.color_secondary = settings.color_secondary; config.color_secondary = settings.color_secondary;
config.color_error = settings.color_error; config.color_error = settings.color_error;
config.surface_override = settings.surface_override;
config.color_surface = settings.color_surface;
theme = lv_theme_material_init(display, &config); theme = lv_theme_material_init(display, &config);
} }
active_theme = required_theme; active_theme = required_theme;

View File

@ -71,14 +71,17 @@ extern const lv_obj_class_t lvgl_page_indicator_dot_class;
#define COLOR_ON_SECONDARY_CONTAINER (theme->config.is_dark ? lv_color_mix(theme->config.color_secondary, lv_color_white(), LV_OPA_20) : lv_color_mix(theme->config.color_secondary, lv_color_black(), LV_OPA_60)) #define COLOR_ON_SECONDARY_CONTAINER (theme->config.is_dark ? lv_color_mix(theme->config.color_secondary, lv_color_white(), LV_OPA_20) : lv_color_mix(theme->config.color_secondary, lv_color_black(), LV_OPA_60))
#define COLOR_ERROR (theme->config.is_dark ? lv_color_mix(theme->config.color_error, lv_color_white(), 140) : theme->config.color_error) #define COLOR_ERROR (theme->config.is_dark ? lv_color_mix(theme->config.color_error, lv_color_white(), 140) : theme->config.color_error)
#define COLOR_ON_ERROR (theme->config.is_dark ? lv_color_mix(theme->config.color_error, lv_color_black(), LV_OPA_50) : lv_color_white()) #define COLOR_ON_ERROR (theme->config.is_dark ? lv_color_mix(theme->config.color_error, lv_color_black(), LV_OPA_50) : lv_color_white())
#define COLOR_SURFACE (lv_color_mix(theme->config.color_primary, theme->config.is_dark ? NEUTRAL_6 : NEUTRAL_98, TINT_NEUTRAL)) #define DARK_BACKGROUND lv_color_hex(0x21222B)
#define COLOR_SURFACE (theme->config.surface_override ? theme->config.color_surface : theme->config.is_dark ? lv_color_mix(lv_color_hex(0x000000), DARK_BACKGROUND, LV_OPA_20) : lv_color_mix(theme->config.color_primary, NEUTRAL_98, TINT_NEUTRAL))
#define COLOR_ON_SURFACE (lv_color_mix(theme->config.color_primary, theme->config.is_dark ? NEUTRAL_90 : NEUTRAL_10, TINT_NEUTRAL)) #define COLOR_ON_SURFACE (lv_color_mix(theme->config.color_primary, theme->config.is_dark ? NEUTRAL_90 : NEUTRAL_10, TINT_NEUTRAL))
#define COLOR_ON_SURFACE_VARIANT (lv_color_mix(theme->config.color_primary, theme->config.is_dark ? NEUTRAL_80 : NEUTRAL_30, TINT_NEUTRAL_VARIANT)) #define COLOR_ON_SURFACE_VARIANT (lv_color_mix(theme->config.color_primary, theme->config.is_dark ? NEUTRAL_80 : NEUTRAL_30, TINT_NEUTRAL_VARIANT))
#define COLOR_SURFACE_CONTAINER_LOWEST (lv_color_mix(theme->config.color_primary, theme->config.is_dark ? NEUTRAL_4 : NEUTRAL_100, TINT_NEUTRAL)) #define SURFACE_LIFT (theme->config.is_dark ? lv_color_hex(0xFFFFFF) : lv_color_hex(0x000000))
#define COLOR_SURFACE_CONTAINER_LOW (lv_color_mix(theme->config.color_primary, theme->config.is_dark ? NEUTRAL_10 : NEUTRAL_96, TINT_NEUTRAL)) #define SURFACE_SINK (theme->config.is_dark ? lv_color_hex(0x000000) : lv_color_hex(0xFFFFFF))
#define COLOR_SURFACE_CONTAINER (lv_color_mix(theme->config.color_primary, theme->config.is_dark ? NEUTRAL_12 : NEUTRAL_94, TINT_NEUTRAL)) #define COLOR_SURFACE_CONTAINER_LOWEST (theme->config.surface_override ? lv_color_mix(SURFACE_SINK, theme->config.color_surface, 64) : theme->config.is_dark ? lv_color_mix(lv_color_hex(0x000000), DARK_BACKGROUND, 89) : lv_color_mix(theme->config.color_primary, NEUTRAL_100, TINT_NEUTRAL))
#define COLOR_SURFACE_CONTAINER_HIGH (lv_color_mix(theme->config.color_primary, theme->config.is_dark ? NEUTRAL_17 : NEUTRAL_92, TINT_NEUTRAL)) #define COLOR_SURFACE_CONTAINER_LOW (theme->config.surface_override ? lv_color_mix(SURFACE_LIFT, theme->config.color_surface, theme->config.is_dark ? 10 : 5) : theme->config.is_dark ? DARK_BACKGROUND : lv_color_mix(theme->config.color_primary, NEUTRAL_96, TINT_NEUTRAL))
#define COLOR_SURFACE_CONTAINER_HIGHEST (lv_color_mix(theme->config.color_primary, theme->config.is_dark ? NEUTRAL_22 : NEUTRAL_90, TINT_NEUTRAL)) #define COLOR_SURFACE_CONTAINER (theme->config.surface_override ? lv_color_mix(SURFACE_LIFT, theme->config.color_surface, theme->config.is_dark ? 15 : 10) : theme->config.is_dark ? lv_color_mix(lv_color_hex(0xFFFFFF), DARK_BACKGROUND, 8) : lv_color_mix(theme->config.color_primary, NEUTRAL_94, TINT_NEUTRAL))
#define COLOR_SURFACE_CONTAINER_HIGH (theme->config.surface_override ? lv_color_mix(SURFACE_LIFT, theme->config.color_surface, theme->config.is_dark ? 28 : 15) : theme->config.is_dark ? lv_color_mix(lv_color_hex(0xFFFFFF), DARK_BACKGROUND, 20) : lv_color_mix(theme->config.color_primary, NEUTRAL_92, TINT_NEUTRAL))
#define COLOR_SURFACE_CONTAINER_HIGHEST (theme->config.surface_override ? lv_color_mix(SURFACE_LIFT, theme->config.color_surface, theme->config.is_dark ? 41 : 20) : theme->config.is_dark ? lv_color_mix(lv_color_hex(0xFFFFFF), DARK_BACKGROUND, 33) : lv_color_mix(theme->config.color_primary, NEUTRAL_90, TINT_NEUTRAL))
#define COLOR_OUTLINE (lv_color_mix(theme->config.color_primary, theme->config.is_dark ? NEUTRAL_60 : NEUTRAL_50, TINT_NEUTRAL_VARIANT)) #define COLOR_OUTLINE (lv_color_mix(theme->config.color_primary, theme->config.is_dark ? NEUTRAL_60 : NEUTRAL_50, TINT_NEUTRAL_VARIANT))
#define COLOR_OUTLINE_VARIANT (lv_color_mix(theme->config.color_primary, theme->config.is_dark ? NEUTRAL_30 : NEUTRAL_80, TINT_NEUTRAL_VARIANT)) #define COLOR_OUTLINE_VARIANT (lv_color_mix(theme->config.color_primary, theme->config.is_dark ? NEUTRAL_30 : NEUTRAL_80, TINT_NEUTRAL_VARIANT))
#define COLOR_INVERSE_SURFACE (lv_color_mix(theme->config.color_primary, theme->config.is_dark ? NEUTRAL_90 : NEUTRAL_20, TINT_NEUTRAL)) #define COLOR_INVERSE_SURFACE (lv_color_mix(theme->config.color_primary, theme->config.is_dark ? NEUTRAL_90 : NEUTRAL_20, TINT_NEUTRAL))
@ -140,8 +143,8 @@ extern const lv_obj_class_t lvgl_page_indicator_dot_class;
#define CHIP_PAD_HOR (theme->config.is_compact ? 3 : SPACE_4) #define CHIP_PAD_HOR (theme->config.is_compact ? 3 : SPACE_4)
#define CHIP_PAD_VER (theme->config.is_compact ? 2 : LV_DPX_CALC(theme->disp_dpi, 6)) #define CHIP_PAD_VER (theme->config.is_compact ? 2 : LV_DPX_CALC(theme->disp_dpi, 6))
#define PAGE_INDICATOR_COLOR COLOR_ON_SURFACE_VARIANT #define PAGE_INDICATOR_COLOR COLOR_ON_SURFACE_VARIANT
#define PAGE_INDICATOR_DOT_SIZE (theme->config.is_compact ? 4 : LV_DPX_CALC(theme->disp_dpi, 6)) #define PAGE_INDICATOR_DOT_SIZE (theme->config.is_compact ? 6 : LV_DPX_CALC(theme->disp_dpi, 8))
#define PAGE_INDICATOR_GAP (theme->config.is_compact ? 3 : LV_DPX_CALC(theme->disp_dpi, 6)) #define PAGE_INDICATOR_GAP (theme->config.is_compact ? 4 : LV_DPX_CALC(theme->disp_dpi, 6))
#define BADGE_COLOR COLOR_SECONDARY #define BADGE_COLOR COLOR_SECONDARY
#define BADGE_SIZE (theme->config.is_compact ? 4 : LV_DPX_CALC(theme->disp_dpi, 6)) #define BADGE_SIZE (theme->config.is_compact ? 4 : LV_DPX_CALC(theme->disp_dpi, 6))
#define SCROLLBAR_COLOR COLOR_OUTLINE #define SCROLLBAR_COLOR COLOR_OUTLINE
@ -174,8 +177,8 @@ extern const lv_obj_class_t lvgl_page_indicator_dot_class;
#define ICON_BUTTON_TONAL_COLOR COLOR_SECONDARY_CONTAINER #define ICON_BUTTON_TONAL_COLOR COLOR_SECONDARY_CONTAINER
#define ICON_BUTTON_TONAL_ICON_COLOR COLOR_ON_SECONDARY_CONTAINER #define ICON_BUTTON_TONAL_ICON_COLOR COLOR_ON_SECONDARY_CONTAINER
#define ICON_BUTTON_TONAL_PRESSED_COLOR (lv_color_mix(COLOR_ON_SECONDARY_CONTAINER, COLOR_SECONDARY_CONTAINER, OPACITY_STATE_PRESSED)) #define ICON_BUTTON_TONAL_PRESSED_COLOR (lv_color_mix(COLOR_ON_SECONDARY_CONTAINER, COLOR_SECONDARY_CONTAINER, OPACITY_STATE_PRESSED))
#define APP_BAR_COLOR (lv_color_mix(theme->config.color_primary, NEUTRAL_22, TINT_NEUTRAL)) #define APP_BAR_COLOR COLOR_SURFACE_CONTAINER_LOW
#define APP_BAR_CONTENT_COLOR lv_color_white() #define APP_BAR_CONTENT_COLOR COLOR_ON_SURFACE
#define APP_BAR_PRESSED_OPACITY (LV_OPA_20) #define APP_BAR_PRESSED_OPACITY (LV_OPA_20)
#define APP_BAR_PAD_HOR (theme->config.is_compact ? 0 : SPACE_1) #define APP_BAR_PAD_HOR (theme->config.is_compact ? 0 : SPACE_1)
#define APP_BAR_GAP (theme->config.is_compact ? 2 : SPACE_1) #define APP_BAR_GAP (theme->config.is_compact ? 2 : SPACE_1)
@ -270,11 +273,14 @@ extern const lv_obj_class_t lvgl_page_indicator_dot_class;
#define LIST_DIVIDER_COLOR COLOR_OUTLINE_VARIANT #define LIST_DIVIDER_COLOR COLOR_OUTLINE_VARIANT
#define LIST_DIVIDER_WIDTH DENSITY_BORDER #define LIST_DIVIDER_WIDTH DENSITY_BORDER
#define LIST_SUBHEADER_TEXT_COLOR COLOR_ON_SURFACE_VARIANT #define LIST_SUBHEADER_TEXT_COLOR COLOR_ON_SURFACE_VARIANT
#define TABS_DIVIDER_COLOR COLOR_OUTLINE_VARIANT #define TABS_COLOR CARD_COLOR
#define TABS_PAD (theme->config.is_compact ? 2 : SPACE_1)
#define TABS_GAP (theme->config.is_compact ? 2 : SPACE_1)
#define TAB_LABEL_COLOR COLOR_ON_SURFACE_VARIANT #define TAB_LABEL_COLOR COLOR_ON_SURFACE_VARIANT
#define TAB_SELECTED_LABEL_COLOR COLOR_PRIMARY #define TAB_PAD (theme->config.is_compact ? 2 : SPACE_1)
#define TAB_INDICATOR_COLOR COLOR_PRIMARY #define TAB_SHAPE SHAPE_CONTROL
#define TAB_INDICATOR_WIDTH (theme->config.is_compact ? 2 : LV_DPX_CALC(theme->disp_dpi, 3)) #define TAB_SELECTED_COLOR COLOR_SECONDARY_CONTAINER
#define TAB_SELECTED_LABEL_COLOR COLOR_ON_SECONDARY_CONTAINER
#define TAB_PAGE_PAD DENSITY_CONTAINER_PAD #define TAB_PAGE_PAD DENSITY_CONTAINER_PAD
#define DIALOG_COLOR COLOR_SURFACE_CONTAINER_HIGH #define DIALOG_COLOR COLOR_SURFACE_CONTAINER_HIGH
#define DIALOG_TEXT_COLOR COLOR_ON_SURFACE_VARIANT #define DIALOG_TEXT_COLOR COLOR_ON_SURFACE_VARIANT
@ -658,6 +664,8 @@ static bool config_equals(const lv_theme_material_config_t * a, const lv_theme_m
lv_color_eq(a->color_primary, b->color_primary) && lv_color_eq(a->color_primary, b->color_primary) &&
lv_color_eq(a->color_secondary, b->color_secondary) && lv_color_eq(a->color_secondary, b->color_secondary) &&
lv_color_eq(a->color_error, b->color_error) && lv_color_eq(a->color_error, b->color_error) &&
a->surface_override == b->surface_override &&
lv_color_eq(a->color_surface, b->color_surface) &&
a->font_small == b->font_small && a->font_small == b->font_small &&
a->font_normal == b->font_normal && a->font_normal == b->font_normal &&
a->font_large == b->font_large; a->font_large == b->font_large;
@ -807,6 +815,7 @@ static void style_init(my_theme_t * theme)
lv_style_set_radius(&theme->styles.chip, CHIP_SHAPE); lv_style_set_radius(&theme->styles.chip, CHIP_SHAPE);
lv_style_set_pad_hor(&theme->styles.chip, CHIP_PAD_HOR); lv_style_set_pad_hor(&theme->styles.chip, CHIP_PAD_HOR);
lv_style_set_pad_ver(&theme->styles.chip, CHIP_PAD_VER); lv_style_set_pad_ver(&theme->styles.chip, CHIP_PAD_VER);
lv_style_set_margin_all(&theme->styles.chip, FOCUS_WIDTH + FOCUS_OFFSET);
style_init_reset(&theme->styles.chip_selected); style_init_reset(&theme->styles.chip_selected);
lv_style_set_bg_color(&theme->styles.chip_selected, CHIP_SELECTED_COLOR); lv_style_set_bg_color(&theme->styles.chip_selected, CHIP_SELECTED_COLOR);
@ -823,6 +832,7 @@ static void style_init(my_theme_t * theme)
lv_style_set_pad_hor(&theme->styles.button, BUTTON_PAD_HOR); lv_style_set_pad_hor(&theme->styles.button, BUTTON_PAD_HOR);
lv_style_set_pad_ver(&theme->styles.button, BUTTON_PAD_VER); lv_style_set_pad_ver(&theme->styles.button, BUTTON_PAD_VER);
lv_style_set_pad_gap(&theme->styles.button, BUTTON_GAP); lv_style_set_pad_gap(&theme->styles.button, BUTTON_GAP);
lv_style_set_margin_all(&theme->styles.button, FOCUS_WIDTH + FOCUS_OFFSET);
#endif #endif
#if LV_USE_BUTTON #if LV_USE_BUTTON
@ -923,6 +933,7 @@ static void style_init(my_theme_t * theme)
style_init_reset(&theme->styles.switch_track); style_init_reset(&theme->styles.switch_track);
lv_style_set_width(&theme->styles.switch_track, SWITCH_WIDTH); lv_style_set_width(&theme->styles.switch_track, SWITCH_WIDTH);
lv_style_set_height(&theme->styles.switch_track, SWITCH_HEIGHT); lv_style_set_height(&theme->styles.switch_track, SWITCH_HEIGHT);
lv_style_set_margin_all(&theme->styles.switch_track, FOCUS_WIDTH + FOCUS_OFFSET);
lv_style_set_bg_color(&theme->styles.switch_track, SWITCH_TRACK_COLOR); lv_style_set_bg_color(&theme->styles.switch_track, SWITCH_TRACK_COLOR);
lv_style_set_bg_opa(&theme->styles.switch_track, LV_OPA_COVER); lv_style_set_bg_opa(&theme->styles.switch_track, LV_OPA_COVER);
lv_style_set_border_color(&theme->styles.switch_track, SWITCH_TRACK_BORDER_COLOR); lv_style_set_border_color(&theme->styles.switch_track, SWITCH_TRACK_BORDER_COLOR);
@ -990,6 +1001,7 @@ static void style_init(my_theme_t * theme)
style_init_reset(&theme->styles.sliderbox); style_init_reset(&theme->styles.sliderbox);
lv_style_set_pad_column(&theme->styles.sliderbox, SLIDERBOX_GAP); lv_style_set_pad_column(&theme->styles.sliderbox, SLIDERBOX_GAP);
lv_style_set_pad_all(&theme->styles.sliderbox, FOCUS_WIDTH + FOCUS_OFFSET);
#if LV_USE_BAR || LV_USE_SLIDER #if LV_USE_BAR || LV_USE_SLIDER
style_init_reset(&theme->styles.slider_track_size); style_init_reset(&theme->styles.slider_track_size);
@ -1222,25 +1234,27 @@ static void style_init(my_theme_t * theme)
#if LV_USE_TABVIEW #if LV_USE_TABVIEW
style_init_reset(&theme->styles.tabs); style_init_reset(&theme->styles.tabs);
lv_style_set_bg_opa(&theme->styles.tabs, LV_OPA_TRANSP); lv_style_set_bg_color(&theme->styles.tabs, TABS_COLOR);
lv_style_set_border_color(&theme->styles.tabs, TABS_DIVIDER_COLOR); lv_style_set_bg_opa(&theme->styles.tabs, LV_OPA_COVER);
lv_style_set_border_width(&theme->styles.tabs, DENSITY_BORDER); lv_style_set_border_width(&theme->styles.tabs, 0);
lv_style_set_border_side(&theme->styles.tabs, LV_BORDER_SIDE_BOTTOM); lv_style_set_radius(&theme->styles.tabs, CARD_SHAPE);
lv_style_set_pad_all(&theme->styles.tabs, TABS_PAD);
lv_style_set_pad_gap(&theme->styles.tabs, TABS_GAP);
#endif #endif
#if LV_USE_BUTTON && LV_USE_TABVIEW #if LV_USE_BUTTON && LV_USE_TABVIEW
style_init_reset(&theme->styles.tab); style_init_reset(&theme->styles.tab);
lv_style_set_bg_opa(&theme->styles.tab, LV_OPA_TRANSP); lv_style_set_bg_opa(&theme->styles.tab, LV_OPA_TRANSP);
lv_style_set_text_color(&theme->styles.tab, TAB_LABEL_COLOR); lv_style_set_text_color(&theme->styles.tab, TAB_LABEL_COLOR);
lv_style_set_radius(&theme->styles.tab, 0); lv_style_set_radius(&theme->styles.tab, TAB_SHAPE);
lv_style_set_pad_all(&theme->styles.tab, TAB_PAD);
#endif #endif
#if LV_USE_BUTTON && LV_USE_TABVIEW #if LV_USE_BUTTON && LV_USE_TABVIEW
style_init_reset(&theme->styles.tab_selected); style_init_reset(&theme->styles.tab_selected);
lv_style_set_bg_color(&theme->styles.tab_selected, TAB_SELECTED_COLOR);
lv_style_set_bg_opa(&theme->styles.tab_selected, LV_OPA_COVER);
lv_style_set_text_color(&theme->styles.tab_selected, TAB_SELECTED_LABEL_COLOR); lv_style_set_text_color(&theme->styles.tab_selected, TAB_SELECTED_LABEL_COLOR);
lv_style_set_border_color(&theme->styles.tab_selected, TAB_INDICATOR_COLOR);
lv_style_set_border_width(&theme->styles.tab_selected, TAB_INDICATOR_WIDTH);
lv_style_set_border_side(&theme->styles.tab_selected, LV_BORDER_SIDE_BOTTOM);
#endif #endif
#if LV_USE_TABVIEW #if LV_USE_TABVIEW
@ -1492,7 +1506,9 @@ void lv_theme_material_config_init(lv_theme_material_config_t * config)
config->animations_enabled = true; config->animations_enabled = true;
config->color_primary = lv_color_hex(0x00BBFF); config->color_primary = lv_color_hex(0x00BBFF);
config->color_secondary = lv_color_hex(0xEE81C3); config->color_secondary = lv_color_hex(0xEE81C3);
config->color_error = lv_color_hex(0xB3261E); config->color_error = lv_color_hex(0xEF6C00);
config->surface_override = false;
config->color_surface = lv_color_hex(0x000000);
config->font_small = LV_FONT_DEFAULT; config->font_small = LV_FONT_DEFAULT;
config->font_normal = LV_FONT_DEFAULT; config->font_normal = LV_FONT_DEFAULT;
config->font_large = LV_FONT_DEFAULT; config->font_large = LV_FONT_DEFAULT;
@ -1841,6 +1857,8 @@ static void theme_apply(lv_theme_t * th, lv_obj_t * obj)
lv_obj_add_style(obj, &theme->styles.page_indicator_label, 0); lv_obj_add_style(obj, &theme->styles.page_indicator_label, 0);
return; return;
} }
/* label */
lv_obj_add_style(obj, &theme->styles.focus_ring, LV_STATE_FOCUS_KEY);
return; return;
} }
#endif #endif

View File

@ -16,6 +16,8 @@ typedef struct {
lv_color_t color_primary; lv_color_t color_primary;
lv_color_t color_secondary; lv_color_t color_secondary;
lv_color_t color_error; lv_color_t color_error;
bool surface_override;
lv_color_t color_surface;
const lv_font_t * font_small; const lv_font_t * font_small;
const lv_font_t * font_normal; const lv_font_t * font_normal;
const lv_font_t * font_large; const lv_font_t * font_large;

View File

@ -90,8 +90,8 @@ extern const lv_obj_class_t lvgl_page_indicator_dot_class;
#define CHIP_PAD_HOR (theme->config.is_compact ? 3 : SPACE_4) #define CHIP_PAD_HOR (theme->config.is_compact ? 3 : SPACE_4)
#define CHIP_PAD_VER (theme->config.is_compact ? 2 : SPACE_2) #define CHIP_PAD_VER (theme->config.is_compact ? 2 : SPACE_2)
#define PAGE_INDICATOR_COLOR COLOR_ON_SURFACE #define PAGE_INDICATOR_COLOR COLOR_ON_SURFACE
#define PAGE_INDICATOR_DOT_SIZE (theme->config.is_compact ? 4 : LV_DPX_CALC(theme->disp_dpi, 6)) #define PAGE_INDICATOR_DOT_SIZE (theme->config.is_compact ? 6 : LV_DPX_CALC(theme->disp_dpi, 8))
#define PAGE_INDICATOR_GAP (theme->config.is_compact ? 3 : LV_DPX_CALC(theme->disp_dpi, 6)) #define PAGE_INDICATOR_GAP (theme->config.is_compact ? 4 : LV_DPX_CALC(theme->disp_dpi, 6))
#define BADGE_COLOR COLOR_ON_SURFACE #define BADGE_COLOR COLOR_ON_SURFACE
#define BADGE_SIZE (theme->config.is_compact ? 4 : LV_DPX_CALC(theme->disp_dpi, 6)) #define BADGE_SIZE (theme->config.is_compact ? 4 : LV_DPX_CALC(theme->disp_dpi, 6))
#define SCROLLBAR_COLOR COLOR_ON_SURFACE #define SCROLLBAR_COLOR COLOR_ON_SURFACE
@ -114,7 +114,6 @@ extern const lv_obj_class_t lvgl_page_indicator_dot_class;
#define ICON_BUTTON_TONAL_ICON_COLOR COLOR_ON_SURFACE #define ICON_BUTTON_TONAL_ICON_COLOR COLOR_ON_SURFACE
#define ICON_BUTTON_TONAL_BORDER_COLOR COLOR_OUTLINE #define ICON_BUTTON_TONAL_BORDER_COLOR COLOR_OUTLINE
#define APP_BAR_TITLE_COLOR COLOR_ON_SURFACE #define APP_BAR_TITLE_COLOR COLOR_ON_SURFACE
#define APP_BAR_DIVIDER_COLOR COLOR_OUTLINE
#define APP_BAR_PAD_HOR (theme->config.is_compact ? 0 : SPACE_1) #define APP_BAR_PAD_HOR (theme->config.is_compact ? 0 : SPACE_1)
#define APP_BAR_GAP (theme->config.is_compact ? 2 : SPACE_1) #define APP_BAR_GAP (theme->config.is_compact ? 2 : SPACE_1)
#define APP_BAR_FOCUS_WIDTH FOCUS_INSET_WIDTH #define APP_BAR_FOCUS_WIDTH FOCUS_INSET_WIDTH
@ -181,9 +180,10 @@ extern const lv_obj_class_t lvgl_page_indicator_dot_class;
#define LIST_ITEM_PAD_VER DENSITY_LIST_ITEM_PAD_VER #define LIST_ITEM_PAD_VER DENSITY_LIST_ITEM_PAD_VER
#define LIST_ITEM_GAP (theme->config.is_compact ? 3 : SPACE_4) #define LIST_ITEM_GAP (theme->config.is_compact ? 3 : SPACE_4)
#define LIST_DIVIDER_COLOR COLOR_OUTLINE #define LIST_DIVIDER_COLOR COLOR_OUTLINE
#define TABS_DIVIDER_COLOR COLOR_OUTLINE #define TABS_PAD (theme->config.is_compact ? 2 : SPACE_1)
#define TAB_INDICATOR_COLOR COLOR_PRIMARY #define TABS_GAP (theme->config.is_compact ? 2 : SPACE_1)
#define TAB_INDICATOR_WIDTH (theme->config.is_compact ? LINE_MEDIUM : LINE_THICK) #define TAB_PAD (theme->config.is_compact ? 2 : SPACE_1)
#define TAB_SHAPE SHAPE_CONTROL
#define TAB_PAGE_PAD DENSITY_CONTAINER_PAD #define TAB_PAGE_PAD DENSITY_CONTAINER_PAD
#define DIALOG_COLOR COLOR_SURFACE #define DIALOG_COLOR COLOR_SURFACE
#define DIALOG_BORDER_COLOR COLOR_OUTLINE #define DIALOG_BORDER_COLOR COLOR_OUTLINE
@ -327,9 +327,6 @@ typedef struct {
#if LV_USE_BUTTON && LV_USE_TABVIEW #if LV_USE_BUTTON && LV_USE_TABVIEW
lv_style_t tab; lv_style_t tab;
#endif #endif
#if LV_USE_BUTTON && LV_USE_TABVIEW
lv_style_t tab_selected;
#endif
#if LV_USE_TABVIEW #if LV_USE_TABVIEW
lv_style_t tab_page; lv_style_t tab_page;
#endif #endif
@ -598,6 +595,7 @@ static void style_init(my_theme_t * theme)
lv_style_set_radius(&theme->styles.chip, CHIP_SHAPE); lv_style_set_radius(&theme->styles.chip, CHIP_SHAPE);
lv_style_set_pad_hor(&theme->styles.chip, CHIP_PAD_HOR); lv_style_set_pad_hor(&theme->styles.chip, CHIP_PAD_HOR);
lv_style_set_pad_ver(&theme->styles.chip, CHIP_PAD_VER); lv_style_set_pad_ver(&theme->styles.chip, CHIP_PAD_VER);
lv_style_set_margin_all(&theme->styles.chip, FOCUS_WIDTH + FOCUS_OFFSET);
#if LV_USE_BUTTON || LV_USE_MSGBOX #if LV_USE_BUTTON || LV_USE_MSGBOX
style_init_reset(&theme->styles.button); style_init_reset(&theme->styles.button);
@ -610,6 +608,7 @@ static void style_init(my_theme_t * theme)
lv_style_set_pad_hor(&theme->styles.button, BUTTON_PAD_HOR); lv_style_set_pad_hor(&theme->styles.button, BUTTON_PAD_HOR);
lv_style_set_pad_ver(&theme->styles.button, BUTTON_PAD_VER); lv_style_set_pad_ver(&theme->styles.button, BUTTON_PAD_VER);
lv_style_set_pad_gap(&theme->styles.button, BUTTON_GAP); lv_style_set_pad_gap(&theme->styles.button, BUTTON_GAP);
lv_style_set_margin_all(&theme->styles.button, FOCUS_WIDTH + FOCUS_OFFSET);
#endif #endif
style_init_reset(&theme->styles.icon_button); style_init_reset(&theme->styles.icon_button);
@ -646,9 +645,7 @@ static void style_init(my_theme_t * theme)
lv_style_set_bg_opa(&theme->styles.app_bar, LV_OPA_TRANSP); lv_style_set_bg_opa(&theme->styles.app_bar, LV_OPA_TRANSP);
lv_style_set_text_color(&theme->styles.app_bar, APP_BAR_TITLE_COLOR); lv_style_set_text_color(&theme->styles.app_bar, APP_BAR_TITLE_COLOR);
lv_style_set_radius(&theme->styles.app_bar, 0); lv_style_set_radius(&theme->styles.app_bar, 0);
lv_style_set_border_color(&theme->styles.app_bar, APP_BAR_DIVIDER_COLOR); lv_style_set_border_width(&theme->styles.app_bar, 0);
lv_style_set_border_width(&theme->styles.app_bar, BORDER);
lv_style_set_border_side(&theme->styles.app_bar, LV_BORDER_SIDE_BOTTOM);
lv_style_set_pad_hor(&theme->styles.app_bar, APP_BAR_PAD_HOR); lv_style_set_pad_hor(&theme->styles.app_bar, APP_BAR_PAD_HOR);
lv_style_set_pad_ver(&theme->styles.app_bar, 0); lv_style_set_pad_ver(&theme->styles.app_bar, 0);
lv_style_set_pad_gap(&theme->styles.app_bar, APP_BAR_GAP); lv_style_set_pad_gap(&theme->styles.app_bar, APP_BAR_GAP);
@ -666,6 +663,7 @@ static void style_init(my_theme_t * theme)
style_init_reset(&theme->styles.switch_track); style_init_reset(&theme->styles.switch_track);
lv_style_set_width(&theme->styles.switch_track, SWITCH_WIDTH); lv_style_set_width(&theme->styles.switch_track, SWITCH_WIDTH);
lv_style_set_height(&theme->styles.switch_track, SWITCH_HEIGHT); lv_style_set_height(&theme->styles.switch_track, SWITCH_HEIGHT);
lv_style_set_margin_all(&theme->styles.switch_track, FOCUS_WIDTH + FOCUS_OFFSET);
lv_style_set_bg_color(&theme->styles.switch_track, SWITCH_TRACK_COLOR); lv_style_set_bg_color(&theme->styles.switch_track, SWITCH_TRACK_COLOR);
lv_style_set_bg_opa(&theme->styles.switch_track, LV_OPA_COVER); lv_style_set_bg_opa(&theme->styles.switch_track, LV_OPA_COVER);
lv_style_set_border_color(&theme->styles.switch_track, SWITCH_TRACK_BORDER_COLOR); lv_style_set_border_color(&theme->styles.switch_track, SWITCH_TRACK_BORDER_COLOR);
@ -721,6 +719,7 @@ static void style_init(my_theme_t * theme)
style_init_reset(&theme->styles.sliderbox); style_init_reset(&theme->styles.sliderbox);
lv_style_set_pad_column(&theme->styles.sliderbox, SLIDERBOX_GAP); lv_style_set_pad_column(&theme->styles.sliderbox, SLIDERBOX_GAP);
lv_style_set_pad_all(&theme->styles.sliderbox, FOCUS_WIDTH + FOCUS_OFFSET);
#if LV_USE_BAR || LV_USE_SLIDER #if LV_USE_BAR || LV_USE_SLIDER
style_init_reset(&theme->styles.slider_track_size); style_init_reset(&theme->styles.slider_track_size);
@ -897,9 +896,9 @@ static void style_init(my_theme_t * theme)
#if LV_USE_TABVIEW #if LV_USE_TABVIEW
style_init_reset(&theme->styles.tabs); style_init_reset(&theme->styles.tabs);
lv_style_set_bg_opa(&theme->styles.tabs, LV_OPA_TRANSP); lv_style_set_bg_opa(&theme->styles.tabs, LV_OPA_TRANSP);
lv_style_set_border_color(&theme->styles.tabs, TABS_DIVIDER_COLOR); lv_style_set_border_width(&theme->styles.tabs, 0);
lv_style_set_border_width(&theme->styles.tabs, BORDER); lv_style_set_pad_all(&theme->styles.tabs, TABS_PAD);
lv_style_set_border_side(&theme->styles.tabs, LV_BORDER_SIDE_BOTTOM); lv_style_set_pad_gap(&theme->styles.tabs, TABS_GAP);
#endif #endif
#if LV_USE_BUTTON && LV_USE_TABVIEW #if LV_USE_BUTTON && LV_USE_TABVIEW
@ -907,14 +906,8 @@ static void style_init(my_theme_t * theme)
lv_style_set_bg_opa(&theme->styles.tab, LV_OPA_TRANSP); lv_style_set_bg_opa(&theme->styles.tab, LV_OPA_TRANSP);
lv_style_set_text_color(&theme->styles.tab, COLOR_ON_SURFACE); lv_style_set_text_color(&theme->styles.tab, COLOR_ON_SURFACE);
lv_style_set_border_width(&theme->styles.tab, 0); lv_style_set_border_width(&theme->styles.tab, 0);
lv_style_set_radius(&theme->styles.tab, 0); lv_style_set_radius(&theme->styles.tab, TAB_SHAPE);
#endif lv_style_set_pad_all(&theme->styles.tab, TAB_PAD);
#if LV_USE_BUTTON && LV_USE_TABVIEW
style_init_reset(&theme->styles.tab_selected);
lv_style_set_border_color(&theme->styles.tab_selected, TAB_INDICATOR_COLOR);
lv_style_set_border_width(&theme->styles.tab_selected, TAB_INDICATOR_WIDTH);
lv_style_set_border_side(&theme->styles.tab_selected, LV_BORDER_SIDE_BOTTOM);
#endif #endif
#if LV_USE_TABVIEW #if LV_USE_TABVIEW
@ -1306,7 +1299,7 @@ static void theme_apply(lv_theme_t * th, lv_obj_t * obj)
if(lv_obj_check_type(lss_ancestor(obj, 2), &lv_tabview_class) && lss_is_child(lss_ancestor(obj, 2), 0, parent)) { if(lv_obj_check_type(lss_ancestor(obj, 2), &lv_tabview_class) && lss_is_child(lss_ancestor(obj, 2), 0, parent)) {
lv_obj_add_style(obj, &theme->styles.tab, 0); lv_obj_add_style(obj, &theme->styles.tab, 0);
lv_obj_add_style(obj, &theme->styles.inverted, LV_STATE_PRESSED); lv_obj_add_style(obj, &theme->styles.inverted, LV_STATE_PRESSED);
lv_obj_add_style(obj, &theme->styles.tab_selected, LV_STATE_CHECKED); lv_obj_add_style(obj, &theme->styles.inverted, LV_STATE_CHECKED);
lv_obj_add_style(obj, &theme->styles.focus_ring, LV_STATE_FOCUS_KEY); lv_obj_add_style(obj, &theme->styles.focus_ring, LV_STATE_FOCUS_KEY);
lv_obj_add_style(obj, &theme->styles.edit_ring, LV_STATE_EDITED); lv_obj_add_style(obj, &theme->styles.edit_ring, LV_STATE_EDITED);
return; return;
@ -1440,6 +1433,8 @@ static void theme_apply(lv_theme_t * th, lv_obj_t * obj)
lv_obj_add_style(obj, &theme->styles.page_indicator_label, 0); lv_obj_add_style(obj, &theme->styles.page_indicator_label, 0);
return; return;
} }
/* label */
lv_obj_add_style(obj, &theme->styles.focus_ring, LV_STATE_FOCUS_KEY);
return; return;
} }
#endif #endif

View File

@ -38,7 +38,6 @@ static void sliderbox_constructor(const lv_obj_class_t* classPointer, lv_obj_t*
lv_obj_remove_flag(obj, LV_OBJ_FLAG_SCROLLABLE); lv_obj_remove_flag(obj, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_flex_flow(obj, LV_FLEX_FLOW_ROW); lv_obj_set_flex_flow(obj, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(obj, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_set_flex_align(obj, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_all(obj, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(obj, 0, LV_STATE_DEFAULT); lv_obj_set_style_border_width(obj, 0, LV_STATE_DEFAULT);
lv_obj_set_style_bg_opa(obj, 0, LV_STATE_DEFAULT); lv_obj_set_style_bg_opa(obj, 0, LV_STATE_DEFAULT);
} }

View File

@ -7,6 +7,7 @@
#include <lvgl/grid_navigation.h> #include <lvgl/grid_navigation.h>
#include <lvgl/insets.h> #include <lvgl/insets.h>
#include <lvgl/lvgl.h> #include <lvgl/lvgl.h>
#include <lvgl/theme.h>
#include <lvgl/widgets/icon_button.h> #include <lvgl/widgets/icon_button.h>
#include <lvgl/widgets/spinner.h> #include <lvgl/widgets/spinner.h>
@ -16,16 +17,16 @@
constexpr auto* TAG = "lvgl_toolbar"; constexpr auto* TAG = "lvgl_toolbar";
static uint32_t getToolbarHeight(UiDensity uiDensity) { static uint32_t getToolbarHeight(bool isCompact) {
if (uiDensity == LVGL_UI_DENSITY_COMPACT) { if (isCompact) {
return lvgl_get_text_font_height(FONT_SIZE_DEFAULT) * 1.6f; return lvgl_get_text_font_height(FONT_SIZE_DEFAULT) * 1.6f;
} else { } else {
return lvgl_get_text_font_height(FONT_SIZE_LARGE) * 2.2f; return lvgl_get_text_font_height(FONT_SIZE_LARGE) * 2.2f;
} }
} }
static const _lv_font_t* getToolbarFont(UiDensity uiDensity) { static const _lv_font_t* getToolbarFont(bool isCompact) {
if (uiDensity == LVGL_UI_DENSITY_COMPACT) { if (isCompact) {
return lvgl_get_text_font(FONT_SIZE_DEFAULT); return lvgl_get_text_font(FONT_SIZE_DEFAULT);
} else { } else {
return lvgl_get_text_font(FONT_SIZE_LARGE); return lvgl_get_text_font(FONT_SIZE_LARGE);
@ -33,9 +34,9 @@ static const _lv_font_t* getToolbarFont(UiDensity uiDensity) {
} }
/** The size of icon buttons, which leaves room for the theme's focus indicator */ /** The size of icon buttons, which leaves room for the theme's focus indicator */
static uint32_t getActionButtonSize(UiDensity uiDensity) { static uint32_t getActionButtonSize(bool isCompact) {
auto toolbar_height = getToolbarHeight(uiDensity); auto toolbar_height = getToolbarHeight(isCompact);
auto padding = (uiDensity != LVGL_UI_DENSITY_COMPACT) ? (uint32_t)(toolbar_height * 0.2f) : 8; auto padding = !isCompact ? (uint32_t)(toolbar_height * 0.2f) : 8;
return toolbar_height - padding; return toolbar_height - padding;
} }
@ -84,18 +85,18 @@ static void toolbar_constructor(const lv_obj_class_t* class_p, lv_obj_t* obj) {
lv_obj_add_flag(obj, LV_OBJ_FLAG_SCROLL_ON_FOCUS); lv_obj_add_flag(obj, LV_OBJ_FLAG_SCROLL_ON_FOCUS);
} }
static lv_obj_t* create_action_button(lv_obj_t* parent, UiDensity ui_density) { static lv_obj_t* create_action_button(lv_obj_t* parent, bool is_compact) {
auto button_size = getActionButtonSize(ui_density); auto button_size = getActionButtonSize(is_compact);
lv_obj_t* button = lvgl_icon_button_create(parent); lv_obj_t* button = lvgl_icon_button_create(parent);
lv_obj_set_size(button, button_size, button_size); lv_obj_set_size(button, button_size, button_size);
return button; return button;
} }
lv_obj_t* lvgl_toolbar_create(lv_obj_t* parent, const char* title) { lv_obj_t* lvgl_toolbar_create(lv_obj_t* parent, const char* title) {
auto ui_density = lvgl_get_ui_density(); const bool is_compact = lvgl_theme_is_compact();
lv_obj_t* obj = lv_obj_class_create_obj(&lvgl_toolbar_class, parent); lv_obj_t* obj = lv_obj_class_create_obj(&lvgl_toolbar_class, parent);
lv_obj_class_init_obj(obj); lv_obj_class_init_obj(obj);
lv_obj_set_height(obj, getToolbarHeight(ui_density)); lv_obj_set_height(obj, getToolbarHeight(is_compact));
auto* toolbar = reinterpret_cast<Toolbar*>(obj); auto* toolbar = reinterpret_cast<Toolbar*>(obj);
toolbar->nav_action_callback = nullptr; toolbar->nav_action_callback = nullptr;
@ -109,12 +110,12 @@ lv_obj_t* lvgl_toolbar_create(lv_obj_t* parent, const char* title) {
// The arrow keys move between the close button and the actions, and up and down leave the toolbar // The arrow keys move between the close button and the actions, and up and down leave the toolbar
lvgl_grid_navigation_add(obj); lvgl_grid_navigation_add(obj);
toolbar->close_button = create_action_button(obj, ui_density); toolbar->close_button = create_action_button(obj, is_compact);
toolbar->close_button_image = lv_image_create(toolbar->close_button); toolbar->close_button_image = lv_image_create(toolbar->close_button);
lv_obj_align(toolbar->close_button_image, LV_ALIGN_CENTER, 0, 0); lv_obj_align(toolbar->close_button_image, LV_ALIGN_CENTER, 0, 0);
toolbar->title_label = lv_label_create(obj); toolbar->title_label = lv_label_create(obj);
lv_obj_set_style_text_font(toolbar->title_label, getToolbarFont(ui_density), LV_STATE_DEFAULT); lv_obj_set_style_text_font(toolbar->title_label, getToolbarFont(is_compact), LV_STATE_DEFAULT);
lv_label_set_text(toolbar->title_label, title); lv_label_set_text(toolbar->title_label, title);
lv_label_set_long_mode(toolbar->title_label, LV_LABEL_LONG_MODE_SCROLL); lv_label_set_long_mode(toolbar->title_label, LV_LABEL_LONG_MODE_SCROLL);
lv_obj_set_flex_grow(toolbar->title_label, 1); lv_obj_set_flex_grow(toolbar->title_label, 1);
@ -155,7 +156,7 @@ static lv_obj_t* toolbar_add_button_action(lv_obj_t* obj, const char* imageOrBut
check(toolbar->action_count < TOOLBAR_ACTION_LIMIT, "max actions reached"); check(toolbar->action_count < TOOLBAR_ACTION_LIMIT, "max actions reached");
toolbar->action_count++; toolbar->action_count++;
lv_obj_t* action_button = create_action_button(obj, lvgl_get_ui_density()); lv_obj_t* action_button = create_action_button(obj, lvgl_theme_is_compact());
lv_obj_add_event_cb(action_button, callback, LV_EVENT_SHORT_CLICKED, user_data); lv_obj_add_event_cb(action_button, callback, LV_EVENT_SHORT_CLICKED, user_data);
lv_obj_t* button_content; lv_obj_t* button_content;
if (isImage) { if (isImage) {

View File

@ -14,7 +14,10 @@
animationsEnabled: bool = true; animationsEnabled: bool = true;
colorPrimary: color = #00bbff; colorPrimary: color = #00bbff;
colorSecondary: color = #ee81c3; colorSecondary: color = #ee81c3;
colorError: color = #B3261E; colorError: color = #EF6C00;
// Replaces the surface (screen background) colour
surfaceOverride: bool = false;
colorSurface: color = #000000;
fontSmall: font = raw("LV_FONT_DEFAULT"); fontSmall: font = raw("LV_FONT_DEFAULT");
fontNormal: font = raw("LV_FONT_DEFAULT"); fontNormal: font = raw("LV_FONT_DEFAULT");
fontLarge: font = raw("LV_FONT_DEFAULT"); fontLarge: font = raw("LV_FONT_DEFAULT");
@ -108,15 +111,20 @@
@color-error: if($isDark, mix($colorError, white, 55%), $colorError); @color-error: if($isDark, mix($colorError, white, 55%), $colorError);
@color-on-error: if($isDark, mix($colorError, black, 50%), white); @color-on-error: if($isDark, mix($colorError, black, 50%), white);
// Surfaces: neutral tones tinted with the primary color // Surfaces: neutral tones tinted with the primary color.
@color-surface: mix($colorPrimary, if($isDark, @neutral-6, @neutral-98), @tint-neutral); // In dark mode, they're derived from the screen background colour (the low container tone, which apps and the window use).
@dark-background: #21222b;
@color-surface: if($surfaceOverride, $colorSurface, if($isDark, mix(#000000, @dark-background, 20%), mix($colorPrimary, @neutral-98, @tint-neutral)));
@color-on-surface: mix($colorPrimary, if($isDark, @neutral-90, @neutral-10), @tint-neutral); @color-on-surface: mix($colorPrimary, if($isDark, @neutral-90, @neutral-10), @tint-neutral);
@color-on-surface-variant: mix($colorPrimary, if($isDark, @neutral-80, @neutral-30), @tint-neutral-variant); @color-on-surface-variant: mix($colorPrimary, if($isDark, @neutral-80, @neutral-30), @tint-neutral-variant);
@color-surface-container-lowest: mix($colorPrimary, if($isDark, @neutral-4, @neutral-100), @tint-neutral); // With a surface override, the containers are the surface lifted towards the text colour, in the same steps as the neutral tones
@color-surface-container-low: mix($colorPrimary, if($isDark, @neutral-10, @neutral-96), @tint-neutral); @surface-lift: if($isDark, #FFFFFF, #000000);
@color-surface-container: mix($colorPrimary, if($isDark, @neutral-12, @neutral-94), @tint-neutral); @surface-sink: if($isDark, #000000, #FFFFFF);
@color-surface-container-high: mix($colorPrimary, if($isDark, @neutral-17, @neutral-92), @tint-neutral); @color-surface-container-lowest: if($surfaceOverride, mix(@surface-sink, $colorSurface, 25%), if($isDark, mix(#000000, @dark-background, 35%), mix($colorPrimary, @neutral-100, @tint-neutral)));
@color-surface-container-highest: mix($colorPrimary, if($isDark, @neutral-22, @neutral-90), @tint-neutral); @color-surface-container-low: if($surfaceOverride, mix(@surface-lift, $colorSurface, if($isDark, 4%, 2%)), if($isDark, @dark-background, mix($colorPrimary, @neutral-96, @tint-neutral)));
@color-surface-container: if($surfaceOverride, mix(@surface-lift, $colorSurface, if($isDark, 6%, 4%)), if($isDark, mix(#FFFFFF, @dark-background, 3%), mix($colorPrimary, @neutral-94, @tint-neutral)));
@color-surface-container-high: if($surfaceOverride, mix(@surface-lift, $colorSurface, if($isDark, 11%, 6%)), if($isDark, mix(#FFFFFF, @dark-background, 8%), mix($colorPrimary, @neutral-92, @tint-neutral)));
@color-surface-container-highest: if($surfaceOverride, mix(@surface-lift, $colorSurface, if($isDark, 16%, 8%)), if($isDark, mix(#FFFFFF, @dark-background, 13%), mix($colorPrimary, @neutral-90, @tint-neutral)));
@color-outline: mix($colorPrimary, if($isDark, @neutral-60, @neutral-50), @tint-neutral-variant); @color-outline: mix($colorPrimary, if($isDark, @neutral-60, @neutral-50), @tint-neutral-variant);
@color-outline-variant: mix($colorPrimary, if($isDark, @neutral-30, @neutral-80), @tint-neutral-variant); @color-outline-variant: mix($colorPrimary, if($isDark, @neutral-30, @neutral-80), @tint-neutral-variant);
@color-inverse-surface: mix($colorPrimary, if($isDark, @neutral-90, @neutral-20), @tint-neutral); @color-inverse-surface: mix($colorPrimary, if($isDark, @neutral-90, @neutral-20), @tint-neutral);
@ -203,8 +211,8 @@
// Page indicator (lvgl_page_indicator_create): a dot per page, the current page's dot is filled // Page indicator (lvgl_page_indicator_create): a dot per page, the current page's dot is filled
@page-indicator-color: @color-on-surface-variant; @page-indicator-color: @color-on-surface-variant;
@page-indicator-dot-size: if($isCompact, 4, 6dp); @page-indicator-dot-size: if($isCompact, 6, 8dp);
@page-indicator-gap: if($isCompact, 3, 6dp); @page-indicator-gap: if($isCompact, 4, 6dp);
// Badge (lvgl_badge_create): a small dot that marks an item, e.g. a favourite // Badge (lvgl_badge_create): a small dot that marks an item, e.g. a favourite
@badge-color: @color-secondary; @badge-color: @color-secondary;
@ -250,10 +258,9 @@
@icon-button-tonal-icon-color: @color-on-secondary-container; @icon-button-tonal-icon-color: @color-on-secondary-container;
@icon-button-tonal-pressed-color: mix(@color-on-secondary-container, @color-secondary-container, @opacity-state-pressed); @icon-button-tonal-pressed-color: mix(@color-on-secondary-container, @color-secondary-container, @opacity-state-pressed);
// Toolbar: Material 2 top app bar, filled with a dark neutral tone for contrast with the content. // Toolbar: Material 3 top app bar, in the same colour as the app content and the statusbar (which shows the window's container colour)
// The tone is the same in light and dark mode. The statusbar uses a darker neutral tone with the same tint. @app-bar-color: @color-surface-container-low;
@app-bar-color: mix($colorPrimary, @neutral-22, @tint-neutral); @app-bar-content-color: @color-on-surface;
@app-bar-content-color: white;
@app-bar-pressed-opacity: 20%; @app-bar-pressed-opacity: 20%;
@app-bar-pad-hor: if($isCompact, 0, @space-1); @app-bar-pad-hor: if($isCompact, 0, @space-1);
@app-bar-gap: if($isCompact, 2, @space-1); @app-bar-gap: if($isCompact, 2, @space-1);
@ -373,12 +380,15 @@
@list-divider-width: @density-border; @list-divider-width: @density-border;
@list-subheader-text-color: @color-on-surface-variant; @list-subheader-text-color: @color-on-surface-variant;
// Tabs: Material 3 primary tabs // Tabs: the tab bar is a Material 3 navigation bar with a pill-shaped active indicator
@tabs-divider-color: @color-outline-variant; @tabs-color: @card-color;
@tabs-pad: if($isCompact, 2, @space-1);
@tabs-gap: if($isCompact, 2, @space-1);
@tab-label-color: @color-on-surface-variant; @tab-label-color: @color-on-surface-variant;
@tab-selected-label-color: @color-primary; @tab-pad: if($isCompact, 2, @space-1);
@tab-indicator-color: @color-primary; @tab-shape: @shape-control;
@tab-indicator-width: if($isCompact, 2, 3dp); @tab-selected-color: @color-secondary-container;
@tab-selected-label-color: @color-on-secondary-container;
@tab-page-pad: @density-container-pad; @tab-page-pad: @density-container-pad;
// Dialog (message box) // Dialog (message box)
@ -613,6 +623,8 @@ chip {
radius: @chip-shape; radius: @chip-shape;
padHor: @chip-pad-hor; padHor: @chip-pad-hor;
padVer: @chip-pad-ver; padVer: @chip-pad-ver;
// Room for the focus ring, which the parent would clip
marginAll: @focus-width + @focus-offset;
} }
chip_selected { chip_selected {
@ -630,6 +642,8 @@ button {
padHor: @button-pad-hor; padHor: @button-pad-hor;
padVer: @button-pad-ver; padVer: @button-pad-ver;
padGap: @button-gap; padGap: @button-gap;
// Room for the focus ring, which the parent would clip
marginAll: @focus-width + @focus-offset;
} }
button_pressed { button_pressed {
@ -733,6 +747,8 @@ app_bar_field {
switch_track { switch_track {
width: @switch-width; width: @switch-width;
height: @switch-height; height: @switch-height;
// Room for the focus ring, which the parent would clip
marginAll: @focus-width + @focus-offset;
bgColor: @switch-track-color; bgColor: @switch-track-color;
bgOpa: cover; bgOpa: cover;
borderColor: @switch-track-border-color; borderColor: @switch-track-border-color;
@ -792,6 +808,8 @@ checkbox_box_selected {
sliderbox { sliderbox {
padColumn: @sliderbox-gap; padColumn: @sliderbox-gap;
// Room for the focus ring of the step buttons, which the sliderbox would clip
padAll: @focus-width + @focus-offset;
} }
slider_track_size { slider_track_size {
@ -996,23 +1014,25 @@ list_subheader {
} }
tabs { tabs {
bgOpa: transp; bgColor: @tabs-color;
borderColor: @tabs-divider-color; bgOpa: cover;
borderWidth: @density-border; borderWidth: 0;
borderSide: bottom; radius: @card-shape;
padAll: @tabs-pad;
padGap: @tabs-gap;
} }
tab { tab {
bgOpa: transp; bgOpa: transp;
textColor: @tab-label-color; textColor: @tab-label-color;
radius: 0; radius: @tab-shape;
padAll: @tab-pad;
} }
tab_selected { tab_selected {
bgColor: @tab-selected-color;
bgOpa: cover;
textColor: @tab-selected-label-color; textColor: @tab-selected-label-color;
borderColor: @tab-indicator-color;
borderWidth: @tab-indicator-width;
borderSide: bottom;
} }
tab_page { tab_page {
@ -1414,6 +1434,11 @@ apply(page_indicator > label) {
page_indicator_label: default; page_indicator_label: default;
} }
// Labels can be selectable, e.g. rows that keys move through
apply(label) {
focus_ring: state.focusKey;
}
apply(page_indicator_dot) { apply(page_indicator_dot) {
page_indicator_dot: default; page_indicator_dot: default;
page_indicator_dot_current: state.checked; page_indicator_dot_current: state.checked;

View File

@ -146,8 +146,8 @@
// Page indicator (lvgl_page_indicator_create): a dot per page, the current page's dot is filled // Page indicator (lvgl_page_indicator_create): a dot per page, the current page's dot is filled
@page-indicator-color: @color-on-surface; @page-indicator-color: @color-on-surface;
@page-indicator-dot-size: if($isCompact, 4, 6dp); @page-indicator-dot-size: if($isCompact, 6, 8dp);
@page-indicator-gap: if($isCompact, 3, 6dp); @page-indicator-gap: if($isCompact, 4, 6dp);
// Badge (lvgl_badge_create): a small dot that marks an item, e.g. a favourite // Badge (lvgl_badge_create): a small dot that marks an item, e.g. a favourite
@badge-color: @color-on-surface; @badge-color: @color-on-surface;
@ -180,7 +180,6 @@
// Toolbar: top app bar with a divider, which shows the background like the statusbar does // Toolbar: top app bar with a divider, which shows the background like the statusbar does
@app-bar-title-color: @color-on-surface; @app-bar-title-color: @color-on-surface;
@app-bar-divider-color: @color-outline;
@app-bar-pad-hor: if($isCompact, 0, @space-1); @app-bar-pad-hor: if($isCompact, 0, @space-1);
@app-bar-gap: if($isCompact, 2, @space-1); @app-bar-gap: if($isCompact, 2, @space-1);
// The focus ring is drawn inside toolbar buttons, as there's little room around them // The focus ring is drawn inside toolbar buttons, as there's little room around them
@ -268,10 +267,11 @@
@list-item-gap: if($isCompact, 3, @space-4); @list-item-gap: if($isCompact, 3, @space-4);
@list-divider-color: @color-outline; @list-divider-color: @color-outline;
// Tabs // Tabs: the tab bar is a navigation bar, the selected tab is an inverted pill
@tabs-divider-color: @color-outline; @tabs-pad: if($isCompact, 2, @space-1);
@tab-indicator-color: @color-primary; @tabs-gap: if($isCompact, 2, @space-1);
@tab-indicator-width: if($isCompact, @line-medium, @line-thick); @tab-pad: if($isCompact, 2, @space-1);
@tab-shape: @shape-control;
@tab-page-pad: @density-container-pad; @tab-page-pad: @density-container-pad;
// Dialog // Dialog
@ -441,6 +441,8 @@ chip {
radius: @chip-shape; radius: @chip-shape;
padHor: @chip-pad-hor; padHor: @chip-pad-hor;
padVer: @chip-pad-ver; padVer: @chip-pad-ver;
// Room for the focus ring, which the parent would clip
marginAll: @focus-width + @focus-offset;
} }
button { button {
@ -453,6 +455,8 @@ button {
padHor: @button-pad-hor; padHor: @button-pad-hor;
padVer: @button-pad-ver; padVer: @button-pad-ver;
padGap: @button-gap; padGap: @button-gap;
// Room for the focus ring, which the parent would clip
marginAll: @focus-width + @focus-offset;
} }
icon_button { icon_button {
@ -494,9 +498,7 @@ app_bar {
bgOpa: transp; bgOpa: transp;
textColor: @app-bar-title-color; textColor: @app-bar-title-color;
radius: 0; radius: 0;
borderColor: @app-bar-divider-color; borderWidth: 0;
borderWidth: @border;
borderSide: bottom;
padHor: @app-bar-pad-hor; padHor: @app-bar-pad-hor;
padVer: 0; padVer: 0;
padGap: @app-bar-gap; padGap: @app-bar-gap;
@ -516,6 +518,8 @@ app_bar_focus_ring {
switch_track { switch_track {
width: @switch-width; width: @switch-width;
height: @switch-height; height: @switch-height;
// Room for the focus ring, which the parent would clip
marginAll: @focus-width + @focus-offset;
bgColor: @switch-track-color; bgColor: @switch-track-color;
bgOpa: cover; bgOpa: cover;
borderColor: @switch-track-border-color; borderColor: @switch-track-border-color;
@ -565,6 +569,8 @@ checkbox_box_selected {
sliderbox { sliderbox {
padColumn: @sliderbox-gap; padColumn: @sliderbox-gap;
// Room for the focus ring of the step buttons, which the sliderbox would clip
padAll: @focus-width + @focus-offset;
} }
slider_track_size { slider_track_size {
@ -723,22 +729,17 @@ list_subheader {
tabs { tabs {
bgOpa: transp; bgOpa: transp;
borderColor: @tabs-divider-color; borderWidth: 0;
borderWidth: @border; padAll: @tabs-pad;
borderSide: bottom; padGap: @tabs-gap;
} }
tab { tab {
bgOpa: transp; bgOpa: transp;
textColor: @color-on-surface; textColor: @color-on-surface;
borderWidth: 0; borderWidth: 0;
radius: 0; radius: @tab-shape;
} padAll: @tab-pad;
tab_selected {
borderColor: @tab-indicator-color;
borderWidth: @tab-indicator-width;
borderSide: bottom;
} }
tab_page { tab_page {
@ -978,7 +979,7 @@ apply(obj) {
apply(tabview > *[0] > button) { apply(tabview > *[0] > button) {
tab: default; tab: default;
inverted: state.pressed; inverted: state.pressed;
tab_selected: state.checked; inverted: state.checked;
focus_ring: state.focusKey; focus_ring: state.focusKey;
edit_ring: state.edited; edit_ring: state.edited;
} }
@ -1077,6 +1078,11 @@ apply(page_indicator > label) {
page_indicator_label: default; page_indicator_label: default;
} }
// Labels can be selectable, e.g. rows that keys move through
apply(label) {
focus_ring: state.focusKey;
}
apply(page_indicator_dot) { apply(page_indicator_dot) {
page_indicator_dot: default; page_indicator_dot: default;
page_indicator_dot_current: state.checked; page_indicator_dot_current: state.checked;

View File

@ -13,6 +13,13 @@ enum class ThemeMode {
Dark Dark
}; };
enum class DensityMode {
/** Regular or compact, as configured for the device */
DeviceDefault,
Regular,
Compact
};
struct AppearanceSettings { struct AppearanceSettings {
/** Default text font size in pixels, or 0 for the device's default size */ /** Default text font size in pixels, or 0 for the device's default size */
uint16_t fontSize = 0; uint16_t fontSize = 0;
@ -23,15 +30,28 @@ struct AppearanceSettings {
ThemeMode themeMode = ThemeMode::DeviceDefault; ThemeMode themeMode = ThemeMode::DeviceDefault;
/** Use the monochrome theme, also on colour displays */ /** Use the monochrome theme, also on colour displays */
bool monoTheme = false; bool monoTheme = false;
DensityMode densityMode = DensityMode::DeviceDefault;
bool animationsEnabled = true; bool animationsEnabled = true;
/** Theme colours as 0xRRGGBB, or empty for the theme's default colour */ /** Theme colours as 0xRRGGBB, or empty for the theme's default colour */
std::optional<uint32_t> primaryColor; std::optional<uint32_t> primaryColor;
std::optional<uint32_t> secondaryColor; std::optional<uint32_t> secondaryColor;
std::optional<uint32_t> errorColor; std::optional<uint32_t> errorColor;
/** Surface (screen background) colour as 0xRRGGBB, or empty for the theme's own surface. See getSurfaceColor(). */
std::optional<uint32_t> surfaceColor;
/** How strongly surfaceColor shows, from 0 (low) to SURFACE_TINT_LEVEL_COUNT - 1 (high) */
uint8_t surfaceTintLevel = 1;
bool operator==(const AppearanceSettings&) const = default; bool operator==(const AppearanceSettings&) const = default;
}; };
constexpr uint8_t SURFACE_TINT_LEVEL_COUNT = 3;
/**
* Mixes the surface colour into black (dark themes) or white (light themes), so the theme's text stays readable on it.
* @return the colour that the theme uses as its surface, as 0xRRGGBB
*/
uint32_t getSurfaceColor(uint32_t color, uint8_t tintLevel, bool isDark);
bool load(AppearanceSettings& settings); bool load(AppearanceSettings& settings);
AppearanceSettings loadOrGetDefault(); AppearanceSettings loadOrGetDefault();

View File

@ -48,9 +48,12 @@ public:
Secondary Secondary
}; };
/** Sets the colour of the tile icons. Call before creating the widgets. */ /** Sets the colour of the tile icons. Applies from the next (re)population. */
void setIconColor(IconColor color) { iconColor = color; } void setIconColor(IconColor color) { iconColor = color; }
/** Shows the first page from the next (re)population. */
void showFirstPage() { page = 0; }
/** Swiping left or right over the grid goes to the next or previous page. Call before creating the widgets. */ /** Swiping left or right over the grid goes to the next or previous page. Call before creating the widgets. */
void setSwipeNavigation(bool enabled) { swipeNavigation = enabled; } void setSwipeNavigation(bool enabled) { swipeNavigation = enabled; }
@ -89,8 +92,8 @@ private:
lv_obj_t* prevButton = nullptr; lv_obj_t* prevButton = nullptr;
lv_obj_t* nextButton = nullptr; lv_obj_t* nextButton = nullptr;
lv_obj_t* pageIndicator = nullptr; lv_obj_t* pageIndicator = nullptr;
lv_obj_t* pageButtons = nullptr; lv_obj_t* bottomBar = nullptr;
lv_obj_t* barButtons = nullptr; lv_obj_t* barSpacer = nullptr;
void createGrid(lv_obj_t* parent); void createGrid(lv_obj_t* parent);
void goToPreviousPage(); void goToPreviousPage();
@ -105,6 +108,7 @@ private:
static void onPrevPressed(lv_event_t* e); static void onPrevPressed(lv_event_t* e);
static void onNextPressed(lv_event_t* e); static void onNextPressed(lv_event_t* e);
static void onGridGesture(lv_event_t* e); static void onGridGesture(lv_event_t* e);
static void onBottomBarFocused(lv_event_t* e);
static void onTileClicked(lv_event_t* e); static void onTileClicked(lv_event_t* e);
static void onTileLongPressed(lv_event_t* e); static void onTileLongPressed(lv_event_t* e);
static void onTileKey(lv_event_t* e); static void onTileKey(lv_event_t* e);

View File

@ -26,7 +26,7 @@ class View final {
void updateScanning(); void updateScanning();
void updatePeerList(); void updatePeerList();
void createPeerListItem(const bluetooth::PeerRecord& record, bool isPaired, size_t index); void createPeerListItem(lv_obj_t* list, const bluetooth::PeerRecord& record, bool isPaired, size_t index);
static void onConnect(lv_event_t* event); static void onConnect(lv_event_t* event);

View File

@ -1,6 +1,7 @@
#pragma once #pragma once
#include <string> #include <string>
#include <vector>
#include <cstdint> #include <cstdint>
struct Device; struct Device;
@ -9,7 +10,8 @@ namespace tt::app::i2cscanner {
std::string getAddressText(uint8_t address); std::string getAddressText(uint8_t address);
std::string getPortNamesForDropdown(); /** The names of the ready I2C controllers, in the order of getActivePortAtIndex() */
std::vector<std::string> getPortNames();
bool getActivePortAtIndex(int32_t index, struct Device** out); bool getActivePortAtIndex(int32_t index, struct Device** out);

View File

@ -0,0 +1,27 @@
#pragma once
#include <Tactility/app/AppGrid.h>
#include <cstdint>
#include <vector>
namespace tt::app::launcher {
/** The content of the launcher grid and its tile interactions */
struct LauncherMode {
/** Represents this mode on the button that switches to it */
const char* buttonIcon;
/** The tile icon colour for themes that aren't monochrome */
AppGrid::IconColor iconColor;
/** Returns the items in display order */
std::vector<AppGridItem> (*collect)();
/** Optional */
void (*onLongPressed)(AppGrid& grid, const ::AppManifest& manifest);
/** Optional */
void (*onKey)(AppGrid& grid, const ::AppManifest& manifest, uint32_t key);
};
extern const LauncherMode APPS_MODE;
extern const LauncherMode SETTINGS_MODE;
}

View File

@ -25,7 +25,7 @@ class View final {
void updateScanning(); void updateScanning();
void updateNetworkList(); void updateNetworkList();
void updateConnectToHidden(); void updateConnectToHidden();
void createSsidListItem(const WifiApRecord& record, bool isConnecting, size_t index); void createSsidListItem(lv_obj_t* list, const WifiApRecord& record, bool isConnecting, size_t index);
static void showDetails(lv_event_t* event); static void showDetails(lv_event_t* event);
static void connect(lv_event_t* event); static void connect(lv_event_t* event);

View File

@ -61,7 +61,6 @@ namespace app {
namespace power { extern const ::AppManifest manifest; } namespace power { extern const ::AppManifest manifest; }
namespace poweroff { extern const ::AppManifest manifest; } namespace poweroff { extern const ::AppManifest manifest; }
namespace selectiondialog { extern const ::AppManifest manifest; } namespace selectiondialog { extern const ::AppManifest manifest; }
namespace settings { extern const ::AppManifest manifest; }
namespace setup { extern const ::AppManifest manifest; } namespace setup { extern const ::AppManifest manifest; }
namespace shell { extern const ::AppManifest manifest; extern const ::AppManifest sh_manifest; } namespace shell { extern const ::AppManifest manifest; extern const ::AppManifest sh_manifest; }
namespace systeminfo { extern const ::AppManifest manifest; } namespace systeminfo { extern const ::AppManifest manifest; }
@ -131,7 +130,6 @@ static void registerInternalApps() {
if (device_exists_of_type(&POWER_SUPPLY_TYPE)) { if (device_exists_of_type(&POWER_SUPPLY_TYPE)) {
app_manager_add(&app::poweroff::manifest); app_manager_add(&app::poweroff::manifest);
} }
app_manager_add(&app::settings::manifest);
app_manager_add(&app::selectiondialog::manifest); app_manager_add(&app::selectiondialog::manifest);
app_manager_add(&app::setup::manifest); app_manager_add(&app::setup::manifest);
app_manager_add(&app::shell::manifest); app_manager_add(&app::shell::manifest);

View File

@ -56,43 +56,39 @@ const AppGridItem* getTileItem(lv_event_t* e) {
} }
lv_obj_t* createBarSection(lv_obj_t* parent, lv_flex_align_t align) { // The width of the visible buttons on one side of the bar's spacer, including the gaps between them
auto* section = lv_obj_create(parent); int32_t getSideWidth(lv_obj_t* bar, bool rightSide) {
lv_obj_set_size(section, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(section, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(section, 0, LV_STATE_DEFAULT);
lv_obj_set_flex_flow(section, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(section, align, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_remove_flag(section, LV_OBJ_FLAG_SCROLLABLE);
return section;
}
// The width that a section's visible children need, including the gaps between them
int32_t getContentWidth(lv_obj_t* section) {
int32_t width = 0; int32_t width = 0;
uint32_t visible = 0; uint32_t visible = 0;
const uint32_t count = lv_obj_get_child_count(section); bool afterSpacer = false;
const uint32_t count = lv_obj_get_child_count(bar);
for (uint32_t i = 0; i < count; i++) { for (uint32_t i = 0; i < count; i++) {
lv_obj_t* child = lv_obj_get_child(section, static_cast<int32_t>(i)); lv_obj_t* child = lv_obj_get_child(bar, static_cast<int32_t>(i));
if (!lv_obj_has_flag(child, LV_OBJ_FLAG_HIDDEN)) { if (lv_obj_get_style_flex_grow(child, LV_PART_MAIN) > 0) {
afterSpacer = true;
} else if (afterSpacer == rightSide && !lv_obj_has_flag_any(child, static_cast<lv_obj_flag_t>(LV_OBJ_FLAG_HIDDEN | LV_OBJ_FLAG_IGNORE_LAYOUT))) {
width += lv_obj_get_width(child); width += lv_obj_get_width(child);
visible++; visible++;
} }
} }
return visible > 1 ? width + static_cast<int32_t>(visible - 1) * lv_obj_get_style_pad_column(section, LV_PART_MAIN) : width; return visible > 1 ? width + static_cast<int32_t>(visible - 1) * lv_obj_get_style_pad_column(bar, LV_PART_MAIN) : width;
} }
// The page indicator gets the space between the side sections, which grow equally to keep it centered // The page indicator is centered on the bar and gets the space that the buttons on the wider side leave on both sides
void updatePageIndicatorWidth(void* data) { void updatePageIndicatorWidth(void* data) {
auto* bottom_bar = static_cast<lv_obj_t*>(data); auto* bottom_bar = static_cast<lv_obj_t*>(data);
auto* left_section = lv_obj_get_child(bottom_bar, 0); lv_obj_t* indicator = nullptr;
auto* indicator_section = lv_obj_get_child(bottom_bar, 1); const uint32_t count = lv_obj_get_child_count(bottom_bar);
auto* page_buttons = lv_obj_get_child(bottom_bar, 2); for (uint32_t i = 0; i < count && indicator == nullptr; i++) {
auto* indicator = lv_obj_get_child(indicator_section, 0); lv_obj_t* child = lv_obj_get_child(bottom_bar, static_cast<int32_t>(i));
if (lv_obj_has_flag(child, LV_OBJ_FLAG_IGNORE_LAYOUT)) {
indicator = child;
}
}
if (indicator == nullptr) { if (indicator == nullptr) {
return; return;
} }
const int32_t side_width = std::max(getContentWidth(left_section), getContentWidth(page_buttons)); const int32_t side_width = std::max(getSideWidth(bottom_bar, false), getSideWidth(bottom_bar, true));
const int32_t gaps = 2 * lv_obj_get_style_pad_column(bottom_bar, LV_PART_MAIN); const int32_t gaps = 2 * lv_obj_get_style_pad_column(bottom_bar, LV_PART_MAIN);
const int32_t available = lv_obj_get_content_width(bottom_bar) - 2 * side_width - gaps; const int32_t available = lv_obj_get_content_width(bottom_bar) - 2 * side_width - gaps;
lv_obj_set_style_max_width(indicator, std::max<int32_t>(0, available), LV_STATE_DEFAULT); lv_obj_set_style_max_width(indicator, std::max<int32_t>(0, available), LV_STATE_DEFAULT);
@ -230,7 +226,8 @@ void AppGrid::populate() {
pageCount = page_count; pageCount = page_count;
lv_obj_set_flag(pageIndicator, LV_OBJ_FLAG_HIDDEN, page_count <= 1); lv_obj_set_flag(pageIndicator, LV_OBJ_FLAG_HIDDEN, page_count <= 1);
lv_obj_set_flag(pageButtons, LV_OBJ_FLAG_HIDDEN, page_count <= 1); lv_obj_set_flag(prevButton, LV_OBJ_FLAG_HIDDEN, page_count <= 1);
lv_obj_set_flag(nextButton, LV_OBJ_FLAG_HIDDEN, page_count <= 1);
lvgl_page_indicator_set_page_count(pageIndicator, page_count); lvgl_page_indicator_set_page_count(pageIndicator, page_count);
lvgl_page_indicator_set_page(pageIndicator, page); lvgl_page_indicator_set_page(pageIndicator, page);
@ -317,31 +314,53 @@ void AppGrid::createGrid(lv_obj_t* parent) {
} }
} }
// Keys enter the bar on the previous page button, or on the first button when there's only one page
void AppGrid::onBottomBarFocused(lv_event_t* e) {
const auto* self = static_cast<AppGrid*>(lv_event_get_user_data(e));
lv_obj_t* current = lvgl_grid_navigation_get_focused(self->bottomBar);
lv_obj_t* target = lv_obj_has_flag(self->prevButton, LV_OBJ_FLAG_HIDDEN) ? lv_obj_get_child(self->bottomBar, 0) : self->prevButton;
if (current == nullptr || target == current || target == self->barSpacer) {
return;
}
const bool showSelection = lv_obj_has_state(current, LV_STATE_FOCUS_KEY);
lv_gridnav_set_focused(self->bottomBar, target, LV_ANIM_OFF);
if (!showSelection) {
lv_obj_remove_state(target, LV_STATE_FOCUS_KEY);
}
}
void AppGrid::createWidgetsWithBottomBar(lv_obj_t* parent) { void AppGrid::createWidgetsWithBottomBar(lv_obj_t* parent) {
// The side sections grow equally, so the page indicator stays centered bottomBar = lv_obj_create(parent);
auto* bottom_bar = createBarSection(parent, LV_FLEX_ALIGN_START); lv_obj_set_size(bottomBar, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_width(bottom_bar, LV_PCT(100)); lv_obj_set_style_pad_all(bottomBar, 0, LV_STATE_DEFAULT);
lvgl_obj_add_edge_padding(bottom_bar); lv_obj_set_style_border_width(bottomBar, 0, LV_STATE_DEFAULT);
barButtons = createBarSection(bottom_bar, LV_FLEX_ALIGN_START); lv_obj_set_flex_flow(bottomBar, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_grow(barButtons, 1); lv_obj_set_flex_align(bottomBar, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
auto* indicator_section = createBarSection(bottom_bar, LV_FLEX_ALIGN_CENTER); lv_obj_remove_flag(bottomBar, LV_OBJ_FLAG_SCROLLABLE);
pageButtons = createBarSection(bottom_bar, LV_FLEX_ALIGN_END); lvgl_obj_add_edge_padding(bottomBar);
lv_obj_set_flex_grow(pageButtons, 1); lv_obj_add_event_cb(bottomBar, onBottomBarSizeChanged, LV_EVENT_SIZE_CHANGED, nullptr);
lv_obj_add_event_cb(bottom_bar, onBottomBarSizeChanged, LV_EVENT_SIZE_CHANGED, nullptr); lv_obj_add_event_cb(bottomBar, onBottomBarDeleted, LV_EVENT_DELETE, nullptr);
lv_obj_add_event_cb(bottom_bar, onBottomBarDeleted, LV_EVENT_DELETE, nullptr);
createGrid(parent); createGrid(parent);
lv_obj_move_foreground(bottom_bar); lv_obj_move_foreground(bottomBar);
pageIndicator = lvgl_page_indicator_create(indicator_section); // The bar buttons go before the spacer, which pushes the page buttons to the right
prevButton = lvgl_icon_button_create(pageButtons); barSpacer = lv_obj_create(bottomBar);
lv_obj_set_size(barSpacer, 0, 0);
lv_obj_set_flex_grow(barSpacer, 1);
lv_obj_remove_flag(barSpacer, LV_OBJ_FLAG_CLICKABLE);
prevButton = lvgl_icon_button_create(bottomBar);
lv_label_set_text(lv_label_create(prevButton), "<"); lv_label_set_text(lv_label_create(prevButton), "<");
lv_obj_add_event_cb(prevButton, onPrevPressed, LV_EVENT_SHORT_CLICKED, this); lv_obj_add_event_cb(prevButton, onPrevPressed, LV_EVENT_SHORT_CLICKED, this);
nextButton = lvgl_icon_button_create(pageButtons); nextButton = lvgl_icon_button_create(bottomBar);
lv_label_set_text(lv_label_create(nextButton), ">"); lv_label_set_text(lv_label_create(nextButton), ">");
lv_obj_add_event_cb(nextButton, onNextPressed, LV_EVENT_SHORT_CLICKED, this); lv_obj_add_event_cb(nextButton, onNextPressed, LV_EVENT_SHORT_CLICKED, this);
// The arrow keys move between the page buttons pageIndicator = lvgl_page_indicator_create(bottomBar);
lvgl_grid_navigation_add(pageButtons); lv_obj_add_flag(pageIndicator, LV_OBJ_FLAG_IGNORE_LAYOUT);
lv_obj_align(pageIndicator, LV_ALIGN_CENTER, 0, 0);
// The arrow keys move between all buttons of the bar as one row
lvgl_grid_navigation_add(bottomBar);
lv_obj_add_event_cb(bottomBar, onBottomBarFocused, LV_EVENT_FOCUSED, this);
// Resolves the grid's flex_grow height, which the page size is derived from. // Resolves the grid's flex_grow height, which the page size is derived from.
lv_obj_update_layout(parent); lv_obj_update_layout(parent);
@ -356,7 +375,8 @@ void AppGrid::createWidgetsWithBottomBar(lv_obj_t* parent) {
} }
lv_obj_t* AppGrid::addBarButton(const char* icon, lv_event_cb_t onClicked, void* userData) { lv_obj_t* AppGrid::addBarButton(const char* icon, lv_event_cb_t onClicked, void* userData) {
auto* button = lvgl_icon_button_create(barButtons); auto* button = lvgl_icon_button_create(bottomBar);
lv_obj_move_to_index(button, lv_obj_get_index(barSpacer));
auto* label = lv_label_create(button); auto* label = lv_label_create(button);
lv_obj_set_style_text_font(label, lvgl_get_shared_icon_default_font(), LV_STATE_DEFAULT); lv_obj_set_style_text_font(label, lvgl_get_shared_icon_default_font(), LV_STATE_DEFAULT);
lv_label_set_text(label, icon); lv_label_set_text(label, icon);

View File

@ -20,6 +20,7 @@
#include <tactility/log.h> #include <tactility/log.h>
#include <lvgl.h> #include <lvgl.h>
#include <lvgl/fonts.h>
#include <lvgl/lvgl.h> #include <lvgl/lvgl.h>
#include <lvgl/theme.h> #include <lvgl/theme.h>
#include <lvgl/widgets/card.h> #include <lvgl/widgets/card.h>
@ -74,8 +75,11 @@ constexpr NamedColor PALETTE_COLORS[] = {
{ "Grey", LV_PALETTE_GREY }, { "Grey", LV_PALETTE_GREY },
}; };
constexpr size_t COLOR_COUNT = 3; constexpr size_t COLOR_COUNT = 4;
constexpr const char* COLOR_TITLES[COLOR_COUNT] = { "Primary color", "Secondary color", "Error color" }; constexpr size_t SURFACE_COLOR_INDEX = 2;
constexpr const char* COLOR_TITLES[COLOR_COUNT] = { "Primary color", "Secondary color", "Surface color", "Error color" };
// In tint level order (see settings::appearance::getSurfaceColor())
constexpr const char* SURFACE_TINT_NAMES[settings::appearance::SURFACE_TINT_LEVEL_COUNT] = { "Low", "Medium", "High" };
struct ColorRowWidgets { struct ColorRowWidgets {
lv_obj_t* row = nullptr; lv_obj_t* row = nullptr;
@ -118,7 +122,11 @@ struct Context {
AppStream selectStream {}; AppStream selectStream {};
uint8_t selectBuffer[256] {}; uint8_t selectBuffer[256] {};
/** The scroll position of the content, kept while e.g. a selection dialog covers the window. Accessed with the LVGL lock held. */
int32_t contentScrollY = 0;
// Valid while the window is shown // Valid while the window is shown
lv_obj_t* content = nullptr;
lv_obj_t* applyButton = nullptr; lv_obj_t* applyButton = nullptr;
lv_obj_t* spinner = nullptr; lv_obj_t* spinner = nullptr;
FontRowWidgets regularRow; FontRowWidgets regularRow;
@ -126,9 +134,17 @@ struct Context {
lv_obj_t* errorLabel = nullptr; lv_obj_t* errorLabel = nullptr;
lv_obj_t* lightChip = nullptr; lv_obj_t* lightChip = nullptr;
lv_obj_t* darkChip = nullptr; lv_obj_t* darkChip = nullptr;
lv_obj_t* regularDensityChip = nullptr;
lv_obj_t* compactDensityChip = nullptr;
lv_obj_t* regularThemeChip = nullptr; lv_obj_t* regularThemeChip = nullptr;
lv_obj_t* monoThemeChip = nullptr; lv_obj_t* monoThemeChip = nullptr;
// Shown for the regular theme: the "Theme Colors" title and card
lv_obj_t* colorsTitle = nullptr;
lv_obj_t* colorsCard = nullptr;
ColorRowWidgets colorRows[COLOR_COUNT]; ColorRowWidgets colorRows[COLOR_COUNT];
/** Shown when a surface colour is set */
lv_obj_t* surfaceTintRow = nullptr;
lv_obj_t* surfaceTintChips[settings::appearance::SURFACE_TINT_LEVEL_COUNT] = {};
/** The display can only show the mono theme */ /** The display can only show the mono theme */
bool isMonoDisplay = false; bool isMonoDisplay = false;
}; };
@ -177,6 +193,23 @@ bool isDeviceDefaultDark() {
return defaults.is_dark; return defaults.is_dark;
} }
bool isDeviceDefaultCompact() {
LvglThemeSettings defaults;
lvgl_theme_get_default_settings(&defaults);
return defaults.is_compact;
}
bool isCompact(const settings::appearance::AppearanceSettings& settings) {
switch (settings.densityMode) {
case settings::appearance::DensityMode::Regular:
return false;
case settings::appearance::DensityMode::Compact:
return true;
default:
return isDeviceDefaultCompact();
}
}
bool isDark(const settings::appearance::AppearanceSettings& settings) { bool isDark(const settings::appearance::AppearanceSettings& settings) {
switch (settings.themeMode) { switch (settings.themeMode) {
case settings::appearance::ThemeMode::Light: case settings::appearance::ThemeMode::Light:
@ -197,22 +230,39 @@ void setChecked(lv_obj_t* object, bool checked) {
} }
void updateColorRow(Context* ctx, size_t index); void updateColorRow(Context* ctx, size_t index);
uint32_t getDefaultColor(size_t index);
void updateSurfaceTintRow(Context* ctx) {
if (ctx->surfaceTintRow == nullptr) {
return;
}
const auto& settings = ctx->pendingSettings;
setHidden(ctx->surfaceTintRow, !settings.surfaceColor.has_value());
for (uint8_t i = 0; i < settings::appearance::SURFACE_TINT_LEVEL_COUNT; i++) {
setChecked(ctx->surfaceTintChips[i], settings.surfaceTintLevel == i);
}
}
void updateThemeWidgets(Context* ctx) { void updateThemeWidgets(Context* ctx) {
const bool dark = isDark(ctx->pendingSettings); const bool dark = isDark(ctx->pendingSettings);
setChecked(ctx->lightChip, !dark); setChecked(ctx->lightChip, !dark);
setChecked(ctx->darkChip, dark); setChecked(ctx->darkChip, dark);
const bool compact = isCompact(ctx->pendingSettings);
setChecked(ctx->regularDensityChip, !compact);
setChecked(ctx->compactDensityChip, compact);
if (ctx->regularThemeChip != nullptr) { if (ctx->regularThemeChip != nullptr) {
setChecked(ctx->regularThemeChip, !ctx->pendingSettings.monoTheme); setChecked(ctx->regularThemeChip, !ctx->pendingSettings.monoTheme);
setChecked(ctx->monoThemeChip, ctx->pendingSettings.monoTheme); setChecked(ctx->monoThemeChip, ctx->pendingSettings.monoTheme);
} }
// The mono theme doesn't use colours // The mono theme doesn't use colours
for (size_t i = 0; i < COLOR_COUNT; i++) { if (ctx->colorsCard != nullptr) {
if (ctx->colorRows[i].row != nullptr) { setHidden(ctx->colorsTitle, ctx->pendingSettings.monoTheme);
setHidden(ctx->colorRows[i].row, ctx->pendingSettings.monoTheme); setHidden(ctx->colorsCard, ctx->pendingSettings.monoTheme);
for (size_t i = 0; i < COLOR_COUNT; i++) {
updateColorRow(ctx, i); updateColorRow(ctx, i);
} }
} }
updateSurfaceTintRow(ctx);
} }
/** Updates the widgets to the pending settings. Requires the LVGL lock. */ /** Updates the widgets to the pending settings. Requires the LVGL lock. */
@ -247,6 +297,24 @@ void onDarkPressed(lv_event_t* event) {
setDark(static_cast<Context*>(lv_event_get_user_data(event)), true); setDark(static_cast<Context*>(lv_event_get_user_data(event)), true);
} }
void setCompact(Context* ctx, bool compact) {
// The device default is kept when it matches, so it doesn't count as a change
if (compact == isDeviceDefaultCompact()) {
ctx->pendingSettings.densityMode = settings::appearance::DensityMode::DeviceDefault;
} else {
ctx->pendingSettings.densityMode = compact ? settings::appearance::DensityMode::Compact : settings::appearance::DensityMode::Regular;
}
updateWidgets(ctx);
}
void onRegularDensityPressed(lv_event_t* event) {
setCompact(static_cast<Context*>(lv_event_get_user_data(event)), false);
}
void onCompactDensityPressed(lv_event_t* event) {
setCompact(static_cast<Context*>(lv_event_get_user_data(event)), true);
}
void onRegularThemePressed(lv_event_t* event) { void onRegularThemePressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event)); auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
ctx->pendingSettings.monoTheme = false; ctx->pendingSettings.monoTheme = false;
@ -259,10 +327,16 @@ void onMonoThemePressed(lv_event_t* event) {
updateWidgets(ctx); updateWidgets(ctx);
} }
void onSurfaceTintPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
ctx->pendingSettings.surfaceTintLevel = static_cast<uint8_t>(reinterpret_cast<uintptr_t>(lv_obj_get_user_data(lv_event_get_target_obj(event))));
updateWidgets(ctx);
}
void onAnimationsChanged(lv_event_t* event) { void onAnimationsChanged(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event)); auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
auto* checkbox = static_cast<lv_obj_t*>(lv_event_get_target(event)); auto* animations_switch = static_cast<lv_obj_t*>(lv_event_get_target(event));
ctx->pendingSettings.animationsEnabled = lv_obj_has_state(checkbox, LV_STATE_CHECKED); ctx->pendingSettings.animationsEnabled = lv_obj_has_state(animations_switch, LV_STATE_CHECKED);
updateWidgets(ctx); updateWidgets(ctx);
} }
@ -278,22 +352,35 @@ std::optional<uint32_t>& getColor(settings::appearance::AppearanceSettings& sett
return settings.primaryColor; return settings.primaryColor;
case 1: case 1:
return settings.secondaryColor; return settings.secondaryColor;
case SURFACE_COLOR_INDEX:
return settings.surfaceColor;
default: default:
return settings.errorColor; return settings.errorColor;
} }
} }
/** The surface has no default colour of its own: the theme derives it */
uint32_t getDefaultColor(size_t index) { uint32_t getDefaultColor(size_t index) {
LvglThemeSettings defaults; LvglThemeSettings defaults;
lvgl_theme_get_default_settings(&defaults); lvgl_theme_get_default_settings(&defaults);
const lv_color_t colors[COLOR_COUNT] = { defaults.color_primary, defaults.color_secondary, defaults.color_error }; const lv_color_t color = index == 0 ? defaults.color_primary : (index == 1 ? defaults.color_secondary : defaults.color_error);
return lv_color_to_u32(colors[index]) & 0xFFFFFF; return lv_color_to_u32(color) & 0xFFFFFF;
} }
void updateColorRow(Context* ctx, size_t index) { void updateColorRow(Context* ctx, size_t index) {
const auto& widgets = ctx->colorRows[index]; const auto& widgets = ctx->colorRows[index];
const auto& color = getColor(ctx->pendingSettings, 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); uint32_t shown;
if (color.has_value()) {
// The picked colour: the surface's tinted version of it is often too dark (or light) to recognise
shown = *color;
} else if (index != SURFACE_COLOR_INDEX) {
shown = getDefaultColor(index);
} else {
// The theme's own near-black or near-white surface
shown = isDark(ctx->pendingSettings) ? 0x131313 : 0xFAFAFA;
}
lv_obj_set_style_bg_color(widgets.swatch, lv_color_hex(shown), LV_STATE_DEFAULT);
} }
void onColorButtonPressed(lv_event_t* event) { void onColorButtonPressed(lv_event_t* event) {
@ -372,9 +459,6 @@ lv_obj_t* createRow(lv_obj_t* parent) {
lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); 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_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); 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); lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE);
return row; return row;
} }
@ -390,8 +474,10 @@ lv_obj_t* createSection(lv_obj_t* parent, const char* title) {
return card; return card;
} }
/** A row with a title. Its other items move to the next line when they don't fit next to the title. */
lv_obj_t* createLabeledRow(lv_obj_t* parent, const char* text) { lv_obj_t* createLabeledRow(lv_obj_t* parent, const char* text) {
auto* row = createRow(parent); auto* row = createRow(parent);
lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW_WRAP);
auto* label = lv_label_create(row); auto* label = lv_label_create(row);
lv_label_set_text(label, text); lv_label_set_text(label, text);
lv_obj_set_flex_grow(label, 1); lv_obj_set_flex_grow(label, 1);
@ -448,14 +534,34 @@ void createThemeCard(lv_obj_t* parent, Context* ctx) {
ctx->monoThemeChip = createChip(style_row, "Mono", onMonoThemePressed, ctx); ctx->monoThemeChip = createChip(style_row, "Mono", onMonoThemePressed, ctx);
} }
auto* animations_checkbox = lv_checkbox_create(card); auto* density_row = createLabeledRow(card, "Density");
lv_checkbox_set_text(animations_checkbox, "Animations"); ctx->regularDensityChip = createChip(density_row, "Regular", onRegularDensityPressed, ctx);
setChecked(animations_checkbox, ctx->pendingSettings.animationsEnabled); ctx->compactDensityChip = createChip(density_row, "Compact", onCompactDensityPressed, ctx);
lv_obj_add_event_cb(animations_checkbox, onAnimationsChanged, LV_EVENT_VALUE_CHANGED, ctx);
if (!ctx->isMonoDisplay) { auto* animations_row = createLabeledRow(card, "Animations");
for (size_t i = 0; i < COLOR_COUNT; i++) { auto* animations_switch = lv_switch_create(animations_row);
ctx->colorRows[i] = createColorRow(card, i, ctx); setChecked(animations_switch, ctx->pendingSettings.animationsEnabled);
lv_obj_add_event_cb(animations_switch, onAnimationsChanged, LV_EVENT_VALUE_CHANGED, ctx);
}
void createColorsCard(lv_obj_t* parent, Context* ctx) {
ctx->colorsTitle = lv_label_create(parent);
lv_label_set_text(ctx->colorsTitle, "Theme Colors");
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);
ctx->colorsCard = card;
for (size_t i = 0; i < COLOR_COUNT; i++) {
ctx->colorRows[i] = createColorRow(card, i, ctx);
if (i == SURFACE_COLOR_INDEX) {
// How strongly the surface colour shows: it's mixed into black (dark) or white (light)
ctx->surfaceTintRow = createLabeledRow(card, "Surface tint");
for (uint8_t level = 0; level < settings::appearance::SURFACE_TINT_LEVEL_COUNT; level++) {
ctx->surfaceTintChips[level] = createChip(ctx->surfaceTintRow, SURFACE_TINT_NAMES[level], onSurfaceTintPressed, ctx);
lv_obj_set_user_data(ctx->surfaceTintChips[level], reinterpret_cast<void*>(static_cast<uintptr_t>(level)));
}
} }
} }
} }
@ -470,21 +576,30 @@ lv_obj_t* createButton(lv_obj_t* parent, const char* text, lv_event_cb_t callbac
FontRowWidgets createFontRow(lv_obj_t* parent, const char* title, lv_event_cb_t onSelect, lv_event_cb_t onDefault, Context* ctx) { FontRowWidgets createFontRow(lv_obj_t* parent, const char* title, lv_event_cb_t onSelect, lv_event_cb_t onDefault, Context* ctx) {
FontRowWidgets widgets; FontRowWidgets widgets;
// "Title [Default]" // "Title [Default] [Select]"
auto* title_row = createRow(parent); // "file.ttf"
auto* label = lv_label_create(title_row); auto* row = createRow(parent);
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]" // The title with the file name below it, which take the width that the buttons leave. Texts that don't fit scroll.
auto* file_row = createRow(parent); auto* texts = lv_obj_create(row);
widgets.fileLabel = lv_label_create(file_row); lv_obj_remove_style_all(texts);
lv_obj_set_flex_grow(widgets.fileLabel, 1); lv_obj_set_height(texts, LV_SIZE_CONTENT);
lv_label_set_long_mode(widgets.fileLabel, LV_LABEL_LONG_MODE_DOTS); lv_obj_set_flex_grow(texts, 1);
lv_obj_set_style_pad_left(widgets.fileLabel, 12, LV_STATE_DEFAULT); lv_obj_set_flex_flow(texts, LV_FLEX_FLOW_COLUMN);
createButton(file_row, "Select", onSelect, ctx); lv_obj_remove_flag(texts, LV_OBJ_FLAG_SCROLLABLE);
auto* label = lv_label_create(texts);
lv_obj_set_width(label, LV_PCT(100));
lv_label_set_long_mode(label, LV_LABEL_LONG_MODE_SCROLL_CIRCULAR);
lv_label_set_text(label, title);
widgets.fileLabel = lv_label_create(texts);
lv_obj_set_width(widgets.fileLabel, LV_PCT(100));
lv_label_set_long_mode(widgets.fileLabel, LV_LABEL_LONG_MODE_SCROLL_CIRCULAR);
lv_obj_set_style_text_font(widgets.fileLabel, lvgl_get_text_font(FONT_SIZE_SMALL), LV_STATE_DEFAULT);
widgets.defaultButton = createButton(row, "Default", onDefault, ctx);
createButton(row, "Select", onSelect, ctx);
widgets.warningLabel = lv_label_create(parent); widgets.warningLabel = lv_label_create(parent);
lv_obj_set_width(widgets.warningLabel, LV_PCT(100)); lv_obj_set_width(widgets.warningLabel, LV_PCT(100));
@ -504,6 +619,7 @@ void createWidgets(lv_obj_t* parent, void* userData) {
ctx->applyButton = lvgl_toolbar_add_image_button_action(toolbar, LV_SYMBOL_OK, onApplyPressed, ctx); ctx->applyButton = lvgl_toolbar_add_image_button_action(toolbar, LV_SYMBOL_OK, onApplyPressed, ctx);
auto* content = lv_obj_create(parent); auto* content = lv_obj_create(parent);
ctx->content = content;
lv_obj_set_width(content, LV_PCT(100)); lv_obj_set_width(content, LV_PCT(100));
lv_obj_set_flex_grow(content, 1); lv_obj_set_flex_grow(content, 1);
lv_obj_set_flex_flow(content, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_flow(content, LV_FLEX_FLOW_COLUMN);
@ -512,6 +628,10 @@ void createWidgets(lv_obj_t* parent, void* userData) {
const auto color_format = lv_display_get_color_format(lv_obj_get_display(parent)); 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; ctx->isMonoDisplay = color_format == LV_COLOR_FORMAT_I1 || color_format == LV_COLOR_FORMAT_L8;
createThemeCard(content, ctx); createThemeCard(content, ctx);
// Monochrome and greyscale displays always use the mono theme, which doesn't use colours
if (!ctx->isMonoDisplay) {
createColorsCard(content, ctx);
}
auto* fonts_card = createSection(content, "Fonts"); auto* fonts_card = createSection(content, "Fonts");
auto* font_size_row = createRow(fonts_card); auto* font_size_row = createRow(fonts_card);
@ -543,7 +663,7 @@ void createWidgets(lv_obj_t* parent, void* userData) {
createStepButton(font_size_row, LV_SYMBOL_PLUS, onFontSizeIncrementPressed, 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->regularRow = createFontRow(fonts_card, "Regular font", onSelectRegularPressed, onDefaultRegularPressed, ctx);
ctx->monoRow = createFontRow(fonts_card, "Mono font", onSelectMonoPressed, onDefaultMonoPressed, ctx); ctx->monoRow = createFontRow(fonts_card, "Monospace font", onSelectMonoPressed, onDefaultMonoPressed, ctx);
ctx->errorLabel = lv_label_create(fonts_card); ctx->errorLabel = lv_label_create(fonts_card);
lv_obj_set_width(ctx->errorLabel, LV_PCT(100)); lv_obj_set_width(ctx->errorLabel, LV_PCT(100));
@ -552,10 +672,17 @@ void createWidgets(lv_obj_t* parent, void* userData) {
lv_obj_set_style_text_color(ctx->errorLabel, lv_palette_main(LV_PALETTE_RED), LV_STATE_DEFAULT); lv_obj_set_style_text_color(ctx->errorLabel, lv_palette_main(LV_PALETTE_RED), LV_STATE_DEFAULT);
updateWidgets(ctx); updateWidgets(ctx);
// The window is rebuilt when it's shown again, e.g. after a selection dialog
lv_obj_update_layout(content);
lv_obj_scroll_to_y(content, ctx->contentScrollY, LV_ANIM_OFF);
} }
void destroyWidgets(void* userData) { void destroyWidgets(void* userData) {
auto* ctx = static_cast<Context*>(userData); auto* ctx = static_cast<Context*>(userData);
// The LVGL lock is already held, so the content can still be read
ctx->contentScrollY = lv_obj_get_scroll_y(ctx->content);
ctx->content = nullptr;
ctx->applyButton = nullptr; ctx->applyButton = nullptr;
ctx->spinner = nullptr; ctx->spinner = nullptr;
ctx->regularRow = {}; ctx->regularRow = {};
@ -563,11 +690,19 @@ void destroyWidgets(void* userData) {
ctx->errorLabel = nullptr; ctx->errorLabel = nullptr;
ctx->lightChip = nullptr; ctx->lightChip = nullptr;
ctx->darkChip = nullptr; ctx->darkChip = nullptr;
ctx->regularDensityChip = nullptr;
ctx->compactDensityChip = nullptr;
ctx->regularThemeChip = nullptr; ctx->regularThemeChip = nullptr;
ctx->monoThemeChip = nullptr; ctx->monoThemeChip = nullptr;
ctx->colorsTitle = nullptr;
ctx->colorsCard = nullptr;
for (auto& row : ctx->colorRows) { for (auto& row : ctx->colorRows) {
row = {}; row = {};
} }
ctx->surfaceTintRow = nullptr;
for (auto& chip : ctx->surfaceTintChips) {
chip = nullptr;
}
} }
int32_t applyThreadMain(void* context) { int32_t applyThreadMain(void* context) {

View File

@ -20,6 +20,7 @@
#include <tactility/log.h> #include <tactility/log.h>
#include <lvgl/lvgl.h> #include <lvgl/lvgl.h>
#include <lvgl/widgets/card.h>
#include <lvgl/widgets/spinner.h> #include <lvgl/widgets/spinner.h>
#include <lvgl/widgets/toolbar.h> #include <lvgl/widgets/toolbar.h>
@ -94,12 +95,19 @@ void onRefreshPressed(lv_event_t* e) {
requestRefresh(ctx); requestRefresh(ctx);
} }
lv_obj_t* createCard(lv_obj_t* parent) {
auto* card = lvgl_card_create(parent);
lv_obj_set_size(card, LV_PCT(100), LV_SIZE_CONTENT);
return card;
}
void showRefreshFailedError(Context* ctx, const char* message) { void showRefreshFailedError(Context* ctx, const char* message) {
lv_obj_clean(ctx->contentWrapper); lv_obj_clean(ctx->contentWrapper);
auto* label = lv_label_create(ctx->contentWrapper); auto* label = lv_label_create(createCard(ctx->contentWrapper));
lv_obj_set_width(label, LV_PCT(100));
lv_label_set_long_mode(label, LV_LABEL_LONG_MODE_WRAP);
lv_label_set_text(label, message); lv_label_set_text(label, message);
lv_obj_align(label, LV_ALIGN_CENTER, 0, 0);
lv_obj_remove_flag(ctx->refreshButton, LV_OBJ_FLAG_HIDDEN); lv_obj_remove_flag(ctx->refreshButton, LV_OBJ_FLAG_HIDDEN);
} }
@ -129,9 +137,17 @@ void showApps(Context* ctx) {
return left.appName < right.appName; return left.appName < right.appName;
}); });
auto* list = lv_list_create(ctx->contentWrapper); auto* card = createCard(ctx->contentWrapper);
lv_obj_set_style_pad_all(card, 0, LV_STATE_DEFAULT);
// The list items' pressed and focused backgrounds follow the card's rounded corners
lv_obj_set_style_clip_corner(card, true, LV_STATE_DEFAULT);
// The card provides the background
auto* list = lv_list_create(card);
lv_obj_set_style_pad_all(list, 0, LV_STATE_DEFAULT); lv_obj_set_style_pad_all(list, 0, LV_STATE_DEFAULT);
lv_obj_set_size(list, LV_PCT(100), LV_SIZE_CONTENT); lv_obj_set_size(list, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_bg_opa(list, LV_OPA_TRANSP, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(list, 0, LV_STATE_DEFAULT);
for (int i = 0; i < ctx->entries.size(); i++) { for (int i = 0; i < ctx->entries.size(); i++) {
auto& entry = ctx->entries[i]; auto& entry = ctx->entries[i];
LOG_I(TAG, "Adding %s", entry.appName.c_str()); LOG_I(TAG, "Adding %s", entry.appName.c_str());
@ -248,10 +264,9 @@ void createWidgets(lv_obj_t* parent, void* userData) {
lv_obj_add_flag(ctx->refreshButton, LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(ctx->refreshButton, LV_OBJ_FLAG_HIDDEN);
ctx->contentWrapper = lv_obj_create(parent); ctx->contentWrapper = lv_obj_create(parent);
lv_obj_set_style_border_width(ctx->contentWrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_width(ctx->contentWrapper, LV_PCT(100)); lv_obj_set_width(ctx->contentWrapper, LV_PCT(100));
lv_obj_set_flex_grow(ctx->contentWrapper, 1); lv_obj_set_flex_grow(ctx->contentWrapper, 1);
lv_obj_set_style_pad_all(ctx->contentWrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_pad_ver(ctx->contentWrapper, 0, LV_STATE_DEFAULT);
ctx->restoreScrollOnNextShow = true; ctx->restoreScrollOnNextShow = true;
if (ctx->needsInitialRefresh) { if (ctx->needsInitialRefresh) {

View File

@ -226,6 +226,7 @@ void createWidgets(lv_obj_t* parent, void* userData) {
// The global toolbar nav callback only knows how to stop old-model apps. // The global toolbar nav callback only knows how to stop old-model apps.
lvgl_toolbar_set_nav_action(ctx->toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); lvgl_toolbar_set_nav_action(ctx->toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
auto* wrapper = lv_obj_create(parent); auto* wrapper = lv_obj_create(parent);
lv_obj_set_style_border_width(wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_width(wrapper, LV_PCT(100)); lv_obj_set_width(wrapper, LV_PCT(100));
lv_obj_set_flex_grow(wrapper, 1); lv_obj_set_flex_grow(wrapper, 1);
lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN);

View File

@ -15,6 +15,7 @@
#include <TactilityCpp/Allocator.h> #include <TactilityCpp/Allocator.h>
#include <lvgl/widgets/card.h>
#include <lvgl/widgets/toolbar.h> #include <lvgl/widgets/toolbar.h>
#include <lvgl.h> #include <lvgl.h>
#include <algorithm> #include <algorithm>
@ -73,9 +74,14 @@ void createWidgets(lv_obj_t* parent, void* userData) {
// The global toolbar nav callback only knows how to stop old-model apps. // The global toolbar nav callback only knows how to stop old-model apps.
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
lv_obj_t* list = lv_list_create(parent); auto* content = lv_obj_create(parent);
lv_obj_set_width(list, LV_PCT(100)); lv_obj_set_width(content, LV_PCT(100));
lv_obj_set_flex_grow(list, 1); 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);
auto* card = lvgl_card_create(content);
lv_obj_set_size(card, LV_PCT(100), LV_SIZE_CONTENT);
// createWidgets() can rerun for this same Context (window rebuild-on-remove). // createWidgets() can rerun for this same Context (window rebuild-on-remove).
ctx->packages.clear(); ctx->packages.clear();
@ -84,25 +90,24 @@ void createWidgets(lv_obj_t* parent, void* userData) {
return strcmp(left.id, right.id) < 0; return strcmp(left.id, right.id) < 0;
}); });
for (const auto& package : ctx->packages) { if (ctx->packages.empty()) {
createPackageWidget(&package, list); auto* no_apps_label = lv_label_create(card);
lv_label_set_text(no_apps_label, "No apps installed.");
return;
} }
if (ctx->packages.empty()) { // The list items' pressed and focused backgrounds follow the card's rounded corners
// lv_obj_align() is ignored for children of a flex-managed parent, so the empty-state lv_obj_set_style_pad_all(card, 0, LV_STATE_DEFAULT);
// label needs its own flex-growing wrapper to center within; the (empty) list is hidden lv_obj_set_style_clip_corner(card, true, LV_STATE_DEFAULT);
// rather than deleted so the wrapper can just take its place in the flex flow.
lv_obj_add_flag(list, LV_OBJ_FLAG_HIDDEN);
lv_obj_set_flex_grow(list, 0);
auto* empty_wrapper = lv_obj_create(parent); // The card provides the background
lv_obj_set_width(empty_wrapper, LV_PCT(100)); lv_obj_t* list = lv_list_create(card);
lv_obj_set_flex_grow(empty_wrapper, 1); lv_obj_set_size(list, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_align(empty_wrapper, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_set_style_bg_opa(list, LV_OPA_TRANSP, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(empty_wrapper, 0, LV_STATE_DEFAULT); lv_obj_set_style_border_width(list, 0, LV_STATE_DEFAULT);
auto* no_apps_label = lv_label_create(empty_wrapper); for (const auto& package : ctx->packages) {
lv_label_set_text(no_apps_label, "No apps installed"); createPackageWidget(&package, list);
} }
} }

View File

@ -51,6 +51,7 @@ void createWidgets(lv_obj_t* parent, void* userData) {
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
lv_obj_t* wrapper = lv_obj_create(parent); lv_obj_t* wrapper = lv_obj_create(parent);
lv_obj_set_style_border_width(wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_width(wrapper, LV_PCT(100)); lv_obj_set_width(wrapper, LV_PCT(100));
lv_obj_set_style_pad_all(wrapper, 0, LV_PART_MAIN); lv_obj_set_style_pad_all(wrapper, 0, LV_PART_MAIN);
lv_obj_set_style_pad_row(wrapper, 4, LV_PART_MAIN); lv_obj_set_style_pad_row(wrapper, 4, LV_PART_MAIN);

View File

@ -88,6 +88,9 @@ lv_obj_t* createRow(lv_obj_t* parent) {
auto* row = lv_obj_create(parent); auto* row = lv_obj_create(parent);
lv_obj_remove_style_all(row); lv_obj_remove_style_all(row);
lv_obj_set_size(row, LV_PCT(100), LV_SIZE_CONTENT); 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_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE);
return row; return row;
} }
@ -102,10 +105,9 @@ Section createSection(lv_obj_t* parent, const char* title, bool enabled, lv_even
auto* title_label = lv_label_create(header); auto* title_label = lv_label_create(header);
lv_label_set_text(title_label, title); lv_label_set_text(title_label, title);
lv_obj_align(title_label, LV_ALIGN_LEFT_MID, 0, 0); lv_obj_set_flex_grow(title_label, 1);
auto* sw = lv_switch_create(header); auto* sw = lv_switch_create(header);
lv_obj_align(sw, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_set_state(sw, LV_STATE_CHECKED, enabled); lv_obj_set_state(sw, LV_STATE_CHECKED, enabled);
lv_obj_add_event_cb(sw, onEnabledChanged, LV_EVENT_VALUE_CHANGED, userData); lv_obj_add_event_cb(sw, onEnabledChanged, LV_EVENT_VALUE_CHANGED, userData);
@ -122,10 +124,9 @@ lv_obj_t* createSwitchRow(lv_obj_t* parent, const char* label, lv_event_cb_t cb,
auto* row_label = lv_label_create(row); auto* row_label = lv_label_create(row);
lv_label_set_text(row_label, label); lv_label_set_text(row_label, label);
lv_obj_align(row_label, LV_ALIGN_LEFT_MID, 0, 0); lv_obj_set_flex_grow(row_label, 1);
auto* sw = lv_switch_create(row); auto* sw = lv_switch_create(row);
lv_obj_align(sw, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_add_event_cb(sw, cb, LV_EVENT_VALUE_CHANGED, userData); lv_obj_add_event_cb(sw, cb, LV_EVENT_VALUE_CHANGED, userData);
return sw; return sw;
@ -134,13 +135,14 @@ lv_obj_t* createSwitchRow(lv_obj_t* parent, const char* label, lv_event_cb_t cb,
lv_obj_t* createSliderRow(lv_obj_t* parent, const char* label, int32_t initialValue, lv_event_cb_t cb, void* userData) { lv_obj_t* createSliderRow(lv_obj_t* parent, const char* label, int32_t initialValue, lv_event_cb_t cb, void* userData) {
auto* row = createRow(parent); auto* row = createRow(parent);
// The label gets the width that the slider leaves, and scrolls when its text doesn't fit
auto* row_label = lv_label_create(row); auto* row_label = lv_label_create(row);
lv_label_set_text(row_label, label); lv_label_set_text(row_label, label);
lv_obj_align(row_label, LV_ALIGN_LEFT_MID, 0, 0); lv_obj_set_flex_grow(row_label, 1);
lv_label_set_long_mode(row_label, LV_LABEL_LONG_MODE_SCROLL_CIRCULAR);
auto* sliderBox = lvgl_sliderbox_create(row, 0, 100, 10, initialValue); auto* sliderBox = lvgl_sliderbox_create(row, 0, 100, 10, initialValue);
lv_obj_set_width(sliderBox, LV_PCT(50)); lv_obj_set_width(sliderBox, LV_PCT(75));
lv_obj_align(sliderBox, LV_ALIGN_RIGHT_MID, 0, 0);
lvgl_sliderbox_add_value_changed_cb(sliderBox, cb, userData); lvgl_sliderbox_add_value_changed_cb(sliderBox, cb, userData);
return sliderBox; return sliderBox;
@ -185,6 +187,7 @@ void createWidgets(lv_obj_t* parent, void* userData) {
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
auto* main_wrapper = lv_obj_create(parent); auto* main_wrapper = lv_obj_create(parent);
lv_obj_set_style_border_width(main_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_set_width(main_wrapper, LV_PCT(100)); lv_obj_set_width(main_wrapper, LV_PCT(100));
lv_obj_set_flex_grow(main_wrapper, 1); lv_obj_set_flex_grow(main_wrapper, 1);

View File

@ -12,6 +12,7 @@
#include <Tactility/Tactility.h> #include <Tactility/Tactility.h>
#include <app/event.h> #include <app/event.h>
#include <lvgl/widgets/card.h>
#include <lvgl/widgets/toolbar.h> #include <lvgl/widgets/toolbar.h>
namespace tt::app::btmanage { namespace tt::app::btmanage {
@ -98,7 +99,26 @@ static uint8_t mapRssiToPercentage(int8_t rssi) {
return static_cast<uint8_t>((float)(90 - abs_rssi) / 60.f * 100.f); return static_cast<uint8_t>((float)(90 - abs_rssi) / 60.f * 100.f);
} }
void View::createPeerListItem(const bluetooth::PeerRecord& record, bool isPaired, size_t index) { /** Creates a title with a card below it, and returns the list in the card */
static lv_obj_t* createSection(lv_obj_t* parent, const char* title) {
auto* label = lv_label_create(parent);
lv_label_set_text(label, title);
auto* card = lvgl_card_create(parent);
lv_obj_set_size(card, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(card, 0, LV_STATE_DEFAULT);
// The list items' pressed and focused backgrounds follow the card's rounded corners
lv_obj_set_style_clip_corner(card, true, LV_STATE_DEFAULT);
// The card provides the background
auto* list = lv_list_create(card);
lv_obj_set_size(list, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_bg_opa(list, LV_OPA_TRANSP, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(list, 0, LV_STATE_DEFAULT);
return list;
}
void View::createPeerListItem(lv_obj_t* list, const bluetooth::PeerRecord& record, bool isPaired, size_t index) {
const auto percentage = mapRssiToPercentage(record.rssi); const auto percentage = mapRssiToPercentage(record.rssi);
const auto label = record.name.empty() const auto label = record.name.empty()
? std::format("Unknown ({:02x}{:02x}{:02x}{:02x}{:02x}{:02x}) {}%", ? std::format("Unknown ({:02x}{:02x}{:02x}{:02x}{:02x}{:02x}) {}%",
@ -107,7 +127,7 @@ void View::createPeerListItem(const bluetooth::PeerRecord& record, bool isPaired
percentage) percentage)
: std::format("{} {}%", record.name, percentage); : std::format("{} {}%", record.name, percentage);
auto* button = lv_list_add_button(peers_list, nullptr, label.c_str()); auto* button = lv_list_add_button(list, nullptr, label.c_str());
auto* item_data = new PeerListItemData { context, state, bindings, index, isPaired }; auto* item_data = new PeerListItemData { context, state, bindings, index, isPaired };
lv_obj_set_user_data(button, item_data); lv_obj_set_user_data(button, item_data);
@ -165,22 +185,20 @@ void View::updatePeerList() {
// Enable on boot // Enable on boot
// An unstyled row insets the wrapper like the list items' text: margins would make a full-width item overflow the list auto* enable_on_boot_wrapper = lv_obj_create(peers_list);
auto* enable_on_boot_row = lv_obj_create(peers_list);
lv_obj_remove_style_all(enable_on_boot_row);
lv_obj_set_size(enable_on_boot_row, LV_PCT(100), LV_SIZE_CONTENT);
const int32_t enable_on_boot_inset = (lvgl_get_ui_density() == LVGL_UI_DENSITY_COMPACT) ? 2 : LV_DPX(16);
lv_obj_set_style_pad_hor(enable_on_boot_row, enable_on_boot_inset, LV_STATE_DEFAULT);
auto* enable_on_boot_wrapper = lv_obj_create(enable_on_boot_row);
lv_obj_set_size(enable_on_boot_wrapper, LV_PCT(100), LV_SIZE_CONTENT); lv_obj_set_size(enable_on_boot_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(enable_on_boot_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(enable_on_boot_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_bg_opa(enable_on_boot_wrapper, LV_OPA_TRANSP, LV_STATE_DEFAULT);
lv_obj_set_flex_flow(enable_on_boot_wrapper, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(enable_on_boot_wrapper, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_remove_flag(enable_on_boot_wrapper, LV_OBJ_FLAG_SCROLLABLE);
auto* enable_label = lv_label_create(enable_on_boot_wrapper); auto* enable_label = lv_label_create(enable_on_boot_wrapper);
lv_label_set_text(enable_label, "Enable on boot"); lv_label_set_text(enable_label, "Enable on boot");
lv_obj_align(enable_label, LV_ALIGN_LEFT_MID, 0, 0); lv_obj_set_flex_grow(enable_label, 1);
enable_on_boot_switch = lv_switch_create(enable_on_boot_wrapper); enable_on_boot_switch = lv_switch_create(enable_on_boot_wrapper);
lv_obj_align(enable_on_boot_switch, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_add_event_cb(enable_on_boot_switch, onEnableOnBootSwitchChanged, LV_EVENT_VALUE_CHANGED, nullptr); lv_obj_add_event_cb(enable_on_boot_switch, onEnableOnBootSwitchChanged, LV_EVENT_VALUE_CHANGED, nullptr);
lv_obj_add_event_cb(enable_on_boot_wrapper, onEnableOnBootParentClicked, LV_EVENT_SHORT_CLICKED, enable_on_boot_switch); lv_obj_add_event_cb(enable_on_boot_wrapper, onEnableOnBootParentClicked, LV_EVENT_SHORT_CLICKED, enable_on_boot_switch);
@ -191,22 +209,21 @@ void View::updatePeerList() {
// Paired peers section // Paired peers section
auto paired = state->getPairedPeers(); auto paired = state->getPairedPeers();
if (!paired.empty()) { if (!paired.empty()) {
lv_list_add_text(peers_list, "Paired"); auto* paired_section = createSection(peers_list, "Paired");
for (size_t i = 0; i < paired.size(); ++i) { for (size_t i = 0; i < paired.size(); ++i) {
createPeerListItem(paired[i], true, i); createPeerListItem(paired_section, paired[i], true, i);
} }
} }
// Scan results section // Scan results section
auto scan_results = state->getScanResults(); auto scan_results = state->getScanResults();
lv_list_add_text(peers_list, "Available"); auto* available_section = createSection(peers_list, "Available");
if (!scan_results.empty()) { if (!scan_results.empty()) {
for (size_t i = 0; i < scan_results.size(); ++i) { for (size_t i = 0; i < scan_results.size(); ++i) {
createPeerListItem(scan_results[i], false, i); createPeerListItem(available_section, scan_results[i], false, i);
} }
} else if (!state->isScanning()) { } else if (!state->isScanning()) {
auto* no_devices_label = lv_label_create(peers_list); lv_list_add_text(available_section, "No devices found.");
lv_label_set_text(no_devices_label, "No devices found.");
} }
// Never hide peers_list: it always contains the "Enable on boot" row. // Never hide peers_list: it always contains the "Enable on boot" row.
// While scanning with no results the spinner in the toolbar provides feedback. // While scanning with no results the spinner in the toolbar provides feedback.
@ -241,9 +258,11 @@ void View::init(void* newContext, lv_obj_t* parent) {
lv_obj_add_event_cb(enable_switch, onEnableSwitchChanged, LV_EVENT_VALUE_CHANGED, context); lv_obj_add_event_cb(enable_switch, onEnableSwitchChanged, LV_EVENT_VALUE_CHANGED, context);
// Peer list // Peer list
peers_list = lv_list_create(parent); peers_list = lv_obj_create(parent);
lv_obj_set_flex_grow(peers_list, 1); lv_obj_set_flex_grow(peers_list, 1);
lv_obj_set_width(peers_list, LV_PCT(100)); lv_obj_set_width(peers_list, LV_PCT(100));
lv_obj_set_flex_flow(peers_list, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_border_width(peers_list, 0, LV_STATE_DEFAULT);
} }
void View::update() { void View::update() {

View File

@ -154,17 +154,19 @@ void createWidgets(lv_obj_t* parent, void* userData) {
// Auto-connect toggle row // Auto-connect toggle row
auto* auto_connect_wrapper = lv_obj_create(wrapper); auto* auto_connect_wrapper = lv_obj_create(wrapper);
lv_obj_set_size(auto_connect_wrapper, LV_PCT(100), LV_SIZE_CONTENT); lv_obj_set_size(auto_connect_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(auto_connect_wrapper, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(auto_connect_wrapper, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_remove_flag(auto_connect_wrapper, LV_OBJ_FLAG_SCROLLABLE);
lvgl::obj_set_style_bg_invisible(auto_connect_wrapper); lvgl::obj_set_style_bg_invisible(auto_connect_wrapper);
lv_obj_set_style_pad_all(auto_connect_wrapper, 0, LV_STATE_DEFAULT); lv_obj_set_style_pad_all(auto_connect_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(auto_connect_wrapper, 0, LV_STATE_DEFAULT); lv_obj_set_style_border_width(auto_connect_wrapper, 0, LV_STATE_DEFAULT);
auto* auto_connect_label = lv_label_create(auto_connect_wrapper); auto* auto_connect_label = lv_label_create(auto_connect_wrapper);
lv_label_set_text(auto_connect_label, "Auto-connect"); lv_label_set_text(auto_connect_label, "Auto-connect");
lv_obj_align(auto_connect_label, LV_ALIGN_LEFT_MID, 0, 0); lv_obj_set_flex_grow(auto_connect_label, 1);
auto* auto_connect_switch = lv_switch_create(auto_connect_wrapper); auto* auto_connect_switch = lv_switch_create(auto_connect_wrapper);
lv_obj_add_event_cb(auto_connect_switch, onToggleAutoConnect, LV_EVENT_VALUE_CHANGED, ctx); lv_obj_add_event_cb(auto_connect_switch, onToggleAutoConnect, LV_EVENT_VALUE_CHANGED, ctx);
lv_obj_align(auto_connect_switch, LV_ALIGN_RIGHT_MID, 0, 0);
if (deviceLoaded && device.autoConnect) { if (deviceLoaded && device.autoConnect) {
lv_obj_add_state(auto_connect_switch, LV_STATE_CHECKED); lv_obj_add_state(auto_connect_switch, LV_STATE_CHECKED);

View File

@ -43,10 +43,12 @@ void ChatView::createInputBar(lv_obj_t* parent) {
inputWrapper = lv_obj_create(parent); inputWrapper = lv_obj_create(parent);
auto* wrapper = inputWrapper; auto* wrapper = inputWrapper;
lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_ROW); lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(wrapper, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_size(wrapper, LV_PCT(100), LV_SIZE_CONTENT); lv_obj_set_size(wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(wrapper, 0, 0); lv_obj_set_style_pad_all(wrapper, 0, 0);
lv_obj_set_style_pad_column(wrapper, 4, 0); lv_obj_set_style_pad_column(wrapper, 4, 0);
lv_obj_set_style_border_opa(wrapper, 0, LV_STATE_DEFAULT); lv_obj_set_style_border_opa(wrapper, 0, LV_STATE_DEFAULT);
lv_obj_remove_flag(wrapper, LV_OBJ_FLAG_SCROLLABLE);
inputField = lv_textarea_create(wrapper); inputField = lv_textarea_create(wrapper);
lv_obj_set_flex_grow(inputField, 1); lv_obj_set_flex_grow(inputField, 1);
@ -55,8 +57,6 @@ void ChatView::createInputBar(lv_obj_t* parent) {
lv_textarea_set_max_length(inputField, MAX_MESSAGE_LEN); lv_textarea_set_max_length(inputField, MAX_MESSAGE_LEN);
auto* sendBtn = lv_button_create(wrapper); auto* sendBtn = lv_button_create(wrapper);
lv_obj_set_style_margin_all(sendBtn, 0, LV_STATE_DEFAULT);
lv_obj_set_style_margin_top(sendBtn, 2, LV_STATE_DEFAULT);
lv_obj_add_event_cb(sendBtn, onSendClicked, LV_EVENT_CLICKED, this); lv_obj_add_event_cb(sendBtn, onSendClicked, LV_EVENT_CLICKED, this);
auto* btnLabel = lv_label_create(sendBtn); auto* btnLabel = lv_label_create(sendBtn);
@ -66,6 +66,7 @@ void ChatView::createInputBar(lv_obj_t* parent) {
void ChatView::createSettingsPanel(lv_obj_t* parent) { void ChatView::createSettingsPanel(lv_obj_t* parent) {
settingsPanel = lv_obj_create(parent); settingsPanel = lv_obj_create(parent);
lv_obj_set_style_border_width(settingsPanel, 0, LV_STATE_DEFAULT);
lv_obj_set_width(settingsPanel, LV_PCT(100)); lv_obj_set_width(settingsPanel, LV_PCT(100));
lv_obj_set_flex_grow(settingsPanel, 1); lv_obj_set_flex_grow(settingsPanel, 1);
lv_obj_set_flex_flow(settingsPanel, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_flow(settingsPanel, LV_FLEX_FLOW_COLUMN);
@ -95,6 +96,7 @@ void ChatView::createSettingsPanel(lv_obj_t* parent) {
// Buttons // Buttons
auto* btnRow = lv_obj_create(settingsPanel); auto* btnRow = lv_obj_create(settingsPanel);
lv_obj_set_flex_flow(btnRow, LV_FLEX_FLOW_ROW); lv_obj_set_flex_flow(btnRow, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(btnRow, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_size(btnRow, LV_PCT(100), LV_SIZE_CONTENT); lv_obj_set_size(btnRow, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(btnRow, 0, 0); lv_obj_set_style_pad_all(btnRow, 0, 0);
lv_obj_set_style_pad_column(btnRow, 8, 0); lv_obj_set_style_pad_column(btnRow, 8, 0);
@ -113,6 +115,7 @@ void ChatView::createSettingsPanel(lv_obj_t* parent) {
void ChatView::createChannelPanel(lv_obj_t* parent) { void ChatView::createChannelPanel(lv_obj_t* parent) {
channelPanel = lv_obj_create(parent); channelPanel = lv_obj_create(parent);
lv_obj_set_style_border_width(channelPanel, 0, LV_STATE_DEFAULT);
lv_obj_set_width(channelPanel, LV_PCT(100)); lv_obj_set_width(channelPanel, LV_PCT(100));
lv_obj_set_flex_grow(channelPanel, 1); lv_obj_set_flex_grow(channelPanel, 1);
lv_obj_set_flex_flow(channelPanel, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_flow(channelPanel, LV_FLEX_FLOW_COLUMN);
@ -130,6 +133,7 @@ void ChatView::createChannelPanel(lv_obj_t* parent) {
auto* btnRow = lv_obj_create(channelPanel); auto* btnRow = lv_obj_create(channelPanel);
lv_obj_set_flex_flow(btnRow, LV_FLEX_FLOW_ROW); lv_obj_set_flex_flow(btnRow, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(btnRow, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_size(btnRow, LV_PCT(100), LV_SIZE_CONTENT); lv_obj_set_size(btnRow, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(btnRow, 0, 0); lv_obj_set_style_pad_all(btnRow, 0, 0);
lv_obj_set_style_pad_column(btnRow, 8, 0); lv_obj_set_style_pad_column(btnRow, 8, 0);

View File

@ -19,6 +19,8 @@
#include <lvgl.h> #include <lvgl.h>
#include <lvgl/lvgl.h> #include <lvgl/lvgl.h>
#include <lvgl/theme.h>
#include <lvgl/widgets/card.h>
#include <lvgl/widgets/toolbar.h> #include <lvgl/widgets/toolbar.h>
#include <cstring> #include <cstring>
@ -132,37 +134,47 @@ void createWidgets(lv_obj_t* parent, void* userData) {
// Enable on boot // Enable on boot
lv_obj_t* enable_wrapper = lv_obj_create(content_wrapper); auto* enable_card = lvgl_card_create(content_wrapper);
lv_obj_set_size(enable_card, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_t* enable_wrapper = lv_obj_create(enable_card);
lv_obj_set_size(enable_wrapper, LV_PCT(100), LV_SIZE_CONTENT); lv_obj_set_size(enable_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(enable_wrapper, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(enable_wrapper, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_remove_flag(enable_wrapper, LV_OBJ_FLAG_SCROLLABLE);
lvgl::obj_set_style_bg_invisible(enable_wrapper); lvgl::obj_set_style_bg_invisible(enable_wrapper);
lv_obj_set_style_border_width(enable_wrapper, 0, LV_STATE_DEFAULT); lv_obj_set_style_border_width(enable_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_pad_all(enable_wrapper, 0, LV_STATE_DEFAULT); lv_obj_set_style_pad_all(enable_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_t* enable_label = lv_label_create(enable_wrapper); lv_obj_t* enable_label = lv_label_create(enable_wrapper);
lv_label_set_text(enable_label, "Enable on boot"); lv_label_set_text(enable_label, "Enable on boot");
lv_obj_align(enable_label, LV_ALIGN_LEFT_MID, 0, 0); lv_obj_set_flex_grow(enable_label, 1);
ctx->enableOnBootSwitch = lv_switch_create(enable_wrapper); ctx->enableOnBootSwitch = lv_switch_create(enable_wrapper);
lv_obj_add_event_cb(ctx->enableOnBootSwitch, onEnableOnBootSwitchChanged, LV_EVENT_VALUE_CHANGED, ctx); lv_obj_add_event_cb(ctx->enableOnBootSwitch, onEnableOnBootSwitchChanged, LV_EVENT_VALUE_CHANGED, ctx);
lv_obj_align(ctx->enableOnBootSwitch, LV_ALIGN_RIGHT_MID, 0, 0);
if (service::development::shouldEnableOnBoot()) { if (service::development::shouldEnableOnBoot()) {
lv_obj_add_state(ctx->enableOnBootSwitch, LV_STATE_CHECKED); lv_obj_add_state(ctx->enableOnBootSwitch, LV_STATE_CHECKED);
} else { } else {
lv_obj_remove_state(ctx->enableOnBootSwitch, LV_STATE_CHECKED); lv_obj_remove_state(ctx->enableOnBootSwitch, LV_STATE_CHECKED);
} }
// Status // Status and warning
ctx->statusLabel = lv_label_create(content_wrapper); auto* status_card = lvgl_card_create(content_wrapper);
lv_obj_set_size(status_card, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(status_card, LV_FLEX_FLOW_COLUMN);
// Warning ctx->statusLabel = lv_label_create(status_card);
auto warning_label = lv_label_create(content_wrapper); auto warning_label = lv_label_create(status_card);
lv_label_set_text(warning_label, "This feature is experimental and uses an unsecured http connection."); lv_label_set_text(warning_label, "This feature is experimental and uses an unsecured http connection.");
lv_obj_set_width(warning_label, LV_PCT(100)); lv_obj_set_width(warning_label, LV_PCT(100));
lv_label_set_long_mode(warning_label, LV_LABEL_LONG_WRAP); lv_label_set_long_mode(warning_label, LV_LABEL_LONG_WRAP);
if (lv_display_get_color_format(lv_obj_get_display(parent)) != LV_COLOR_FORMAT_L8) { // The monochrome theme has no error colour
lv_obj_set_style_text_color(warning_label, lv_color_make(0xff, 0xff, 0x00), LV_STATE_DEFAULT); if (!lvgl_theme_is_mono()) {
LvglThemeSettings theme_settings;
lvgl_theme_get_settings(&theme_settings);
lv_obj_set_style_text_color(warning_label, theme_settings.color_error, LV_STATE_DEFAULT);
} }
updateViewState(ctx); updateViewState(ctx);

View File

@ -8,6 +8,7 @@
#include <tactility/log.h> #include <tactility/log.h>
#include <Tactility/Tactility.h> #include <Tactility/Tactility.h>
#include <Tactility/app/selectiondialog/SelectionDialog.h>
#ifdef ESP_PLATFORM #ifdef ESP_PLATFORM
#include <Tactility/service/displayidle/DisplayIdleService.h> #include <Tactility/service/displayidle/DisplayIdleService.h>
#endif #endif
@ -15,15 +16,25 @@
#include <Tactility/settings/DisplaySettings.h> #include <Tactility/settings/DisplaySettings.h>
#include <app/event.h> #include <app/event.h>
#include <app/manager.h>
#include <app/manifest.h> #include <app/manifest.h>
#include <app/scheduler.h> #include <app/scheduler.h>
#include <lvgl_window_manager/window_manager.h> #include <lvgl_window_manager/window_manager.h>
#include <lvgl/fonts.h>
#include <lvgl/grid_navigation.h>
#include <lvgl/icons/shared.h>
#include <lvgl/widgets/card.h>
#include <lvgl/widgets/chip.h>
#include <lvgl/widgets/sliderbox.h>
#include <lvgl/widgets/toolbar.h> #include <lvgl/widgets/toolbar.h>
#include <lvgl.h> #include <lvgl.h>
#include <string>
#include <vector>
#ifdef ESP_PLATFORM #ifdef ESP_PLATFORM
#include <sdkconfig.h> #include <sdkconfig.h>
#endif #endif
@ -36,16 +47,48 @@ constexpr auto* TAG = "Display";
namespace { namespace {
struct TimeoutOption {
uint32_t ms;
const char* name;
};
constexpr TimeoutOption TIMEOUT_OPTIONS[] = {
{ 15000, "15 seconds" },
{ 30000, "30 seconds" },
{ 60000, "1 minute" },
{ 120000, "2 minutes" },
{ 300000, "5 minutes" },
{ 0, "Never" },
};
// The icon is an upright phone with its bar at the bottom, rotated clockwise (0.1 degree units) to the screen's shape.
// In settings::display::Orientation order: Landscape, Portrait, LandscapeFlipped, PortraitFlipped
constexpr int32_t ORIENTATION_ICON_ROTATIONS[] = { 2700, 0, 900, 1800 };
// In settings::display::ScreensaverType order
constexpr const char* SCREENSAVER_NAMES[] = { "None", "Bouncing Balls", "Mystify", "Matrix Rain", "StackChan" };
struct Context { struct Context {
uint32_t appInstanceId; uint32_t appInstanceId;
settings::display::DisplaySettings displaySettings; settings::display::DisplaySettings displaySettings;
bool displaySettingsUpdated = false; bool displaySettingsUpdated = false;
TaskEventGroup* eventGroup = nullptr;
uint32_t selectTimeoutBit = 0;
uint32_t selectScreensaverBit = 0;
uint32_t timeoutDialogId = 0;
uint32_t screensaverDialogId = 0;
// Widgets, valid while the window is shown
lv_obj_t* timeoutSwitch = nullptr; lv_obj_t* timeoutSwitch = nullptr;
lv_obj_t* timeoutDropdown = nullptr; // Shown when the auto screen off is enabled: the timeout and screensaver
lv_obj_t* screensaverDropdown = nullptr; lv_obj_t* timeoutCard = nullptr;
lv_obj_t* orientationDropdown = nullptr; lv_obj_t* timeoutValueLabel = nullptr;
lv_obj_t* screensaverValueLabel = nullptr;
// Shown when auto-rotate is off or unavailable: the orientation label and chips
lv_obj_t* orientationGroup = nullptr;
lv_obj_t* orientationChips = nullptr;
lv_obj_t* autoRotateSwitch = nullptr; lv_obj_t* autoRotateSwitch = nullptr;
lv_obj_t* mountRotationDropdown = nullptr; // Shown when auto-rotate is on: the sensor mounting label and chips
lv_obj_t* mountRotationGroup = nullptr;
}; };
@ -69,29 +112,67 @@ void onBackPressed(lv_event_t* event) {
app_event_emit_close(ctx->appInstanceId); app_event_emit_close(ctx->appInstanceId);
} }
// The slider shows a percentage of the backlight's brightness range
int32_t brightnessToPercent(Device* backlight, int32_t brightness) {
const int32_t min = backlight_get_min_brightness(backlight);
const int32_t range = backlight_get_max_brightness(backlight) - min;
return range > 0 ? ((brightness - min) * 100 + range / 2) / range : 100;
}
int32_t percentToBrightness(Device* backlight, int32_t percent) {
const int32_t min = backlight_get_min_brightness(backlight);
const int32_t range = backlight_get_max_brightness(backlight) - min;
const int32_t brightness = min + (percent * range + 50) / 100;
// A brightness of 0 turns the backlight off, so 0% is the dimmest setting that is still on
return brightness < 1 ? 1 : brightness;
}
void onBacklightSliderEvent(lv_event_t* event) { void onBacklightSliderEvent(lv_event_t* event) {
auto* slider = static_cast<lv_obj_t*>(lv_event_get_target(event)); auto* slider_box = lv_event_get_current_target_obj(event);
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event)); auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
auto* backlight = getBacklightDevice(); auto* backlight = getBacklightDevice();
assert(backlight != nullptr); assert(backlight != nullptr);
int32_t slider_value = lv_slider_get_value(slider); const int32_t percent = lvgl_sliderbox_get_value(slider_box);
ctx->displaySettings.backlightDuty = static_cast<uint8_t>(slider_value); ctx->displaySettings.backlightDuty = static_cast<uint8_t>(percentToBrightness(backlight, percent));
ctx->displaySettingsUpdated = true; ctx->displaySettingsUpdated = true;
backlight_set_brightness(backlight, ctx->displaySettings.backlightDuty); backlight_set_brightness(backlight, ctx->displaySettings.backlightDuty);
device_put(backlight); device_put(backlight);
} }
void onOrientationSet(lv_event_t* event) { void forEachChild(lv_obj_t* parent, auto&& action) {
const uint32_t count = lv_obj_get_child_count(parent);
for (uint32_t i = 0; i < count; i++) {
action(lv_obj_get_child(parent, static_cast<int32_t>(i)), i);
}
}
void setCheckedChip(lv_obj_t* chips, settings::display::Orientation orientation) {
forEachChild(chips, [orientation](lv_obj_t* chip, uint32_t index) {
lv_obj_set_state(chip, LV_STATE_CHECKED, index == static_cast<uint32_t>(orientation));
});
}
/** Shows the manual orientation or the sensor mounting, depending on whether auto-rotate is on */
void updateOrientationVisibility(Context* ctx) {
const bool auto_rotate = ctx->autoRotateSwitch != nullptr && ctx->displaySettings.autoRotateEnabled;
lv_obj_set_flag(ctx->orientationGroup, LV_OBJ_FLAG_HIDDEN, auto_rotate);
if (ctx->mountRotationGroup != nullptr) {
lv_obj_set_flag(ctx->mountRotationGroup, LV_OBJ_FLAG_HIDDEN, !auto_rotate);
}
}
void onOrientationChipPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event)); auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
auto* dropdown = static_cast<lv_obj_t*>(lv_event_get_target(event)); auto* chip = lv_event_get_target_obj(event);
uint32_t selected_index = lv_dropdown_get_selected(dropdown); const auto selected_index = lv_obj_get_index(chip);
LOG_I(TAG, "Selected %u", (unsigned)selected_index); LOG_I(TAG, "Selected %d", (int)selected_index);
auto selected_orientation = static_cast<settings::display::Orientation>(selected_index); auto selected_orientation = static_cast<settings::display::Orientation>(selected_index);
if (selected_orientation != ctx->displaySettings.orientation) { if (selected_orientation != ctx->displaySettings.orientation) {
ctx->displaySettings.orientation = selected_orientation; ctx->displaySettings.orientation = selected_orientation;
ctx->displaySettingsUpdated = true; ctx->displaySettingsUpdated = true;
lv_display_set_rotation(lv_display_get_default(), settings::display::toLvglDisplayRotation(selected_orientation)); lv_display_set_rotation(lv_display_get_default(), settings::display::toLvglDisplayRotation(selected_orientation));
setCheckedChip(ctx->orientationChips, selected_orientation);
} }
} }
@ -101,28 +182,63 @@ void onAutoRotateSwitch(lv_event_t* event) {
bool enabled = lv_obj_has_state(sw, LV_STATE_CHECKED); bool enabled = lv_obj_has_state(sw, LV_STATE_CHECKED);
ctx->displaySettings.autoRotateEnabled = enabled; ctx->displaySettings.autoRotateEnabled = enabled;
ctx->displaySettingsUpdated = true; ctx->displaySettingsUpdated = true;
if (ctx->mountRotationDropdown) { updateOrientationVisibility(ctx);
if (enabled) {
lv_obj_clear_state(ctx->mountRotationDropdown, LV_STATE_DISABLED);
} else {
lv_obj_add_state(ctx->mountRotationDropdown, LV_STATE_DISABLED);
}
}
if (ctx->orientationDropdown) {
if (enabled) {
lv_obj_add_state(ctx->orientationDropdown, LV_STATE_DISABLED);
} else {
lv_obj_clear_state(ctx->orientationDropdown, LV_STATE_DISABLED);
}
}
} }
void onMountRotationSet(lv_event_t* event) { void onMountRotationChipPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event)); auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
auto* dropdown = static_cast<lv_obj_t*>(lv_event_get_target(event)); auto* chip = lv_event_get_target_obj(event);
uint32_t selected_index = lv_dropdown_get_selected(dropdown); const auto orientation = static_cast<settings::display::Orientation>(lv_obj_get_index(chip));
ctx->displaySettings.autoRotateMountRotation = static_cast<settings::display::Orientation>(selected_index); ctx->displaySettings.autoRotateMountRotation = orientation;
ctx->displaySettingsUpdated = true; ctx->displaySettingsUpdated = true;
setCheckedChip(lv_obj_get_parent(chip), orientation);
}
/** Creates a transparent column in a card with a title, which centers the title and the content below it */
lv_obj_t* createCardGroup(lv_obj_t* card, const char* title) {
auto* group = lv_obj_create(card);
lv_obj_set_size(group, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(group, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(group, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_bg_opa(group, LV_OPA_TRANSP, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(group, 0, LV_STATE_DEFAULT);
lv_obj_set_style_pad_all(group, 0, LV_STATE_DEFAULT);
lv_obj_remove_flag(group, LV_OBJ_FLAG_SCROLLABLE);
auto* label = lv_label_create(group);
lv_label_set_text(label, title);
return group;
}
/**
* Creates a row of chips with a phone icon per orientation, in settings::display::Orientation order.
* The row is centered in its parent while the chips fit, and scrolls when they don't.
*/
lv_obj_t* createOrientationChips(lv_obj_t* parent, settings::display::Orientation selected, lv_event_cb_t onPressed, Context* ctx) {
auto* chips = lv_obj_create(parent);
lv_obj_set_size(chips, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
lv_obj_set_style_max_width(chips, LV_PCT(100), LV_STATE_DEFAULT);
lv_obj_set_flex_flow(chips, LV_FLEX_FLOW_ROW);
lv_obj_set_scroll_dir(chips, LV_DIR_HOR);
lv_obj_set_style_bg_opa(chips, LV_OPA_TRANSP, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(chips, 0, LV_STATE_DEFAULT);
// The chips' margins leave room for their focus rings and space them apart
lv_obj_set_style_pad_all(chips, 0, LV_STATE_DEFAULT);
lv_obj_set_style_pad_column(chips, 0, LV_STATE_DEFAULT);
for (int32_t i = 0; i < 4; i++) {
auto* chip = lvgl_chip_create(chips);
lv_obj_add_event_cb(chip, onPressed, LV_EVENT_SHORT_CLICKED, ctx);
auto* icon = lv_label_create(chip);
lv_obj_set_style_text_font(icon, lvgl_get_shared_icon_default_font(), LV_STATE_DEFAULT);
lv_label_set_text(icon, LVGL_ICON_SHARED_PHONE_ANDROID);
lv_obj_set_style_transform_pivot_x(icon, LV_PCT(50), LV_STATE_DEFAULT);
lv_obj_set_style_transform_pivot_y(icon, LV_PCT(50), LV_STATE_DEFAULT);
lv_obj_set_style_transform_rotation(icon, ORIENTATION_ICON_ROTATIONS[i], LV_STATE_DEFAULT);
}
lvgl_grid_navigation_add(chips);
setCheckedChip(chips, selected);
return chips;
} }
void onTimeoutSwitch(lv_event_t* event) { void onTimeoutSwitch(lv_event_t* event) {
@ -131,52 +247,106 @@ void onTimeoutSwitch(lv_event_t* event) {
bool enabled = lv_obj_has_state(sw, LV_STATE_CHECKED); bool enabled = lv_obj_has_state(sw, LV_STATE_CHECKED);
ctx->displaySettings.backlightTimeoutEnabled = enabled; ctx->displaySettings.backlightTimeoutEnabled = enabled;
ctx->displaySettingsUpdated = true; ctx->displaySettingsUpdated = true;
if (ctx->timeoutDropdown) { if (ctx->timeoutCard) {
if (enabled) { lv_obj_set_flag(ctx->timeoutCard, LV_OBJ_FLAG_HIDDEN, !enabled);
lv_obj_clear_state(ctx->timeoutDropdown, LV_STATE_DISABLED); }
if (ctx->screensaverDropdown) { }
lv_obj_clear_state(ctx->screensaverDropdown, LV_STATE_DISABLED);
} const char* getTimeoutName(uint32_t ms) {
} else { for (const auto& option : TIMEOUT_OPTIONS) {
lv_obj_add_state(ctx->timeoutDropdown, LV_STATE_DISABLED); if (option.ms == ms) {
if (ctx->screensaverDropdown) { return option.name;
lv_obj_add_state(ctx->screensaverDropdown, LV_STATE_DISABLED);
}
} }
} }
return "Custom";
} }
void onTimeoutChanged(lv_event_t* event) { const char* getScreensaverName(settings::display::ScreensaverType type) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event)); const auto index = static_cast<size_t>(type);
auto* dropdown = static_cast<lv_obj_t*>(lv_event_get_target(event)); return index < std::size(SCREENSAVER_NAMES) ? SCREENSAVER_NAMES[index] : "";
uint32_t idx = lv_dropdown_get_selected(dropdown); }
// Map dropdown index to ms: 0=15s,1=30s,2=1m,3=2m,4=5m,5=Never
static const uint32_t values_ms[] = {15000, 30000, 60000, 120000, 300000, 0}; /** Requires the LVGL lock */
if (idx < (sizeof(values_ms)/sizeof(values_ms[0]))) { void updateTimeoutLabels(Context* ctx) {
ctx->displaySettings.backlightTimeoutMs = values_ms[idx]; if (ctx->timeoutValueLabel != nullptr) {
ctx->displaySettingsUpdated = true; lv_label_set_text(ctx->timeoutValueLabel, getTimeoutName(ctx->displaySettings.backlightTimeoutMs));
lv_label_set_text(ctx->screensaverValueLabel, getScreensaverName(ctx->displaySettings.screensaverType));
} }
} }
void onScreensaverChanged(lv_event_t* event) { void onChangeTimeoutPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event)); auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
auto* dropdown = static_cast<lv_obj_t*>(lv_event_get_target(event)); task_event_group_signal(ctx->eventGroup, ctx->selectTimeoutBit);
uint32_t idx = lv_dropdown_get_selected(dropdown); }
// Validate index bounds before casting to enum
if (idx >= static_cast<uint32_t>(settings::display::ScreensaverType::Count)) { void onChangeScreensaverPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
task_event_group_signal(ctx->eventGroup, ctx->selectScreensaverBit);
}
void startTimeoutSelection(Context* ctx) {
if (ctx->timeoutDialogId != 0) {
return; return;
} }
auto selected_type = static_cast<settings::display::ScreensaverType>(idx); std::vector<std::string> items;
if (selected_type != ctx->displaySettings.screensaverType) { for (const auto& option : TIMEOUT_OPTIONS) {
ctx->displaySettings.screensaverType = selected_type; items.emplace_back(option.name);
}
ctx->timeoutDialogId = selectiondialog::start(ctx->appInstanceId, "Timeout", items);
}
void startScreensaverSelection(Context* ctx) {
if (ctx->screensaverDialogId != 0) {
return;
}
const std::vector<std::string> items(std::begin(SCREENSAVER_NAMES), std::end(SCREENSAVER_NAMES));
ctx->screensaverDialogId = selectiondialog::start(ctx->appInstanceId, "Screensaver", items);
}
// Other results than an index mean that the dialog was dismissed
void onTimeoutSelectionResult(Context* ctx, int32_t selected) {
ctx->timeoutDialogId = 0;
if (selected >= 0 && selected < static_cast<int32_t>(std::size(TIMEOUT_OPTIONS))) {
ctx->displaySettings.backlightTimeoutMs = TIMEOUT_OPTIONS[selected].ms;
ctx->displaySettingsUpdated = true; ctx->displaySettingsUpdated = true;
} }
} }
void onScreensaverSelectionResult(Context* ctx, int32_t selected) {
ctx->screensaverDialogId = 0;
if (selected >= 0 && selected < static_cast<int32_t>(settings::display::ScreensaverType::Count)) {
ctx->displaySettings.screensaverType = static_cast<settings::display::ScreensaverType>(selected);
ctx->displaySettingsUpdated = true;
}
}
/** A row in a card: "Title Value [Change]" */
lv_obj_t* createValueRow(lv_obj_t* card, const char* title, lv_event_cb_t onChange, Context* ctx) {
auto* row = lv_obj_create(card);
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_all(row, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(row, 0, LV_STATE_DEFAULT);
// The card provides the background
lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, LV_STATE_DEFAULT);
lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE);
auto* title_label = lv_label_create(row);
lv_label_set_text(title_label, title);
lv_obj_set_flex_grow(title_label, 1);
auto* value_label = lv_label_create(row);
auto* button = lv_button_create(row);
lv_label_set_text(lv_label_create(button), "Change");
lv_obj_add_event_cb(button, onChange, LV_EVENT_SHORT_CLICKED, ctx);
return value_label;
}
void createWidgets(lv_obj_t* parent, void* userData) { void createWidgets(lv_obj_t* parent, void* userData) {
auto* ctx = static_cast<Context*>(userData); auto* ctx = static_cast<Context*>(userData);
ctx->displaySettings = settings::display::loadOrGetDefault();
bool has_imu = device_exists_of_type(&IMU_TYPE); bool has_imu = device_exists_of_type(&IMU_TYPE);
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
@ -189,6 +359,7 @@ void createWidgets(lv_obj_t* parent, void* userData) {
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
auto* main_wrapper = lv_obj_create(parent); auto* main_wrapper = lv_obj_create(parent);
lv_obj_set_style_border_width(main_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_set_width(main_wrapper, LV_PCT(100)); lv_obj_set_width(main_wrapper, LV_PCT(100));
lv_obj_set_flex_grow(main_wrapper, 1); lv_obj_set_flex_grow(main_wrapper, 1);
@ -200,22 +371,16 @@ void createWidgets(lv_obj_t* parent, void* userData) {
if (backlight != nullptr) { if (backlight != nullptr) {
bool is_on_off_brightness = backlight_get_min_brightness(backlight) == 0 && backlight_get_max_brightness(backlight) == 1; bool is_on_off_brightness = backlight_get_min_brightness(backlight) == 0 && backlight_get_max_brightness(backlight) == 1;
if (!is_on_off_brightness) { if (!is_on_off_brightness) {
auto* brightness_wrapper = lv_obj_create(main_wrapper); auto* brightness_label = lv_label_create(main_wrapper);
lv_obj_set_size(brightness_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_hor(brightness_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(brightness_wrapper, 0, LV_STATE_DEFAULT);
auto* brightness_label = lv_label_create(brightness_wrapper);
lv_label_set_text(brightness_label, "Brightness"); lv_label_set_text(brightness_label, "Brightness");
lv_obj_align(brightness_label, LV_ALIGN_LEFT_MID, 0, 0);
auto* brightness_slider = lv_slider_create(brightness_wrapper); auto* brightness_card = lvgl_card_create(main_wrapper);
lv_obj_set_width(brightness_slider, LV_PCT(50)); lv_obj_set_size(brightness_card, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_align(brightness_slider, LV_ALIGN_RIGHT_MID, 0, 0);
lv_slider_set_range(brightness_slider, backlight_get_min_brightness(backlight), backlight_get_max_brightness(backlight));
lv_obj_add_event_cb(brightness_slider, onBacklightSliderEvent, LV_EVENT_VALUE_CHANGED, ctx);
lv_slider_set_value(brightness_slider, ctx->displaySettings.backlightDuty, LV_ANIM_OFF); const int32_t percent = brightnessToPercent(backlight, ctx->displaySettings.backlightDuty);
auto* brightness_slider = lvgl_sliderbox_create(brightness_card, 0, 100, 10, percent);
lv_obj_set_width(brightness_slider, LV_PCT(100));
lvgl_sliderbox_add_value_changed_cb(brightness_slider, onBacklightSliderEvent, ctx);
} }
// Only compared against nullptr below, never dereferenced again, so releasing it here is safe. // Only compared against nullptr below, never dereferenced again, so releasing it here is safe.
device_put(backlight); device_put(backlight);
@ -223,65 +388,41 @@ void createWidgets(lv_obj_t* parent, void* userData) {
// Orientation // Orientation
auto* orientation_wrapper = lv_obj_create(main_wrapper); // Auto-rotate (IMU-driven), which decides what the card below shows
lv_obj_set_size(orientation_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(orientation_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(orientation_wrapper, 0, LV_STATE_DEFAULT);
auto* orientation_label = lv_label_create(orientation_wrapper);
lv_label_set_text(orientation_label, "Orientation");
lv_obj_align(orientation_label, LV_ALIGN_LEFT_MID, 0, 0);
ctx->orientationDropdown = lv_dropdown_create(orientation_wrapper);
// Note: order correlates with settings::display::Orientation item order
lv_dropdown_set_options(ctx->orientationDropdown, "Landscape\nPortrait Right\nLandscape Flipped\nPortrait Left");
lv_obj_align(ctx->orientationDropdown, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_add_event_cb(ctx->orientationDropdown, onOrientationSet, LV_EVENT_VALUE_CHANGED, ctx);
// Set the dropdown to match current orientation enum
lv_dropdown_set_selected(ctx->orientationDropdown, static_cast<uint16_t>(ctx->displaySettings.orientation));
if (has_imu && ctx->displaySettings.autoRotateEnabled) {
lv_obj_add_state(ctx->orientationDropdown, LV_STATE_DISABLED);
}
// Auto-rotate (IMU-driven)
if (has_imu) { if (has_imu) {
auto* auto_rotate_wrapper = lv_obj_create(main_wrapper); auto* auto_rotate_row = lv_obj_create(main_wrapper);
lv_obj_set_size(auto_rotate_wrapper, LV_PCT(100), LV_SIZE_CONTENT); lv_obj_set_size(auto_rotate_row, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(auto_rotate_wrapper, 0, LV_STATE_DEFAULT); lv_obj_set_flex_flow(auto_rotate_row, LV_FLEX_FLOW_ROW);
lv_obj_set_style_border_width(auto_rotate_wrapper, 0, LV_STATE_DEFAULT); lv_obj_set_flex_align(auto_rotate_row, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_border_width(auto_rotate_row, 0, LV_STATE_DEFAULT);
lv_obj_set_style_pad_all(auto_rotate_row, 0, LV_STATE_DEFAULT);
lv_obj_remove_flag(auto_rotate_row, LV_OBJ_FLAG_SCROLLABLE);
auto* auto_rotate_label = lv_label_create(auto_rotate_wrapper); auto* auto_rotate_label = lv_label_create(auto_rotate_row);
lv_label_set_text(auto_rotate_label, "Auto-rotate"); lv_label_set_text(auto_rotate_label, "Auto-rotate");
lv_obj_align(auto_rotate_label, LV_ALIGN_LEFT_MID, 0, 0); lv_obj_set_flex_grow(auto_rotate_label, 1);
ctx->autoRotateSwitch = lv_switch_create(auto_rotate_wrapper); ctx->autoRotateSwitch = lv_switch_create(auto_rotate_row);
if (ctx->displaySettings.autoRotateEnabled) { lv_obj_set_state(ctx->autoRotateSwitch, LV_STATE_CHECKED, ctx->displaySettings.autoRotateEnabled);
lv_obj_add_state(ctx->autoRotateSwitch, LV_STATE_CHECKED);
}
lv_obj_align(ctx->autoRotateSwitch, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_add_event_cb(ctx->autoRotateSwitch, onAutoRotateSwitch, LV_EVENT_VALUE_CHANGED, ctx); lv_obj_add_event_cb(ctx->autoRotateSwitch, onAutoRotateSwitch, LV_EVENT_VALUE_CHANGED, ctx);
auto* mount_rotation_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(mount_rotation_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(mount_rotation_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(mount_rotation_wrapper, 0, LV_STATE_DEFAULT);
auto* mount_rotation_label = lv_label_create(mount_rotation_wrapper);
lv_label_set_text(mount_rotation_label, "Sensor mounting");
lv_obj_align(mount_rotation_label, LV_ALIGN_LEFT_MID, 0, 0);
ctx->mountRotationDropdown = lv_dropdown_create(mount_rotation_wrapper);
// Note: order correlates with settings::display::Orientation item order
lv_dropdown_set_options(ctx->mountRotationDropdown, "Landscape\nPortrait Right\nLandscape Flipped\nPortrait Left");
lv_obj_align(ctx->mountRotationDropdown, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_add_event_cb(ctx->mountRotationDropdown, onMountRotationSet, LV_EVENT_VALUE_CHANGED, ctx);
lv_dropdown_set_selected(ctx->mountRotationDropdown, static_cast<uint16_t>(ctx->displaySettings.autoRotateMountRotation));
if (!ctx->displaySettings.autoRotateEnabled) {
lv_obj_add_state(ctx->mountRotationDropdown, LV_STATE_DISABLED);
}
} }
// The card shows the manual orientation, or the sensor mounting when auto-rotate is on
auto* orientation_card = lvgl_card_create(main_wrapper);
lv_obj_set_size(orientation_card, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(orientation_card, LV_FLEX_FLOW_COLUMN);
ctx->orientationGroup = createCardGroup(orientation_card, "Orientation");
ctx->orientationChips = createOrientationChips(ctx->orientationGroup, ctx->displaySettings.orientation, onOrientationChipPressed, ctx);
// Auto-rotate needs to know how the sensor is mounted relative to the display
if (has_imu) {
ctx->mountRotationGroup = createCardGroup(orientation_card, "Sensor mounting");
createOrientationChips(ctx->mountRotationGroup, ctx->displaySettings.autoRotateMountRotation, onMountRotationChipPressed, ctx);
}
updateOrientationVisibility(ctx);
// Screen timeout // Screen timeout
// Note: DisplayIdleService doesn't act on these settings for kernel-driver displays yet // Note: DisplayIdleService doesn't act on these settings for kernel-driver displays yet
// (it only looks up the deprecated tt::hal::display::DisplayDevice), so these currently // (it only looks up the deprecated tt::hal::display::DisplayDevice), so these currently
@ -290,74 +431,46 @@ void createWidgets(lv_obj_t* parent, void* userData) {
if (backlight != nullptr) { if (backlight != nullptr) {
auto* timeout_wrapper = lv_obj_create(main_wrapper); auto* timeout_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(timeout_wrapper, LV_PCT(100), LV_SIZE_CONTENT); lv_obj_set_size(timeout_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(timeout_wrapper, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(timeout_wrapper, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_all(timeout_wrapper, 0, LV_STATE_DEFAULT); lv_obj_set_style_pad_all(timeout_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(timeout_wrapper, 0, LV_STATE_DEFAULT); lv_obj_set_style_border_width(timeout_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_remove_flag(timeout_wrapper, LV_OBJ_FLAG_SCROLLABLE);
auto* timeout_label = lv_label_create(timeout_wrapper); auto* timeout_label = lv_label_create(timeout_wrapper);
lv_label_set_text(timeout_label, "Auto screen off"); lv_label_set_text(timeout_label, "Auto screen off");
lv_obj_align(timeout_label, LV_ALIGN_LEFT_MID, 0, 0); lv_obj_set_flex_grow(timeout_label, 1);
ctx->timeoutSwitch = lv_switch_create(timeout_wrapper); ctx->timeoutSwitch = lv_switch_create(timeout_wrapper);
if (ctx->displaySettings.backlightTimeoutEnabled) { if (ctx->displaySettings.backlightTimeoutEnabled) {
lv_obj_add_state(ctx->timeoutSwitch, LV_STATE_CHECKED); lv_obj_add_state(ctx->timeoutSwitch, LV_STATE_CHECKED);
} }
lv_obj_align(ctx->timeoutSwitch, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_add_event_cb(ctx->timeoutSwitch, onTimeoutSwitch, LV_EVENT_VALUE_CHANGED, ctx); lv_obj_add_event_cb(ctx->timeoutSwitch, onTimeoutSwitch, LV_EVENT_VALUE_CHANGED, ctx);
auto* timeout_select_wrapper = lv_obj_create(main_wrapper); auto* timeout_card = lvgl_card_create(main_wrapper);
lv_obj_set_size(timeout_select_wrapper, LV_PCT(100), LV_SIZE_CONTENT); lv_obj_set_size(timeout_card, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(timeout_select_wrapper, 0, LV_STATE_DEFAULT); lv_obj_set_flex_flow(timeout_card, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_border_width(timeout_select_wrapper, 0, LV_STATE_DEFAULT); lv_obj_set_flag(timeout_card, LV_OBJ_FLAG_HIDDEN, !ctx->displaySettings.backlightTimeoutEnabled);
ctx->timeoutCard = timeout_card;
auto* timeout_value_label = lv_label_create(timeout_select_wrapper); ctx->timeoutValueLabel = createValueRow(timeout_card, "Timeout", onChangeTimeoutPressed, ctx);
lv_label_set_text(timeout_value_label, "Timeout"); ctx->screensaverValueLabel = createValueRow(timeout_card, "Screensaver", onChangeScreensaverPressed, ctx);
lv_obj_align(timeout_value_label, LV_ALIGN_LEFT_MID, 0, 0); updateTimeoutLabels(ctx);
ctx->timeoutDropdown = lv_dropdown_create(timeout_select_wrapper);
lv_dropdown_set_options(ctx->timeoutDropdown, "15 seconds\n30 seconds\n1 minute\n2 minutes\n5 minutes\nNever");
lv_obj_align(ctx->timeoutDropdown, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_add_event_cb(ctx->timeoutDropdown, onTimeoutChanged, LV_EVENT_VALUE_CHANGED, ctx);
// Initialize dropdown selection from settings
uint32_t ms = ctx->displaySettings.backlightTimeoutMs;
uint32_t idx = 2; // default 1 minute
if (ms == 15000) idx = 0;
else if (ms == 30000)
idx = 1;
else if (ms == 60000)
idx = 2;
else if (ms == 120000)
idx = 3;
else if (ms == 300000)
idx = 4;
else if (ms == 0)
idx = 5;
lv_dropdown_set_selected(ctx->timeoutDropdown, idx);
if (!ctx->displaySettings.backlightTimeoutEnabled) {
lv_obj_add_state(ctx->timeoutDropdown, LV_STATE_DISABLED);
}
// Screensaver type
auto* screensaver_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(screensaver_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(screensaver_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(screensaver_wrapper, 0, LV_STATE_DEFAULT);
auto* screensaver_label = lv_label_create(screensaver_wrapper);
lv_label_set_text(screensaver_label, "Screensaver");
lv_obj_align(screensaver_label, LV_ALIGN_LEFT_MID, 0, 0);
ctx->screensaverDropdown = lv_dropdown_create(screensaver_wrapper);
// Note: order correlates with settings::display::ScreensaverType enum order
lv_dropdown_set_options(ctx->screensaverDropdown, "None\nBouncing Balls\nMystify\nMatrix Rain\nStackChan");
lv_obj_align(ctx->screensaverDropdown, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_add_event_cb(ctx->screensaverDropdown, onScreensaverChanged, LV_EVENT_VALUE_CHANGED, ctx);
lv_dropdown_set_selected(ctx->screensaverDropdown, static_cast<uint16_t>(ctx->displaySettings.screensaverType));
if (!ctx->displaySettings.backlightTimeoutEnabled) {
lv_obj_add_state(ctx->screensaverDropdown, LV_STATE_DISABLED);
}
} }
} }
void destroyWidgets(void* userData) {
auto* ctx = static_cast<Context*>(userData);
ctx->timeoutSwitch = nullptr;
ctx->timeoutCard = nullptr;
ctx->timeoutValueLabel = nullptr;
ctx->screensaverValueLabel = nullptr;
ctx->orientationGroup = nullptr;
ctx->orientationChips = nullptr;
ctx->autoRotateSwitch = nullptr;
ctx->mountRotationGroup = nullptr;
}
// Mirrors the old onHide() behaviour: persist the settings (regardless of whether the app is // Mirrors the old onHide() behaviour: persist the settings (regardless of whether the app is
// giving up its thread for a save/resume cycle, or closing for good) whenever they changed. // giving up its thread for a save/resume cycle, or closing for good) whenever they changed.
void persistIfUpdated(Context& ctx) { void persistIfUpdated(Context& ctx) {
@ -386,18 +499,31 @@ int32_t appMain(int argc, char* argv[]) {
uint32_t appInstanceId = app_scheduler_current_app_id(); uint32_t appInstanceId = app_scheduler_current_app_id();
Context ctx {}; Context ctx {};
ctx.appInstanceId = appInstanceId; ctx.appInstanceId = appInstanceId;
// Loaded once: the window is rebuilt from it, e.g. after a dialog closed
ctx.displaySettings = settings::display::loadOrGetDefault();
TaskEventGroup event_group {}; TaskEventGroup event_group {};
task_event_group_construct(&event_group); task_event_group_construct(&event_group);
ctx.eventGroup = &event_group;
check(task_event_group_claim_bit(&event_group, &ctx.selectTimeoutBit) == ERROR_NONE);
check(task_event_group_claim_bit(&event_group, &ctx.selectScreensaverBit) == ERROR_NONE);
AppEventSubscription sub {}; AppEventSubscription sub {};
check(app_event_subscribe(&sub, &event_group) == ERROR_NONE); check(app_event_subscribe(&sub, &event_group) == ERROR_NONE);
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx); WindowId window = window_manager_create_ext(appInstanceId, createWidgets, destroyWidgets, &ctx);
bool shouldClose = false; bool shouldClose = false;
while (!shouldClose) { while (!shouldClose) {
task_event_group_wait_any(&event_group, nullptr, portMAX_DELAY); uint32_t flags = 0;
task_event_group_wait_any(&event_group, &flags, portMAX_DELAY);
if (flags & ctx.selectTimeoutBit) {
startTimeoutSelection(&ctx);
}
if (flags & ctx.selectScreensaverBit) {
startScreensaverSelection(&ctx);
}
AppEvent event {}; AppEvent event {};
while (app_event_poll(&sub, &event) == ERROR_NONE) { while (app_event_poll(&sub, &event) == ERROR_NONE) {
@ -406,6 +532,17 @@ int32_t appMain(int argc, char* argv[]) {
persistIfUpdated(ctx); persistIfUpdated(ctx);
shouldClose = true; shouldClose = true;
break; break;
case APP_EVENT_RESULT:
if (event.result.launch_id == ctx.timeoutDialogId) {
onTimeoutSelectionResult(&ctx, event.result.result);
} else if (event.result.launch_id == ctx.screensaverDialogId) {
onScreensaverSelectionResult(&ctx, event.result.result);
}
lvgl_lock();
updateTimeoutLabels(&ctx);
lvgl_unlock();
app_manager_stop(event.result.launch_id);
break;
default: default:
break; break;
} }

View File

@ -13,6 +13,9 @@
#include <lvgl_window_manager/window_manager.h> #include <lvgl_window_manager/window_manager.h>
#include <lvgl/grid_navigation.h>
#include <lvgl/widgets/card.h>
#include <lvgl/widgets/chip.h>
#include <lvgl/widgets/toolbar.h> #include <lvgl/widgets/toolbar.h>
namespace tt::app::grovesettings { namespace tt::app::grovesettings {
@ -36,11 +39,35 @@ void collectDevices(Context* ctx) {
}); });
} }
void onModeChanged(lv_event_t* e) { struct ModeOption {
GroveMode mode;
const char* name;
};
constexpr ModeOption MODE_OPTIONS[] = {
{ GROVE_MODE_DISABLED, "Disabled" },
{ GROVE_MODE_I2C, "I2C" },
{ GROVE_MODE_UART, "UART" },
};
GroveMode getChipMode(lv_obj_t* chip) {
return static_cast<GroveMode>(reinterpret_cast<intptr_t>(lv_obj_get_user_data(chip)));
}
void updateChips(lv_obj_t* chipsRow, GroveMode mode) {
const uint32_t count = lv_obj_get_child_count(chipsRow);
for (uint32_t i = 0; i < count; i++) {
auto* chip = lv_obj_get_child(chipsRow, static_cast<int32_t>(i));
lv_obj_set_state(chip, LV_STATE_CHECKED, getChipMode(chip) == mode);
}
}
void onModeChipPressed(lv_event_t* e) {
auto* device = static_cast<::Device*>(lv_event_get_user_data(e)); auto* device = static_cast<::Device*>(lv_event_get_user_data(e));
auto* dropdown = static_cast<lv_obj_t*>(lv_event_get_target(e)); auto* chip = lv_event_get_target_obj(e);
auto mode = static_cast<GroveMode>(lv_dropdown_get_selected(dropdown)); if (grove_set_mode(device, getChipMode(chip)) == ERROR_NONE) {
grove_set_mode(device, mode); updateChips(lv_obj_get_parent(chip), getChipMode(chip));
}
} }
void onBackPressed(lv_event_t* event) { void onBackPressed(lv_event_t* event) {
@ -60,29 +87,44 @@ void createWidgets(lv_obj_t* parent, void* userData) {
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
auto* main_wrapper = lv_obj_create(parent); auto* main_wrapper = lv_obj_create(parent);
lv_obj_set_style_border_width(main_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_set_width(main_wrapper, LV_PCT(100)); lv_obj_set_width(main_wrapper, LV_PCT(100));
lv_obj_set_flex_grow(main_wrapper, 1); lv_obj_set_flex_grow(main_wrapper, 1);
for (auto* device : ctx->devices) { for (auto* device : ctx->devices) {
auto* row = lv_obj_create(main_wrapper); auto* label = lv_label_create(main_wrapper);
lv_obj_set_size(row, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(row, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(row, 0, LV_STATE_DEFAULT);
auto* label = lv_label_create(row);
lv_label_set_text(label, device->name); lv_label_set_text(label, device->name);
lv_obj_align(label, LV_ALIGN_LEFT_MID, 0, 0);
auto* dropdown = lv_dropdown_create(row); // The chips are centered in the card while they fit, and scroll when they don't
lv_dropdown_set_options(dropdown, "Disabled\nUART\nI2C"); auto* card = lvgl_card_create(main_wrapper);
lv_obj_align(dropdown, LV_ALIGN_RIGHT_MID, 0, 0); lv_obj_set_size(card, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(card, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_left(label, lv_obj_get_style_pad_left(card, LV_PART_MAIN), LV_STATE_DEFAULT);
auto* chips_row = lv_obj_create(card);
lv_obj_set_size(chips_row, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
lv_obj_set_style_max_width(chips_row, LV_PCT(100), LV_STATE_DEFAULT);
lv_obj_set_flex_flow(chips_row, LV_FLEX_FLOW_ROW);
lv_obj_set_scroll_dir(chips_row, LV_DIR_HOR);
lv_obj_set_style_bg_opa(chips_row, LV_OPA_TRANSP, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(chips_row, 0, LV_STATE_DEFAULT);
// The chips' margins leave room for their focus rings and space them apart
lv_obj_set_style_pad_all(chips_row, 0, LV_STATE_DEFAULT);
lv_obj_set_style_pad_column(chips_row, 0, LV_STATE_DEFAULT);
for (const auto& option : MODE_OPTIONS) {
auto* chip = lvgl_chip_create(chips_row);
lv_label_set_text(lv_label_create(chip), option.name);
lv_obj_set_user_data(chip, reinterpret_cast<void*>(static_cast<intptr_t>(option.mode)));
lv_obj_add_event_cb(chip, onModeChipPressed, LV_EVENT_SHORT_CLICKED, device);
}
lvgl_grid_navigation_add(chips_row);
GroveMode current = GROVE_MODE_DISABLED; GroveMode current = GROVE_MODE_DISABLED;
grove_get_mode(device, &current); grove_get_mode(device, &current);
lv_dropdown_set_selected(dropdown, static_cast<uint32_t>(current)); updateChips(chips_row, current);
lv_obj_add_event_cb(dropdown, onModeChanged, LV_EVENT_VALUE_CHANGED, device);
} }
} }

View File

@ -1,7 +1,5 @@
#include "Tactility/app/i2cscanner/I2cHelpers.h" #include "Tactility/app/i2cscanner/I2cHelpers.h"
#include <Tactility/StringUtils.h>
#include <tactility/drivers/i2c_controller.h> #include <tactility/drivers/i2c_controller.h>
#include <iomanip> #include <iomanip>
@ -18,7 +16,7 @@ std::string getAddressText(uint8_t address) {
return stream.str(); return stream.str();
} }
std::string getPortNamesForDropdown() { std::vector<std::string> getPortNames() {
std::vector<std::string> config_names; std::vector<std::string> config_names;
device_for_each_of_type(&I2C_CONTROLLER_TYPE, &config_names, [](auto* device, auto* context) { device_for_each_of_type(&I2C_CONTROLLER_TYPE, &config_names, [](auto* device, auto* context) {
auto* names = static_cast<std::vector<std::string>*>(context); auto* names = static_cast<std::vector<std::string>*>(context);
@ -27,7 +25,7 @@ std::string getPortNamesForDropdown() {
} }
return true; return true;
}); });
return string::join(config_names, "\n"); return config_names;
} }
bool getActivePortAtIndex(int32_t index, struct Device** out) { bool getActivePortAtIndex(int32_t index, struct Device** out) {

View File

@ -24,7 +24,10 @@
#include <string> #include <string>
#include <vector> #include <vector>
#include <lvgl/grid_navigation.h>
#include <lvgl/lvgl.h> #include <lvgl/lvgl.h>
#include <lvgl/widgets/card.h>
#include <lvgl/widgets/chip.h>
#include <lvgl/widgets/toolbar.h> #include <lvgl/widgets/toolbar.h>
namespace tt::app::i2cscanner { namespace tt::app::i2cscanner {
@ -35,9 +38,6 @@ namespace {
constexpr auto* TAG = "I2cScanner"; constexpr auto* TAG = "I2cScanner";
constexpr auto* START_SCAN_TEXT = "Scan";
constexpr auto* STOP_SCAN_TEXT = "Stop scan";
struct Context { struct Context {
uint32_t appInstanceId; uint32_t appInstanceId;
@ -47,10 +47,14 @@ struct Context {
// State // State
ScanState scanState = ScanStateInitial; ScanState scanState = ScanStateInitial;
struct Device* portDevice = nullptr; struct Device* portDevice = nullptr;
int32_t selectedBus = 0;
std::vector<uint8_t> scannedAddresses; std::vector<uint8_t> scannedAddresses;
// Widgets /** Row and column sizes of the results grid, which must outlive the grid layout */
lv_obj_t* scanButtonLabelWidget = nullptr; std::vector<int32_t> gridRows;
lv_obj_t* portDropdownWidget = nullptr; std::vector<int32_t> gridColumns;
// Widgets: the refresh button and chips only exist when there are I2C interfaces
lv_obj_t* refreshButton = nullptr;
lv_obj_t* chipsRow = nullptr;
lv_obj_t* scanListWidget = nullptr; lv_obj_t* scanListWidget = nullptr;
}; };
@ -94,6 +98,37 @@ int32_t getLastBusIndex() {
return index; return index;
} }
/** The names of the devicetree devices on the bus at the given address, separated by commas */
std::string getDeviceNames(struct Device* port, uint8_t address) {
struct Search {
uint8_t address;
std::string names;
} search = { address, "" };
device_for_each_child(port, &search, [](struct Device* child, void* context) {
auto* search = static_cast<Search*>(context);
if (child->address == search->address) {
if (!search->names.empty()) {
search->names += ", ";
}
search->names += child->name;
}
return true;
});
return search.names;
}
// A disturbed probe can report a device that isn't there, while a real device acknowledges every probe
constexpr int PROBE_COUNT = 3;
bool hasDeviceAt(struct Device* port, uint8_t address) {
for (int i = 0; i < PROBE_COUNT; i++) {
if (i2c_controller_has_device_at_address(port, address, 10 / portTICK_PERIOD_MS) != ERROR_NONE) {
return false;
}
}
return true;
}
bool getPort(Context* ctx, struct Device** outPort) { bool getPort(Context* ctx, struct Device** outPort) {
if (ctx->mutex.lock(100 / portTICK_PERIOD_MS)) { if (ctx->mutex.lock(100 / portTICK_PERIOD_MS)) {
*outPort = ctx->portDevice; *outPort = ctx->portDevice;
@ -128,25 +163,54 @@ bool shouldStopScanTimer(Context* ctx) {
void updateViews(Context* ctx) { void updateViews(Context* ctx) {
if (ctx->mutex.lock(100 / portTICK_PERIOD_MS)) { if (ctx->mutex.lock(100 / portTICK_PERIOD_MS)) {
if (ctx->scanState == ScanStateScanning) { if (ctx->scanListWidget == nullptr) {
lv_label_set_text(ctx->scanButtonLabelWidget, STOP_SCAN_TEXT); ctx->mutex.unlock();
lv_obj_remove_flag(ctx->portDropdownWidget, LV_OBJ_FLAG_CLICKABLE); return;
} else { }
lv_label_set_text(ctx->scanButtonLabelWidget, START_SCAN_TEXT);
lv_obj_add_flag(ctx->portDropdownWidget, LV_OBJ_FLAG_CLICKABLE); // The scan reads the port once, so the bus can't change and the scan can't restart while it runs
const bool scanning = ctx->scanState == ScanStateScanning;
lv_obj_set_state(ctx->refreshButton, LV_STATE_DISABLED, scanning);
if (ctx->chipsRow != nullptr) {
const uint32_t chip_count = lv_obj_get_child_count(ctx->chipsRow);
for (uint32_t i = 0; i < chip_count; i++) {
auto* chip = lv_obj_get_child(ctx->chipsRow, static_cast<int32_t>(i));
lv_obj_set_state(chip, LV_STATE_CHECKED, static_cast<int32_t>(i) == ctx->selectedBus);
lv_obj_set_state(chip, LV_STATE_DISABLED, scanning);
}
} }
lv_obj_clean(ctx->scanListWidget); lv_obj_clean(ctx->scanListWidget);
if (ctx->scanState == ScanStateStopped) { if (ctx->scanState == ScanStateStopped) {
lv_obj_remove_flag(ctx->scanListWidget, LV_OBJ_FLAG_HIDDEN); lv_obj_remove_flag(ctx->scanListWidget, LV_OBJ_FLAG_HIDDEN);
if (!ctx->scannedAddresses.empty()) { // A grid with the title in the first row, then an address and the devicetree device names per row
for (auto address: ctx->scannedAddresses) { const size_t count = ctx->scannedAddresses.size();
std::string address_text = getAddressText(address); ctx->gridColumns = { LV_GRID_CONTENT, LV_GRID_FR(1), LV_GRID_TEMPLATE_LAST };
lv_list_add_text(ctx->scanListWidget, address_text.c_str()); ctx->gridRows.assign(count + 1, LV_GRID_CONTENT);
} ctx->gridRows.push_back(LV_GRID_TEMPLATE_LAST);
lv_obj_set_grid_dsc_array(ctx->scanListWidget, ctx->gridColumns.data(), ctx->gridRows.data());
auto* title = lv_label_create(ctx->scanListWidget);
lv_obj_set_grid_cell(title, LV_GRID_ALIGN_START, 0, 2, LV_GRID_ALIGN_CENTER, 0, 1);
if (count == 0) {
lv_label_set_text(title, "No devices found");
} else { } else {
lv_list_add_text(ctx->scanListWidget, "No devices found"); lv_label_set_text(title, std::format("{} {} found", count, count == 1 ? "device" : "devices").c_str());
}
for (size_t i = 0; i < count; i++) {
const uint8_t address = ctx->scannedAddresses[i];
const auto row = static_cast<int32_t>(i + 1);
auto* address_label = lv_label_create(ctx->scanListWidget);
lv_label_set_text(address_label, getAddressText(address).c_str());
// Keys and encoders move through the addresses, which scrolls the results into view
lv_obj_add_flag(address_label, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_grid_cell(address_label, LV_GRID_ALIGN_START, 0, 1, LV_GRID_ALIGN_START, row, 1);
auto* names_label = lv_label_create(ctx->scanListWidget);
lv_label_set_long_mode(names_label, LV_LABEL_LONG_MODE_WRAP);
lv_label_set_text(names_label, getDeviceNames(ctx->portDevice, address).c_str());
lv_obj_set_grid_cell(names_label, LV_GRID_ALIGN_STRETCH, 1, 1, LV_GRID_ALIGN_START, row, 1);
} }
} else { } else {
lv_obj_add_flag(ctx->scanListWidget, LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(ctx->scanListWidget, LV_OBJ_FLAG_HIDDEN);
@ -194,7 +258,7 @@ void onScanTimer(Context* ctx) {
} }
for (uint8_t address = 1; address < 128; ++address) { for (uint8_t address = 1; address < 128; ++address) {
if (i2c_controller_has_device_at_address(safe_port, address, 10 / portTICK_PERIOD_MS) == ERROR_NONE) { if (hasDeviceAt(safe_port, address)) {
LOG_I(TAG, "Found device at address 0x%02X", address); LOG_I(TAG, "Found device at address 0x%02X", address);
if (!shouldStopScanTimer(ctx)) { if (!shouldStopScanTimer(ctx)) {
addAddressToList(ctx, address); addAddressToList(ctx, address);
@ -269,6 +333,7 @@ void selectBus(Context* ctx, int32_t selected) {
if (ctx->mutex.lock(100 / portTICK_PERIOD_MS)) { if (ctx->mutex.lock(100 / portTICK_PERIOD_MS)) {
ctx->scannedAddresses.clear(); ctx->scannedAddresses.clear();
ctx->portDevice = found_device; ctx->portDevice = found_device;
ctx->selectedBus = selected;
ctx->scanState = ScanStateInitial; ctx->scanState = ScanStateInitial;
ctx->mutex.unlock(); ctx->mutex.unlock();
} }
@ -288,20 +353,15 @@ void onBackPressed(lv_event_t* event) {
app_event_emit_close(ctx->appInstanceId); app_event_emit_close(ctx->appInstanceId);
} }
void onSelectBus(lv_event_t* event) { void onChipPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event)); auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
auto* dropdown = static_cast<lv_obj_t*>(lv_event_get_target(event)); auto* chip = lv_event_get_target_obj(event);
uint32_t selected = lv_dropdown_get_selected(dropdown); selectBus(ctx, static_cast<int32_t>(reinterpret_cast<intptr_t>(lv_obj_get_user_data(chip))));
selectBus(ctx, selected);
} }
void onPressScan(lv_event_t* event) { void onPressRefresh(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event)); auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
if (ctx->scanState == ScanStateScanning) { startScanning(ctx);
stopScanning(ctx);
} else {
startScanning(ctx);
}
updateViews(ctx); updateViews(ctx);
} }
@ -318,50 +378,56 @@ void createWidgets(lv_obj_t* parent, void* userData) {
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
auto* main_wrapper = lv_obj_create(parent); auto* main_wrapper = lv_obj_create(parent);
lv_obj_set_style_border_width(main_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_set_width(main_wrapper, LV_PCT(100)); lv_obj_set_width(main_wrapper, LV_PCT(100));
lv_obj_set_flex_grow(main_wrapper, 1); lv_obj_set_flex_grow(main_wrapper, 1);
auto* wrapper = lv_obj_create(main_wrapper); const std::vector<std::string> port_names = getPortNames();
lv_obj_set_width(wrapper, LV_PCT(100)); if (port_names.empty()) {
lv_obj_set_height(wrapper, LV_SIZE_CONTENT); lv_obj_set_flex_align(main_wrapper, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_border_width(wrapper, 0, 0); auto* label = lv_label_create(main_wrapper);
// Ensures showing selection state for keyboard/encoder devices lv_obj_set_width(label, LV_PCT(100));
lv_obj_set_style_pad_all(wrapper, 6, LV_ALIGN_DEFAULT); lv_label_set_long_mode(label, LV_LABEL_LONG_MODE_WRAP);
lv_obj_set_style_text_align(label, LV_TEXT_ALIGN_CENTER, LV_STATE_DEFAULT);
lv_label_set_text(label, "I2C interface not available");
return;
}
auto* scan_button = lv_button_create(wrapper); ctx->refreshButton = lvgl_toolbar_add_image_button_action(toolbar, LV_SYMBOL_REFRESH, onPressRefresh, ctx);
lv_obj_set_width(scan_button, LV_PCT(48));
lv_obj_align(scan_button, LV_ALIGN_TOP_LEFT, 0, 1); // Shift 1 pixel to align with selection box
lv_obj_add_event_cb(scan_button, onPressScan, LV_EVENT_SHORT_CLICKED, ctx);
auto* scan_button_label = lv_label_create(scan_button);
lv_obj_align(scan_button_label, LV_ALIGN_CENTER, 0, 0);
lv_label_set_text(scan_button_label, START_SCAN_TEXT);
ctx->scanButtonLabelWidget = scan_button_label;
auto* port_dropdown = lv_dropdown_create(wrapper); if (port_names.size() > 1) {
std::string dropdown_items = getPortNamesForDropdown(); // Centered while the chips fit, and scrollable when they don't
lv_dropdown_set_options(port_dropdown, dropdown_items.c_str()); lv_obj_set_flex_align(main_wrapper, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START);
lv_obj_set_width(port_dropdown, LV_PCT(48)); auto* chips_row = lv_obj_create(main_wrapper);
lv_obj_align(port_dropdown, LV_ALIGN_TOP_RIGHT, 0, 0); lv_obj_set_size(chips_row, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
lv_obj_add_event_cb(port_dropdown, onSelectBus, LV_EVENT_VALUE_CHANGED, ctx); lv_obj_set_style_max_width(chips_row, LV_PCT(100), LV_STATE_DEFAULT);
auto selected_bus = getLastBusIndex(); lv_obj_set_flex_flow(chips_row, LV_FLEX_FLOW_ROW);
lv_dropdown_set_selected(port_dropdown, selected_bus); lv_obj_set_scroll_dir(chips_row, LV_DIR_HOR);
ctx->portDropdownWidget = port_dropdown; lv_obj_set_style_border_width(chips_row, 0, LV_STATE_DEFAULT);
// The chips' margins leave room for their focus rings and space them apart
lv_obj_set_style_pad_all(chips_row, 0, LV_STATE_DEFAULT);
lv_obj_set_style_pad_column(chips_row, 0, LV_STATE_DEFAULT);
for (size_t i = 0; i < port_names.size(); i++) {
auto* chip = lvgl_chip_create(chips_row);
lv_label_set_text(lv_label_create(chip), port_names[i].c_str());
lv_obj_set_user_data(chip, reinterpret_cast<void*>(static_cast<intptr_t>(i)));
lv_obj_add_event_cb(chip, onChipPressed, LV_EVENT_SHORT_CLICKED, ctx);
}
lvgl_grid_navigation_add(chips_row);
ctx->chipsRow = chips_row;
}
auto* scan_list = lv_list_create(main_wrapper); auto* scan_list = lvgl_card_create(main_wrapper);
lv_obj_set_style_margin_top(scan_list, 8, 0); lv_obj_set_size(scan_list, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_width(scan_list, LV_PCT(100)); lv_obj_set_layout(scan_list, LV_LAYOUT_GRID);
lv_obj_set_height(scan_list, LV_SIZE_CONTENT); lvgl_grid_navigation_add(scan_list);
lv_obj_add_flag(scan_list, LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(scan_list, LV_OBJ_FLAG_HIDDEN);
ctx->scanListWidget = scan_list; ctx->scanListWidget = scan_list;
struct Device* dummy; struct Device* dummy;
if (getActivePortAtIndex(selected_bus, &dummy)) { const int32_t last_bus = getLastBusIndex();
selectBus(ctx, selected_bus); selectBus(ctx, getActivePortAtIndex(last_bus, &dummy) ? last_bus : 0);
} else if (getActivePortAtIndex(0, &dummy)) {
lv_dropdown_set_selected(port_dropdown, 0);
selectBus(ctx, 0);
}
} }
// Mirrors the old model's onHide(): stop any in-flight scan before this app's task exits // Mirrors the old model's onHide(): stop any in-flight scan before this app's task exits

View File

@ -122,6 +122,7 @@ void createWidgets(lv_obj_t* parent, void* userData) {
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
auto* main_wrapper = lv_obj_create(parent); auto* main_wrapper = lv_obj_create(parent);
lv_obj_set_style_border_width(main_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_set_width(main_wrapper, LV_PCT(100)); lv_obj_set_width(main_wrapper, LV_PCT(100));
lv_obj_set_flex_grow(main_wrapper, 1); lv_obj_set_flex_grow(main_wrapper, 1);
@ -129,15 +130,17 @@ void createWidgets(lv_obj_t* parent, void* userData) {
// Keyboard backlight toggle // Keyboard backlight toggle
auto* bl_wrapper = lv_obj_create(main_wrapper); auto* bl_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(bl_wrapper, LV_PCT(100), LV_SIZE_CONTENT); lv_obj_set_size(bl_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(bl_wrapper, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(bl_wrapper, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_remove_flag(bl_wrapper, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_style_pad_all(bl_wrapper, 0, LV_STATE_DEFAULT); lv_obj_set_style_pad_all(bl_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(bl_wrapper, 0, LV_STATE_DEFAULT); lv_obj_set_style_border_width(bl_wrapper, 0, LV_STATE_DEFAULT);
auto* bl_label = lv_label_create(bl_wrapper); auto* bl_label = lv_label_create(bl_wrapper);
lv_label_set_text(bl_label, "Keyboard backlight"); lv_label_set_text(bl_label, "Keyboard backlight");
lv_obj_align(bl_label, LV_ALIGN_LEFT_MID, 0, 0); lv_obj_set_flex_grow(bl_label, 1);
ctx->switchBacklight = lv_switch_create(bl_wrapper); ctx->switchBacklight = lv_switch_create(bl_wrapper);
if (ctx->kbSettings.backlightEnabled) lv_obj_add_state(ctx->switchBacklight, LV_STATE_CHECKED); if (ctx->kbSettings.backlightEnabled) lv_obj_add_state(ctx->switchBacklight, LV_STATE_CHECKED);
lv_obj_align(ctx->switchBacklight, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_add_event_cb(ctx->switchBacklight, onBacklightSwitch, LV_EVENT_VALUE_CHANGED, ctx); lv_obj_add_event_cb(ctx->switchBacklight, onBacklightSwitch, LV_EVENT_VALUE_CHANGED, ctx);
// Brightness slider // Brightness slider
@ -160,15 +163,17 @@ void createWidgets(lv_obj_t* parent, void* userData) {
// Backlight timeout enable // Backlight timeout enable
auto* to_enable_wrapper = lv_obj_create(main_wrapper); auto* to_enable_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(to_enable_wrapper, LV_PCT(100), LV_SIZE_CONTENT); lv_obj_set_size(to_enable_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(to_enable_wrapper, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(to_enable_wrapper, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_remove_flag(to_enable_wrapper, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_style_pad_all(to_enable_wrapper, 0, LV_STATE_DEFAULT); lv_obj_set_style_pad_all(to_enable_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(to_enable_wrapper, 0, LV_STATE_DEFAULT); lv_obj_set_style_border_width(to_enable_wrapper, 0, LV_STATE_DEFAULT);
auto* to_enable_label = lv_label_create(to_enable_wrapper); auto* to_enable_label = lv_label_create(to_enable_wrapper);
lv_label_set_text(to_enable_label, "Auto backlight off"); lv_label_set_text(to_enable_label, "Auto backlight off");
lv_obj_align(to_enable_label, LV_ALIGN_LEFT_MID, 0, 0); lv_obj_set_flex_grow(to_enable_label, 1);
ctx->switchTimeoutEnable = lv_switch_create(to_enable_wrapper); ctx->switchTimeoutEnable = lv_switch_create(to_enable_wrapper);
if (ctx->kbSettings.backlightTimeoutEnabled) lv_obj_add_state(ctx->switchTimeoutEnable, LV_STATE_CHECKED); if (ctx->kbSettings.backlightTimeoutEnabled) lv_obj_add_state(ctx->switchTimeoutEnable, LV_STATE_CHECKED);
lv_obj_align(ctx->switchTimeoutEnable, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_add_event_cb(ctx->switchTimeoutEnable, onTimeoutEnableSwitch, LV_EVENT_VALUE_CHANGED, ctx); lv_obj_add_event_cb(ctx->switchTimeoutEnable, onTimeoutEnableSwitch, LV_EVENT_VALUE_CHANGED, ctx);
auto* timeout_select_wrapper = lv_obj_create(main_wrapper); auto* timeout_select_wrapper = lv_obj_create(main_wrapper);

View File

@ -0,0 +1,131 @@
#include <Tactility/app/launcher/Favourites.h>
#include <Tactility/app/launcher/LauncherMode.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <app/package_manifest.h>
#include <lvgl/icons/shared.h>
#include <tactility/log.h>
#include <algorithm>
#include <cstring>
#include <string>
namespace tt::app::launcher {
namespace {
constexpr auto* TAG = "Launcher";
const Favourites favourites;
struct IconEntry {
const char* id;
const char* icon;
};
constexpr IconEntry ICONS[] = {
{"tactility.apphub", LVGL_ICON_SHARED_DOWNLOAD},
{"tactility.camera", LVGL_ICON_SHARED_CAMERA},
{"tactility.chat", LVGL_ICON_SHARED_FORUM},
{"tactility.files", LVGL_ICON_SHARED_FOLDER},
{"tactility.i2cscanner", LVGL_ICON_SHARED_CABLE},
{"tactility.notes", LVGL_ICON_SHARED_EDIT_NOTE},
{"tactility.screenshot", LVGL_ICON_SHARED_IMAGE},
{"tactility.systeminfo", LVGL_ICON_SHARED_DEVICES},
{"tactility.terminal", LVGL_ICON_SHARED_TERMINAL},
{"tactility.webserversettings", LVGL_ICON_SHARED_CLOUD},
};
// Hidden apps that are still shown in the launcher
constexpr const char* SHOWN_HIDDEN_APP_IDS[] = {
"tactility.files",
};
const char* appIcon(const ::AppManifest* manifest) {
for (const auto& entry : ICONS) {
if (!strcmp(manifest->id, entry.id)) return entry.icon;
}
return LVGL_ICON_SHARED_DEPLOYED_CODE;
}
bool isShown(const ::AppManifest& manifest) {
if (manifest.category != APP_CATEGORY_USER && manifest.category != APP_CATEGORY_SYSTEM) {
return false;
}
if ((manifest.flags & APP_MANIFEST_FLAG_HIDDEN) == 0) {
return true;
}
return std::ranges::any_of(SHOWN_HIDDEN_APP_IDS, [&](const char* id) { return strcmp(manifest.id, id) == 0; });
}
void collectManifest(const ::AppManifest* manifest, void* context) {
auto* manifests = static_cast<std::vector<::AppManifest>*>(context);
manifests->push_back(*manifest);
}
// Apps from installed packages this device can't run (wrong device, too little RAM)
void collectIncompatibleAppIds(const AppPackage* package, void* context) {
if (app_package_manifest_is_compatible(&package->package)) {
return;
}
auto* ids = static_cast<std::vector<std::string>*>(context);
for (size_t i = 0; i < package->app_id_count; i++) {
ids->emplace_back(package->app_ids[i]);
}
}
std::vector<AppGridItem> collectItems() {
const std::vector<std::string> favouriteIds = favourites.load();
std::vector<::AppManifest> collected;
app_manager_for_each_manifest(collectManifest, &collected);
std::vector<std::string> incompatibleIds;
app_manager_for_each_package(collectIncompatibleAppIds, &incompatibleIds);
std::erase_if(collected, [&](const ::AppManifest& manifest) {
return !isShown(manifest) || std::ranges::find(incompatibleIds, std::string(manifest.id)) != incompatibleIds.end();
});
std::vector<AppGridItem> items;
items.reserve(collected.size());
for (const auto& manifest : collected) {
items.push_back({ manifest, appIcon(&manifest), Favourites::contains(favouriteIds, manifest.id) });
}
std::ranges::sort(items, [](const AppGridItem& a, const AppGridItem& b) {
if (a.highlighted != b.highlighted) {
return a.highlighted;
}
return strcmp(a.manifest.name, b.manifest.name) < 0;
});
return items;
}
void toggleFavourite(AppGrid& grid, const char* appId, bool keepSelection) {
if (!favourites.toggle(appId)) {
LOG_E(TAG, "Failed to save favourites");
}
grid.requestRepopulate(keepSelection);
}
void onLongPressed(AppGrid& grid, const ::AppManifest& manifest) {
toggleFavourite(grid, manifest.id, false);
}
void onKey(AppGrid& grid, const ::AppManifest& manifest, uint32_t key) {
if (key == 'f' || key == 'F') {
toggleFavourite(grid, manifest.id, true);
}
}
} // namespace
const LauncherMode APPS_MODE = {
.buttonIcon = LVGL_ICON_SHARED_APPS,
.iconColor = AppGrid::IconColor::Primary,
.collect = collectItems,
.onLongPressed = onLongPressed,
.onKey = onKey
};
}

View File

@ -1,13 +1,10 @@
#include <app/event.h> #include <app/event.h>
#include <app/manager.h> #include <app/manager.h>
#include <app/manifest.h> #include <app/manifest.h>
#include <app/package_manifest.h>
#include <app/scheduler.h> #include <app/scheduler.h>
#include <app/start.h> #include <app/start.h>
#include <algorithm>
#include <cstring> #include <cstring>
#include <string>
#include <vector> #include <vector>
#include <lvgl.h> #include <lvgl.h>
@ -22,7 +19,7 @@
#include <tactility/log.h> #include <tactility/log.h>
#include <Tactility/app/AppGrid.h> #include <Tactility/app/AppGrid.h>
#include <Tactility/app/launcher/Favourites.h> #include <Tactility/app/launcher/LauncherMode.h>
#include <Tactility/app/setup/Setup.h> #include <Tactility/app/setup/Setup.h>
#include <Tactility/settings/BootSettings.h> #include <Tactility/settings/BootSettings.h>
#include <Tactility/Tactility.h> #include <Tactility/Tactility.h>
@ -33,69 +30,27 @@ constexpr auto* TAG = "Launcher";
namespace { namespace {
struct IconEntry {
const char* id;
const char* icon;
};
constexpr IconEntry ICONS[] = {
{"tactility.apphub", LVGL_ICON_SHARED_DOWNLOAD},
{"tactility.camera", LVGL_ICON_SHARED_CAMERA},
{"tactility.chat", LVGL_ICON_SHARED_FORUM},
{"tactility.files", LVGL_ICON_SHARED_FOLDER},
{"tactility.i2cscanner", LVGL_ICON_SHARED_CABLE},
{"tactility.notes", LVGL_ICON_SHARED_EDIT_NOTE},
{"tactility.screenshot", LVGL_ICON_SHARED_IMAGE},
{"tactility.systeminfo", LVGL_ICON_SHARED_DEVICES},
{"tactility.terminal", LVGL_ICON_SHARED_TERMINAL},
{"tactility.webserversettings", LVGL_ICON_SHARED_CLOUD},
};
// Hidden apps that are still shown in the launcher
constexpr const char* SHOWN_HIDDEN_APP_IDS[] = {
"tactility.files",
};
const char* appIcon(const ::AppManifest* manifest) {
for (const auto& entry : ICONS) {
if (!strcmp(manifest->id, entry.id)) return entry.icon;
}
return LVGL_ICON_SHARED_DEPLOYED_CODE;
}
bool isShown(const ::AppManifest& manifest) {
if (manifest.category != APP_CATEGORY_USER && manifest.category != APP_CATEGORY_SYSTEM) {
return false;
}
if ((manifest.flags & APP_MANIFEST_FLAG_HIDDEN) == 0) {
return true;
}
return std::ranges::any_of(SHOWN_HIDDEN_APP_IDS, [&](const char* id) { return strcmp(manifest.id, id) == 0; });
}
void collectManifest(const ::AppManifest* manifest, void* context) {
auto* manifests = static_cast<std::vector<::AppManifest>*>(context);
manifests->push_back(*manifest);
}
// Apps from installed packages this device can't run (wrong device, too little RAM)
void collectIncompatibleAppIds(const AppPackage* package, void* context) {
if (app_package_manifest_is_compatible(&package->package)) {
return;
}
auto* ids = static_cast<std::vector<std::string>*>(context);
for (size_t i = 0; i < package->app_id_count; i++) {
ids->emplace_back(package->app_ids[i]);
}
}
std::vector<AppGridItem> collectItems(void* userData); std::vector<AppGridItem> collectItems(void* userData);
void onAppClicked(const ::AppManifest& manifest, void* userData); void onAppClicked(const ::AppManifest& manifest, void* userData);
void onAppLongPressed(const ::AppManifest& manifest, void* userData); void onAppLongPressed(const ::AppManifest& manifest, void* userData);
void onAppKey(const ::AppManifest& manifest, uint32_t key, void* userData); void onAppKey(const ::AppManifest& manifest, uint32_t key, void* userData);
enum class Mode {
Apps,
Settings
};
const LauncherMode& getLauncherMode(Mode mode) {
return mode == Mode::Settings ? SETTINGS_MODE : APPS_MODE;
}
Mode getOtherMode(Mode mode) {
return mode == Mode::Settings ? Mode::Apps : Mode::Settings;
}
struct Context { struct Context {
Favourites favourites; Mode mode = Mode::Apps;
lv_obj_t* modeButtonIcon = nullptr;
AppGrid grid { AppGrid::Callbacks { AppGrid grid { AppGrid::Callbacks {
.collect = collectItems, .collect = collectItems,
.onClicked = onAppClicked, .onClicked = onAppClicked,
@ -106,29 +61,7 @@ struct Context {
}; };
std::vector<AppGridItem> collectItems(void* userData) { std::vector<AppGridItem> collectItems(void* userData) {
const auto* ctx = static_cast<Context*>(userData); return getLauncherMode(static_cast<Context*>(userData)->mode).collect();
const std::vector<std::string> favouriteIds = ctx->favourites.load();
std::vector<::AppManifest> collected;
app_manager_for_each_manifest(collectManifest, &collected);
std::vector<std::string> incompatibleIds;
app_manager_for_each_package(collectIncompatibleAppIds, &incompatibleIds);
std::erase_if(collected, [&](const ::AppManifest& manifest) {
return !isShown(manifest) || std::ranges::find(incompatibleIds, std::string(manifest.id)) != incompatibleIds.end();
});
std::vector<AppGridItem> items;
items.reserve(collected.size());
for (const auto& manifest : collected) {
items.push_back({ manifest, appIcon(&manifest), Favourites::contains(favouriteIds, manifest.id) });
}
std::ranges::sort(items, [](const AppGridItem& a, const AppGridItem& b) {
if (a.highlighted != b.highlighted) {
return a.highlighted;
}
return strcmp(a.manifest.name, b.manifest.name) < 0;
});
return items;
} }
void startApp(const char* appId) { void startApp(const char* appId) {
@ -143,23 +76,38 @@ void onAppClicked(const ::AppManifest& manifest, void*) {
startApp(manifest.id); startApp(manifest.id);
} }
void toggleFavourite(Context* ctx, const char* appId, bool keepSelection) {
if (!ctx->favourites.toggle(appId)) {
LOG_E(TAG, "Failed to save favourites");
}
ctx->grid.requestRepopulate(keepSelection);
}
void onAppLongPressed(const ::AppManifest& manifest, void* userData) { void onAppLongPressed(const ::AppManifest& manifest, void* userData) {
toggleFavourite(static_cast<Context*>(userData), manifest.id, false); auto* ctx = static_cast<Context*>(userData);
const LauncherMode& mode = getLauncherMode(ctx->mode);
if (mode.onLongPressed != nullptr) {
mode.onLongPressed(ctx->grid, manifest);
}
} }
void onAppKey(const ::AppManifest& manifest, uint32_t key, void* userData) { void onAppKey(const ::AppManifest& manifest, uint32_t key, void* userData) {
if (key == 'f' || key == 'F') { auto* ctx = static_cast<Context*>(userData);
toggleFavourite(static_cast<Context*>(userData), manifest.id, true); const LauncherMode& mode = getLauncherMode(ctx->mode);
if (mode.onKey != nullptr) {
mode.onKey(ctx->grid, manifest, key);
} }
} }
void applyIconColor(Context* ctx) {
// The monochrome theme's primary color is black, also on a black background
if (!lvgl_theme_is_mono()) {
ctx->grid.setIconColor(getLauncherMode(ctx->mode).iconColor);
}
}
void onModeButtonPressed(lv_event_t* e) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(e));
ctx->mode = getOtherMode(ctx->mode);
applyIconColor(ctx);
ctx->grid.showFirstPage();
ctx->grid.requestRepopulate(false);
lv_label_set_text(ctx->modeButtonIcon, getLauncherMode(getOtherMode(ctx->mode)).buttonIcon);
}
void onShortcutPressed(lv_event_t* e) { void onShortcutPressed(lv_event_t* e) {
startApp(static_cast<const char*>(lv_event_get_user_data(e))); startApp(static_cast<const char*>(lv_event_get_user_data(e)));
} }
@ -188,13 +136,11 @@ void createWidgets(lv_obj_t* parent, void* userData) {
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
// The monochrome theme's primary color is black, also on a black background applyIconColor(ctx);
if (!lvgl_theme_is_mono()) {
ctx->grid.setIconColor(AppGrid::IconColor::Primary);
}
ctx->grid.setSwipeNavigation(true); ctx->grid.setSwipeNavigation(true);
ctx->grid.createWidgetsWithBottomBar(parent); ctx->grid.createWidgetsWithBottomBar(parent);
ctx->grid.addBarButton(LVGL_ICON_SHARED_SETTINGS, onShortcutPressed, const_cast<char*>("tactility.settings")); lv_obj_t* modeButton = ctx->grid.addBarButton(getLauncherMode(getOtherMode(ctx->mode)).buttonIcon, onModeButtonPressed, ctx);
ctx->modeButtonIcon = lv_obj_get_child(modeButton, 0);
if (isAppRegistered("tactility.poweroff") && supportsPowerOff()) { if (isAppRegistered("tactility.poweroff") && supportsPowerOff()) {
ctx->grid.addBarButton(LVGL_ICON_SHARED_POWER_SETTINGS_NEW, onShortcutPressed, const_cast<char*>("tactility.poweroff")); ctx->grid.addBarButton(LVGL_ICON_SHARED_POWER_SETTINGS_NEW, onShortcutPressed, const_cast<char*>("tactility.poweroff"));
} }

View File

@ -0,0 +1,78 @@
#include <Tactility/app/launcher/LauncherMode.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <lvgl/icons/shared.h>
#include <algorithm>
#include <cstring>
namespace tt::app::launcher {
namespace {
struct IconEntry {
const char* id;
const char* icon;
};
constexpr IconEntry ICONS[] = {
{"tactility.appearance", LVGL_ICON_SHARED_PALETTE},
{"tactility.apppackagelist", LVGL_ICON_SHARED_DEPLOYED_CODE},
{"tactility.audiosettings", LVGL_ICON_SHARED_MUSIC_NOTE},
{"tactility.btmanage", LVGL_ICON_SHARED_BLUETOOTH},
{"tactility.development", LVGL_ICON_SHARED_LOGO_DEV},
{"tactility.display", LVGL_ICON_SHARED_DISPLAY_SETTINGS},
{"tactility.gpssettings", LVGL_ICON_SHARED_NAVIGATION},
{"tactility.grovesettings", LVGL_ICON_SHARED_CABLE},
{"tactility.keyboardsettings", LVGL_ICON_SHARED_KEYBOARD_ALT},
{"tactility.ledstripsettings", LVGL_ICON_SHARED_LIGHTSTRIP},
{"tactility.localesettings", LVGL_ICON_SHARED_LANGUAGE},
{"tactility.power", LVGL_ICON_SHARED_POWER_SETTINGS_NEW},
{"tactility.timedatesettings", LVGL_ICON_SHARED_CALENDAR_MONTH},
{"tactility.touchcalibration", LVGL_ICON_SHARED_CIRCLE},
{"tactility.trackballsettings", LVGL_ICON_SHARED_DEVICES},
{"tactility.usbsettings", LVGL_ICON_SHARED_USB},
{"tactility.wifimanage", LVGL_ICON_SHARED_WIFI},
};
const char* appIcon(const ::AppManifest* manifest) {
for (const auto& entry : ICONS) {
if (!strcmp(manifest->id, entry.id)) return entry.icon;
}
return LVGL_ICON_SHARED_DEPLOYED_CODE;
}
void collectManifest(const ::AppManifest* manifest, void* context) {
auto* manifests = static_cast<std::vector<::AppManifest>*>(context);
manifests->push_back(*manifest);
}
std::vector<AppGridItem> collectItems() {
std::vector<::AppManifest> collected;
app_manager_for_each_manifest(collectManifest, &collected);
std::ranges::sort(collected, [](const ::AppManifest& a, const ::AppManifest& b) {
return strcmp(a.name, b.name) < 0;
});
std::vector<AppGridItem> items;
for (const auto& manifest : collected) {
if (manifest.category == APP_CATEGORY_SETTINGS && (manifest.flags & APP_MANIFEST_FLAG_HIDDEN) == 0) {
items.push_back({ manifest, appIcon(&manifest), false });
}
}
return items;
}
} // namespace
const LauncherMode SETTINGS_MODE = {
.buttonIcon = LVGL_ICON_SHARED_SETTINGS,
.iconColor = AppGrid::IconColor::Secondary,
.collect = collectItems,
.onLongPressed = nullptr,
.onKey = nullptr
};
}

View File

@ -284,9 +284,12 @@ lv_obj_t* createRow(lv_obj_t* parent, const char* title) {
lv_obj_set_size(row, LV_PCT(100), LV_SIZE_CONTENT); lv_obj_set_size(row, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(row, 2, LV_STATE_DEFAULT); lv_obj_set_style_pad_all(row, 2, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(row, 0, LV_STATE_DEFAULT); lv_obj_set_style_border_width(row, 0, LV_STATE_DEFAULT);
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_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE);
auto* label = lv_label_create(row); auto* label = lv_label_create(row);
lv_label_set_text(label, title); lv_label_set_text(label, title);
lv_obj_align(label, LV_ALIGN_LEFT_MID, 0, 0); lv_obj_set_flex_grow(label, 1);
return row; return row;
} }
@ -331,6 +334,7 @@ void createWidgets(lv_obj_t* parent, void* userData) {
lvgl_toolbar_set_nav_action(ctx->toolbar, LV_SYMBOL_CLOSE, onNavigationPressed, ctx); lvgl_toolbar_set_nav_action(ctx->toolbar, LV_SYMBOL_CLOSE, onNavigationPressed, ctx);
ctx->mainPanel = lv_obj_create(parent); ctx->mainPanel = lv_obj_create(parent);
lv_obj_set_style_border_width(ctx->mainPanel, 0, LV_STATE_DEFAULT);
lv_obj_set_width(ctx->mainPanel, LV_PCT(100)); lv_obj_set_width(ctx->mainPanel, LV_PCT(100));
lv_obj_set_flex_grow(ctx->mainPanel, 1); lv_obj_set_flex_grow(ctx->mainPanel, 1);
lv_obj_set_flex_flow(ctx->mainPanel, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_flow(ctx->mainPanel, LV_FLEX_FLOW_COLUMN);
@ -338,6 +342,7 @@ void createWidgets(lv_obj_t* parent, void* userData) {
lv_obj_add_flag(ctx->mainPanel, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_flag(ctx->mainPanel, LV_OBJ_FLAG_SCROLLABLE);
ctx->customPanel = lv_obj_create(parent); ctx->customPanel = lv_obj_create(parent);
lv_obj_set_style_border_width(ctx->customPanel, 0, LV_STATE_DEFAULT);
lv_obj_set_width(ctx->customPanel, LV_PCT(100)); lv_obj_set_width(ctx->customPanel, LV_PCT(100));
lv_obj_set_flex_grow(ctx->customPanel, 1); lv_obj_set_flex_grow(ctx->customPanel, 1);
lv_obj_set_flex_flow(ctx->customPanel, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_flow(ctx->customPanel, LV_FLEX_FLOW_COLUMN);
@ -346,13 +351,11 @@ void createWidgets(lv_obj_t* parent, void* userData) {
auto* outputRow = createRow(ctx->mainPanel, "Output"); auto* outputRow = createRow(ctx->mainPanel, "Output");
auto* outputSwitch = lv_switch_create(outputRow); auto* outputSwitch = lv_switch_create(outputRow);
if (ctx->settings.enabled) lv_obj_add_state(outputSwitch, LV_STATE_CHECKED); if (ctx->settings.enabled) lv_obj_add_state(outputSwitch, LV_STATE_CHECKED);
lv_obj_align(outputSwitch, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_add_event_cb(outputSwitch, onOutputChanged, LV_EVENT_VALUE_CHANGED, ctx); lv_obj_add_event_cb(outputSwitch, onOutputChanged, LV_EVENT_VALUE_CHANGED, ctx);
auto* modeRow = createRow(ctx->mainPanel, "Mode"); auto* modeRow = createRow(ctx->mainPanel, "Mode");
auto* modeButton = lv_button_create(modeRow); auto* modeButton = lv_button_create(modeRow);
lv_obj_set_width(modeButton, LV_PCT(62)); lv_obj_set_width(modeButton, LV_PCT(62));
lv_obj_align(modeButton, LV_ALIGN_RIGHT_MID, 0, 0);
ctx->modeValue = lv_label_create(modeButton); ctx->modeValue = lv_label_create(modeButton);
lv_label_set_text(ctx->modeValue, patternName(ctx->settings.pattern)); lv_label_set_text(ctx->modeValue, patternName(ctx->settings.pattern));
lv_obj_center(ctx->modeValue); lv_obj_center(ctx->modeValue);
@ -361,7 +364,6 @@ void createWidgets(lv_obj_t* parent, void* userData) {
auto* primaryRow = createRow(ctx->mainPanel, "Color"); auto* primaryRow = createRow(ctx->mainPanel, "Color");
auto* primaryButton = lv_button_create(primaryRow); auto* primaryButton = lv_button_create(primaryRow);
lv_obj_set_width(primaryButton, LV_PCT(62)); lv_obj_set_width(primaryButton, LV_PCT(62));
lv_obj_align(primaryButton, LV_ALIGN_RIGHT_MID, 0, 0);
ctx->primaryValue = lv_label_create(primaryButton); ctx->primaryValue = lv_label_create(primaryButton);
lv_label_set_text(ctx->primaryValue, presetName(ctx->settings.primaryPreset)); lv_label_set_text(ctx->primaryValue, presetName(ctx->settings.primaryPreset));
lv_obj_center(ctx->primaryValue); lv_obj_center(ctx->primaryValue);
@ -373,7 +375,6 @@ void createWidgets(lv_obj_t* parent, void* userData) {
ctx->secondaryRow = createRow(ctx->mainPanel, "Second color"); ctx->secondaryRow = createRow(ctx->mainPanel, "Second color");
auto* secondaryButton = lv_button_create(ctx->secondaryRow); auto* secondaryButton = lv_button_create(ctx->secondaryRow);
lv_obj_set_width(secondaryButton, LV_PCT(62)); lv_obj_set_width(secondaryButton, LV_PCT(62));
lv_obj_align(secondaryButton, LV_ALIGN_RIGHT_MID, 0, 0);
ctx->secondaryValue = lv_label_create(secondaryButton); ctx->secondaryValue = lv_label_create(secondaryButton);
lv_label_set_text(ctx->secondaryValue, presetName(ctx->settings.secondaryPreset)); lv_label_set_text(ctx->secondaryValue, presetName(ctx->settings.secondaryPreset));
lv_obj_center(ctx->secondaryValue); lv_obj_center(ctx->secondaryValue);
@ -385,12 +386,12 @@ void createWidgets(lv_obj_t* parent, void* userData) {
auto* brightnessRow = createRow(ctx->mainPanel, "Brightness"); auto* brightnessRow = createRow(ctx->mainPanel, "Brightness");
ctx->brightnessValue = lv_label_create(brightnessRow); ctx->brightnessValue = lv_label_create(brightnessRow);
lv_label_set_text_fmt(ctx->brightnessValue, "%u%%", (static_cast<unsigned>(ctx->settings.brightness) * 100 + 127) / 255); lv_label_set_text_fmt(ctx->brightnessValue, "%u%%", (static_cast<unsigned>(ctx->settings.brightness) * 100 + 127) / 255);
lv_obj_align(ctx->brightnessValue, LV_ALIGN_RIGHT_MID, 0, 0);
ctx->brightnessSlider = lvgl_sliderbox_create(ctx->mainPanel, 0, UINT8_MAX, 17, ctx->settings.brightness); ctx->brightnessSlider = lvgl_sliderbox_create(ctx->mainPanel, 0, UINT8_MAX, 17, ctx->settings.brightness);
lv_obj_set_width(ctx->brightnessSlider, LV_PCT(100)); lv_obj_set_width(ctx->brightnessSlider, LV_PCT(100));
lvgl_sliderbox_add_value_changed_cb(ctx->brightnessSlider, onBrightnessChanged, ctx); lvgl_sliderbox_add_value_changed_cb(ctx->brightnessSlider, onBrightnessChanged, ctx);
ctx->choicePanel = lv_obj_create(parent); ctx->choicePanel = lv_obj_create(parent);
lv_obj_set_style_border_width(ctx->choicePanel, 0, LV_STATE_DEFAULT);
lv_obj_set_width(ctx->choicePanel, LV_PCT(100)); lv_obj_set_width(ctx->choicePanel, LV_PCT(100));
lv_obj_set_flex_grow(ctx->choicePanel, 1); lv_obj_set_flex_grow(ctx->choicePanel, 1);
lv_obj_set_flex_flow(ctx->choicePanel, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_flow(ctx->choicePanel, LV_FLEX_FLOW_COLUMN);

View File

@ -1,8 +1,8 @@
#include <Tactility/Tactility.h> #include <Tactility/Tactility.h>
#include <Tactility/RecursiveMutex.h> #include <Tactility/RecursiveMutex.h>
#include <Tactility/StringUtils.h>
#include <Tactility/app/localesettings/TextResources.h> #include <Tactility/app/localesettings/TextResources.h>
#include <Tactility/app/selectiondialog/SelectionDialog.h>
#include <Tactility/settings/Language.h> #include <Tactility/settings/Language.h>
#include <Tactility/settings/SystemSettings.h> #include <Tactility/settings/SystemSettings.h>
@ -14,11 +14,17 @@
#include <lvgl_window_manager/window_manager.h> #include <lvgl_window_manager/window_manager.h>
#include <tactility/check.h> #include <tactility/check.h>
#include <tactility/concurrent/task_event_group.h>
#include <lvgl/widgets/card.h>
#include <lvgl/widgets/toolbar.h> #include <lvgl/widgets/toolbar.h>
#include <lvgl.h> #include <lvgl.h>
#include <lvgl/fonts.h>
#include <lvgl/lvgl.h>
#include <map> #include <map>
#include <string>
#include <vector>
namespace tt::app::localesettings { namespace tt::app::localesettings {
@ -38,14 +44,19 @@ struct Context {
uint32_t appInstanceId; uint32_t appInstanceId;
tt::i18n::TextResources textResources = tt::i18n::TextResources(TEXT_RESOURCE_PATH); tt::i18n::TextResources textResources = tt::i18n::TextResources(TEXT_RESOURCE_PATH);
RecursiveMutex mutex; RecursiveMutex mutex;
lv_obj_t* languageDropdown = nullptr; TaskEventGroup* eventGroup = nullptr;
uint32_t selectLanguageBit = 0;
uint32_t languageSelectLaunchId = 0;
// Valid while the window is shown
lv_obj_t* languageLabel = nullptr;
lv_obj_t* languageValueLabel = nullptr;
bool settingsUpdated = false; bool settingsUpdated = false;
std::map<settings::Language, std::string> languageMap; std::map<settings::Language, std::string> languageMap;
}; };
std::string getLanguageOptions(Context* ctx) { std::vector<std::string> getLanguageNames(Context* ctx) {
std::vector<std::string> items; std::vector<std::string> items;
for (int i = 0; i < static_cast<int>(settings::Language::count); i++) { for (int i = 0; i < static_cast<int>(settings::Language::count); i++) {
switch (static_cast<settings::Language>(i)) { switch (static_cast<settings::Language>(i)) {
@ -68,25 +79,44 @@ std::string getLanguageOptions(Context* ctx) {
break; break;
} }
} }
return string::join(items, "\n"); return items;
} }
/** Requires the LVGL lock */
void updateViews(Context* ctx) { void updateViews(Context* ctx) {
ctx->textResources.load(); if (ctx->languageLabel == nullptr) {
return;
std::string language_options = getLanguageOptions(ctx); }
lv_dropdown_set_options(ctx->languageDropdown, language_options.c_str()); lv_label_set_text(ctx->languageLabel, ctx->textResources[i18n::Text::LANGUAGE].c_str());
lv_dropdown_set_selected(ctx->languageDropdown, static_cast<uint32_t>(settings::getLanguage())); const auto names = getLanguageNames(ctx);
const auto index = static_cast<size_t>(settings::getLanguage());
lv_label_set_text(ctx->languageValueLabel, index < names.size() ? names[index].c_str() : "");
} }
void onLanguageSet(lv_event_t* event) { void onSelectLanguagePressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event)); auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
auto* dropdown = static_cast<lv_obj_t*>(lv_event_get_target(event)); task_event_group_signal(ctx->eventGroup, ctx->selectLanguageBit);
auto index = lv_dropdown_get_selected(dropdown); }
auto language = static_cast<settings::Language>(index);
settings::setLanguage(language);
updateViews(ctx); void startLanguageSelection(Context* ctx) {
if (ctx->languageSelectLaunchId != 0) {
return;
}
ctx->languageSelectLaunchId = selectiondialog::start(ctx->appInstanceId, ctx->textResources[i18n::Text::LANGUAGE], getLanguageNames(ctx));
}
void onLanguageSelectionResult(Context* ctx, const AppEvent& event) {
ctx->languageSelectLaunchId = 0;
// Other results mean that the dialog was dismissed
const int32_t selected = event.result.result;
if (selected >= 0 && selected < static_cast<int32_t>(settings::Language::count)) {
settings::setLanguage(static_cast<settings::Language>(selected));
ctx->textResources.load();
lvgl_lock();
updateViews(ctx);
lvgl_unlock();
}
app_manager_stop(event.result.launch_id);
} }
// Preserved from the pre-conversion code as-is: declared but never wired to any widget there // Preserved from the pre-conversion code as-is: declared but never wired to any widget there
@ -114,29 +144,57 @@ void createWidgets(lv_obj_t* parent, void* userData) {
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
auto* main_wrapper = lv_obj_create(parent); auto* main_wrapper = lv_obj_create(parent);
lv_obj_set_style_border_width(main_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_set_width(main_wrapper, LV_PCT(100)); lv_obj_set_width(main_wrapper, LV_PCT(100));
lv_obj_set_flex_grow(main_wrapper, 1); lv_obj_set_flex_grow(main_wrapper, 1);
// Language // "Language English [Select]"
auto* card = lvgl_card_create(main_wrapper);
lv_obj_set_size(card, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN);
auto* language_wrapper = lv_obj_create(main_wrapper); // Transparent, so the card provides the background
lv_obj_set_width(language_wrapper, LV_PCT(100)); auto* language_row = lv_obj_create(card);
lv_obj_set_height(language_wrapper, LV_SIZE_CONTENT); lv_obj_set_size(language_row, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(language_wrapper, 8, 0); lv_obj_set_style_pad_all(language_row, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(language_wrapper, 0, 0); lv_obj_set_style_bg_opa(language_row, LV_OPA_TRANSP, LV_STATE_DEFAULT);
lv_obj_set_flex_flow(language_row, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(language_row, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_border_width(language_row, 0, LV_STATE_DEFAULT);
lv_obj_remove_flag(language_row, LV_OBJ_FLAG_SCROLLABLE);
auto* languageLabel = lv_label_create(language_wrapper); // The title with the language below it, which take the width that the button leaves. Texts that don't fit scroll.
lv_label_set_text(languageLabel, ctx->textResources[i18n::Text::LANGUAGE].c_str()); auto* texts = lv_obj_create(language_row);
lv_obj_align(languageLabel, LV_ALIGN_LEFT_MID, 4, 0); lv_obj_set_height(texts, LV_SIZE_CONTENT);
lv_obj_set_flex_grow(texts, 1);
lv_obj_set_flex_flow(texts, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_all(texts, 0, LV_STATE_DEFAULT);
lv_obj_set_style_pad_row(texts, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(texts, 0, LV_STATE_DEFAULT);
lv_obj_set_style_bg_opa(texts, LV_OPA_TRANSP, LV_STATE_DEFAULT);
lv_obj_remove_flag(texts, LV_OBJ_FLAG_SCROLLABLE);
ctx->languageDropdown = lv_dropdown_create(language_wrapper); ctx->languageLabel = lv_label_create(texts);
lv_obj_set_width(ctx->languageDropdown, 150); lv_obj_set_width(ctx->languageLabel, LV_PCT(100));
lv_obj_align(ctx->languageDropdown, LV_ALIGN_RIGHT_MID, 0, 0); lv_label_set_long_mode(ctx->languageLabel, LV_LABEL_LONG_MODE_SCROLL_CIRCULAR);
std::string language_options = getLanguageOptions(ctx);
lv_dropdown_set_options(ctx->languageDropdown, language_options.c_str()); ctx->languageValueLabel = lv_label_create(texts);
lv_dropdown_set_selected(ctx->languageDropdown, static_cast<uint32_t>(settings::getLanguage())); lv_obj_set_width(ctx->languageValueLabel, LV_PCT(100));
lv_obj_add_event_cb(ctx->languageDropdown, onLanguageSet, LV_EVENT_VALUE_CHANGED, ctx); lv_label_set_long_mode(ctx->languageValueLabel, LV_LABEL_LONG_MODE_SCROLL_CIRCULAR);
lv_obj_set_style_text_font(ctx->languageValueLabel, lvgl_get_text_font(FONT_SIZE_SMALL), LV_STATE_DEFAULT);
auto* select_button = lv_button_create(language_row);
lv_label_set_text(lv_label_create(select_button), "Change");
lv_obj_add_event_cb(select_button, onSelectLanguagePressed, LV_EVENT_SHORT_CLICKED, ctx);
updateViews(ctx);
}
void destroyWidgets(void* userData) {
auto* ctx = static_cast<Context*>(userData);
ctx->languageLabel = nullptr;
ctx->languageValueLabel = nullptr;
} }
int32_t appMain(int argc, char* argv[]) { int32_t appMain(int argc, char* argv[]) {
@ -146,26 +204,31 @@ int32_t appMain(int argc, char* argv[]) {
TaskEventGroup event_group {}; TaskEventGroup event_group {};
task_event_group_construct(&event_group); task_event_group_construct(&event_group);
ctx.eventGroup = &event_group;
check(task_event_group_claim_bit(&event_group, &ctx.selectLanguageBit) == ERROR_NONE);
AppEventSubscription sub {}; AppEventSubscription sub {};
check(app_event_subscribe(&sub, &event_group) == ERROR_NONE); check(app_event_subscribe(&sub, &event_group) == ERROR_NONE);
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx); WindowId window = window_manager_create_ext(appInstanceId, createWidgets, destroyWidgets, &ctx);
bool shouldClose = false; bool shouldClose = false;
while (!shouldClose) { while (!shouldClose) {
task_event_group_wait_any(&event_group, nullptr, portMAX_DELAY); uint32_t flags = 0;
task_event_group_wait_any(&event_group, &flags, portMAX_DELAY);
if (flags & ctx.selectLanguageBit) {
startLanguageSelection(&ctx);
}
AppEvent event {}; AppEvent event {};
while (app_event_poll(&sub, &event) == ERROR_NONE) { while (app_event_poll(&sub, &event) == ERROR_NONE) {
switch (event.type) { if (event.type == APP_EVENT_CLOSE) {
case APP_EVENT_CLOSE: shouldClose = true;
shouldClose = true; break;
break; } else if (event.type == APP_EVENT_RESULT && event.result.launch_id == ctx.languageSelectLaunchId) {
default: onLanguageSelectionResult(&ctx, event);
break;
} }
if (shouldClose) break;
} }
} }

View File

@ -181,18 +181,20 @@ void createWidgets(lv_obj_t* parent, void* userData) {
lv_obj_t* switch_container = lv_obj_create(card); lv_obj_t* switch_container = lv_obj_create(card);
lv_obj_set_width(switch_container, LV_PCT(100)); lv_obj_set_width(switch_container, LV_PCT(100));
lv_obj_set_height(switch_container, LV_SIZE_CONTENT); lv_obj_set_height(switch_container, LV_SIZE_CONTENT);
lv_obj_set_flex_flow(switch_container, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(switch_container, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_remove_flag(switch_container, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_style_pad_all(switch_container, 0, 0); lv_obj_set_style_pad_all(switch_container, 0, 0);
lv_obj_set_style_pad_gap(switch_container, 0, 0); lv_obj_set_style_pad_gap(switch_container, 0, 0);
lvgl::obj_set_style_bg_invisible(switch_container); lvgl::obj_set_style_bg_invisible(switch_container);
lv_obj_t* label = lv_label_create(switch_container); lv_obj_t* label = lv_label_create(switch_container);
lv_label_set_text(label, "Charging enabled"); lv_label_set_text(label, "Charging enabled");
lv_obj_set_align(label, LV_ALIGN_LEFT_MID); lv_obj_set_flex_grow(label, 1);
lv_obj_t* enable_switch = lv_switch_create(switch_container); lv_obj_t* enable_switch = lv_switch_create(switch_container);
lv_obj_set_user_data(enable_switch, device); lv_obj_set_user_data(enable_switch, device);
lv_obj_add_event_cb(enable_switch, onPowerEnabledChanged, LV_EVENT_VALUE_CHANGED, ctx); lv_obj_add_event_cb(enable_switch, onPowerEnabledChanged, LV_EVENT_VALUE_CHANGED, ctx);
lv_obj_set_align(enable_switch, LV_ALIGN_RIGHT_MID);
lv_obj_set_state(enable_switch, LV_STATE_CHECKED, power_supply_is_allowed_to_charge(device)); lv_obj_set_state(enable_switch, LV_STATE_CHECKED, power_supply_is_allowed_to_charge(device));
entry.enableSwitch = enable_switch; entry.enableSwitch = enable_switch;
} }
@ -201,18 +203,20 @@ void createWidgets(lv_obj_t* parent, void* userData) {
lv_obj_t* qc_container = lv_obj_create(card); lv_obj_t* qc_container = lv_obj_create(card);
lv_obj_set_width(qc_container, LV_PCT(100)); lv_obj_set_width(qc_container, LV_PCT(100));
lv_obj_set_height(qc_container, LV_SIZE_CONTENT); lv_obj_set_height(qc_container, LV_SIZE_CONTENT);
lv_obj_set_flex_flow(qc_container, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(qc_container, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_remove_flag(qc_container, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_style_pad_all(qc_container, 0, 0); lv_obj_set_style_pad_all(qc_container, 0, 0);
lv_obj_set_style_pad_gap(qc_container, 0, 0); lv_obj_set_style_pad_gap(qc_container, 0, 0);
lvgl::obj_set_style_bg_invisible(qc_container); lvgl::obj_set_style_bg_invisible(qc_container);
lv_obj_t* label = lv_label_create(qc_container); lv_obj_t* label = lv_label_create(qc_container);
lv_label_set_text(label, "Quick charge"); lv_label_set_text(label, "Quick charge");
lv_obj_set_align(label, LV_ALIGN_LEFT_MID); lv_obj_set_flex_grow(label, 1);
lv_obj_t* qc_switch = lv_switch_create(qc_container); lv_obj_t* qc_switch = lv_switch_create(qc_container);
lv_obj_set_user_data(qc_switch, device); lv_obj_set_user_data(qc_switch, device);
lv_obj_add_event_cb(qc_switch, onQuickChargeChanged, LV_EVENT_VALUE_CHANGED, ctx); lv_obj_add_event_cb(qc_switch, onQuickChargeChanged, LV_EVENT_VALUE_CHANGED, ctx);
lv_obj_set_align(qc_switch, LV_ALIGN_RIGHT_MID);
lv_obj_set_state(qc_switch, LV_STATE_CHECKED, power_supply_is_quick_charge_enabled(device)); lv_obj_set_state(qc_switch, LV_STATE_CHECKED, power_supply_is_quick_charge_enabled(device));
entry.quickChargeSwitch = qc_switch; entry.quickChargeSwitch = qc_switch;
} }

View File

@ -21,6 +21,9 @@
#include <lvgl.h> #include <lvgl.h>
#include <lvgl/lvgl.h> #include <lvgl/lvgl.h>
#include <lvgl/grid_navigation.h>
#include <lvgl/widgets/card.h>
#include <lvgl/widgets/chip.h>
#include <lvgl/widgets/toolbar.h> #include <lvgl/widgets/toolbar.h>
namespace tt::app::screenshot { namespace tt::app::screenshot {
@ -31,12 +34,29 @@ extern const ::AppManifest manifest;
namespace { namespace {
enum class CaptureMode {
Timer,
AppStart
};
struct ModeOption {
CaptureMode mode;
const char* name;
};
constexpr ModeOption MODE_OPTIONS[] = {
{ CaptureMode::Timer, "Timer" },
{ CaptureMode::AppStart, "App start" },
};
struct Context { struct Context {
uint32_t appInstanceId; uint32_t appInstanceId;
lv_obj_t* modeDropdown = nullptr; CaptureMode mode = CaptureMode::Timer;
lv_obj_t* modeChips = nullptr;
lv_obj_t* pathTextArea = nullptr; lv_obj_t* pathTextArea = nullptr;
lv_obj_t* startStopButtonLabel = nullptr; lv_obj_t* startStopButtonLabel = nullptr;
lv_obj_t* timerWrapper = nullptr; // Shown in timer mode
lv_obj_t* delayRow = nullptr;
lv_obj_t* delayTextArea = nullptr; lv_obj_t* delayTextArea = nullptr;
std::unique_ptr<Timer> updateTimer; std::unique_ptr<Timer> updateTimer;
}; };
@ -56,12 +76,11 @@ void updateScreenshotMode(Context* ctx) {
lv_label_set_text(label, "Start"); lv_label_set_text(label, "Start");
} }
uint32_t selected = lv_dropdown_get_selected(ctx->modeDropdown); const uint32_t chip_count = lv_obj_get_child_count(ctx->modeChips);
if (selected == 0) { // Timer for (uint32_t i = 0; i < chip_count; i++) {
lv_obj_remove_flag(ctx->timerWrapper, LV_OBJ_FLAG_HIDDEN); lv_obj_set_state(lv_obj_get_child(ctx->modeChips, static_cast<int32_t>(i)), LV_STATE_CHECKED, MODE_OPTIONS[i].mode == ctx->mode);
} else {
lv_obj_add_flag(ctx->timerWrapper, LV_OBJ_FLAG_HIDDEN);
} }
lv_obj_set_flag(ctx->delayRow, LV_OBJ_FLAG_HIDDEN, ctx->mode != CaptureMode::Timer);
} }
void onBackPressed(lv_event_t* event) { void onBackPressed(lv_event_t* event) {
@ -85,7 +104,6 @@ void onStartPressed(lv_event_t* event) {
return; return;
} }
uint32_t selected = lv_dropdown_get_selected(ctx->modeDropdown);
const char* path = lv_textarea_get_text(ctx->pathTextArea); const char* path = lv_textarea_get_text(ctx->pathTextArea);
error_t result = directory_make(path, true); error_t result = directory_make(path, true);
@ -94,7 +112,7 @@ void onStartPressed(lv_event_t* event) {
return; return;
} }
if (selected == 0) { if (ctx->mode == CaptureMode::Timer) {
LOG_I(TAG, "Start timed screenshots"); LOG_I(TAG, "Start timed screenshots");
const char* delay_text = lv_textarea_get_text(ctx->delayTextArea); const char* delay_text = lv_textarea_get_text(ctx->delayTextArea);
int delay = atoi(delay_text); int delay = atoi(delay_text);
@ -111,59 +129,62 @@ void onStartPressed(lv_event_t* event) {
updateScreenshotMode(ctx); updateScreenshotMode(ctx);
} }
void onModeSet(lv_event_t* event) { void onModeChipPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event)); auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
ctx->mode = MODE_OPTIONS[lv_obj_get_index(lv_event_get_target_obj(event))].mode;
updateScreenshotMode(ctx); updateScreenshotMode(ctx);
} }
void createModeSettingWidgets(Context* ctx, lv_obj_t* parent) { /** A transparent row in a card: "Title [content]" */
auto service = service::screenshot::optScreenshotService(); lv_obj_t* createRow(lv_obj_t* card, const char* title) {
if (service == nullptr) { auto* row = lv_obj_create(card);
LOG_E(TAG, "Service not found/running"); lv_obj_set_size(row, LV_PCT(100), LV_SIZE_CONTENT);
return; 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_all(row, 0, LV_STATE_DEFAULT);
auto* mode_wrapper = lv_obj_create(parent); lv_obj_set_style_border_width(row, 0, LV_STATE_DEFAULT);
lv_obj_set_size(mode_wrapper, LV_PCT(100), LV_SIZE_CONTENT); lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, LV_STATE_DEFAULT);
lv_obj_set_style_pad_all(mode_wrapper, 0, 0); lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_style_border_width(mode_wrapper, 0, 0); lv_label_set_text(lv_label_create(row), title);
return row;
auto* mode_label = lv_label_create(mode_wrapper);
lv_label_set_text(mode_label, "Mode:");
lv_obj_align(mode_label, LV_ALIGN_LEFT_MID, 0, 0);
ctx->modeDropdown = lv_dropdown_create(mode_wrapper);
lv_dropdown_set_options(ctx->modeDropdown, "Timer\nApp start");
lv_obj_align_to(ctx->modeDropdown, mode_label, LV_ALIGN_OUT_RIGHT_MID, 8, 0);
lv_obj_add_event_cb(ctx->modeDropdown, onModeSet, LV_EVENT_VALUE_CHANGED, ctx);
service::screenshot::Mode mode = service->getMode();
if (mode == service::screenshot::Mode::Apps) {
lv_dropdown_set_selected(ctx->modeDropdown, 1);
}
auto* button = lv_button_create(mode_wrapper);
lv_obj_align(button, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_add_event_cb(button, onStartPressed, LV_EVENT_SHORT_CLICKED, ctx);
ctx->startStopButtonLabel = lv_label_create(button);
lv_obj_align(ctx->startStopButtonLabel, LV_ALIGN_CENTER, 0, 0);
} }
void createFilePathWidgets(Context* ctx, lv_obj_t* parent) { void createModeWidgets(Context* ctx, lv_obj_t* parent) {
auto* path_wrapper = lv_obj_create(parent); auto* title = lv_label_create(parent);
lv_obj_set_size(path_wrapper, LV_PCT(100), LV_SIZE_CONTENT); lv_label_set_text(title, "Mode");
lv_obj_set_style_pad_all(path_wrapper, 0, 0); lv_obj_set_width(title, LV_PCT(100));
lv_obj_set_style_border_width(path_wrapper, 0, 0);
lv_obj_set_flex_flow(path_wrapper, LV_FLEX_FLOW_ROW);
auto* label_wrapper = lv_obj_create(path_wrapper); // The chips are centered in the card while they fit, and scroll when they don't
lv_obj_set_style_border_width(label_wrapper, 0, 0); auto* card = lvgl_card_create(parent);
lv_obj_set_style_pad_all(label_wrapper, 0, 0); lv_obj_set_size(card, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_size(label_wrapper, 44, 36); lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN);
auto* path_label = lv_label_create(label_wrapper); lv_obj_set_flex_align(card, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_label_set_text(path_label, "Path:");
lv_obj_align(path_label, LV_ALIGN_LEFT_MID, 0, 0);
ctx->pathTextArea = lv_textarea_create(path_wrapper); ctx->modeChips = lv_obj_create(card);
lv_obj_set_size(ctx->modeChips, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
lv_obj_set_style_max_width(ctx->modeChips, LV_PCT(100), LV_STATE_DEFAULT);
lv_obj_set_flex_flow(ctx->modeChips, LV_FLEX_FLOW_ROW);
lv_obj_set_scroll_dir(ctx->modeChips, LV_DIR_HOR);
lv_obj_set_style_bg_opa(ctx->modeChips, LV_OPA_TRANSP, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(ctx->modeChips, 0, LV_STATE_DEFAULT);
// The chips' margins leave room for their focus rings and space them apart
lv_obj_set_style_pad_all(ctx->modeChips, 0, LV_STATE_DEFAULT);
lv_obj_set_style_pad_column(ctx->modeChips, 0, LV_STATE_DEFAULT);
for (const auto& option : MODE_OPTIONS) {
auto* chip = lvgl_chip_create(ctx->modeChips);
lv_label_set_text(lv_label_create(chip), option.name);
lv_obj_add_event_cb(chip, onModeChipPressed, LV_EVENT_SHORT_CLICKED, ctx);
}
lvgl_grid_navigation_add(ctx->modeChips);
}
void createSettingsWidgets(Context* ctx, lv_obj_t* parent) {
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);
auto* path_row = createRow(card, "Path");
ctx->pathTextArea = lv_textarea_create(path_row);
lv_textarea_set_one_line(ctx->pathTextArea, true); lv_textarea_set_one_line(ctx->pathTextArea, true);
lv_obj_set_flex_grow(ctx->pathTextArea, 1); lv_obj_set_flex_grow(ctx->pathTextArea, 1);
char data_path[FILE_MAX_PATH_STRING_LENGTH]; char data_path[FILE_MAX_PATH_STRING_LENGTH];
@ -173,41 +194,20 @@ void createFilePathWidgets(Context* ctx, lv_obj_t* parent) {
} else { } else {
lv_textarea_set_text(ctx->pathTextArea, "Error: no data path"); lv_textarea_set_text(ctx->pathTextArea, "Error: no data path");
} }
}
void createTimerSettingsWidgets(Context* ctx, lv_obj_t* parent) { ctx->delayRow = createRow(card, "Delay");
ctx->timerWrapper = lv_obj_create(parent); ctx->delayTextArea = lv_textarea_create(ctx->delayRow);
lv_obj_set_size(ctx->timerWrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(ctx->timerWrapper, 0, 0);
lv_obj_set_style_border_width(ctx->timerWrapper, 0, 0);
auto* delay_wrapper = lv_obj_create(ctx->timerWrapper);
lv_obj_set_size(delay_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(delay_wrapper, 0, 0);
lv_obj_set_style_border_width(delay_wrapper, 0, 0);
lv_obj_set_flex_flow(delay_wrapper, LV_FLEX_FLOW_ROW);
auto* delay_label_wrapper = lv_obj_create(delay_wrapper);
lv_obj_set_style_border_width(delay_label_wrapper, 0, 0);
lv_obj_set_style_pad_all(delay_label_wrapper, 0, 0);
lv_obj_set_size(delay_label_wrapper, 44, 36);
auto* delay_label = lv_label_create(delay_label_wrapper);
lv_label_set_text(delay_label, "Delay:");
lv_obj_align(delay_label, LV_ALIGN_LEFT_MID, 0, 0);
ctx->delayTextArea = lv_textarea_create(delay_wrapper);
lv_textarea_set_one_line(ctx->delayTextArea, true); lv_textarea_set_one_line(ctx->delayTextArea, true);
lv_textarea_set_accepted_chars(ctx->delayTextArea, "0123456789"); lv_textarea_set_accepted_chars(ctx->delayTextArea, "0123456789");
lv_textarea_set_text(ctx->delayTextArea, "10"); lv_textarea_set_text(ctx->delayTextArea, "10");
lv_obj_set_flex_grow(ctx->delayTextArea, 1); lv_obj_set_flex_grow(ctx->delayTextArea, 1);
lv_label_set_text(lv_label_create(ctx->delayRow), "seconds");
}
auto* delay_unit_label_wrapper = lv_obj_create(delay_wrapper); void createStartStopButton(Context* ctx, lv_obj_t* parent) {
lv_obj_set_style_border_width(delay_unit_label_wrapper, 0, 0); auto* button = lv_button_create(parent);
lv_obj_set_style_pad_all(delay_unit_label_wrapper, 0, 0); lv_obj_add_event_cb(button, onStartPressed, LV_EVENT_SHORT_CLICKED, ctx);
lv_obj_set_size(delay_unit_label_wrapper, LV_SIZE_CONTENT, 36); ctx->startStopButtonLabel = lv_label_create(button);
auto* delay_unit_label = lv_label_create(delay_unit_label_wrapper);
lv_obj_align(delay_unit_label, LV_ALIGN_LEFT_MID, 0, 0);
lv_label_set_text(delay_unit_label, "seconds");
} }
void createWidgets(lv_obj_t* parent, void* userData) { void createWidgets(lv_obj_t* parent, void* userData) {
@ -230,10 +230,17 @@ void createWidgets(lv_obj_t* parent, void* userData) {
lv_obj_set_flex_grow(wrapper, 1); lv_obj_set_flex_grow(wrapper, 1);
lv_obj_set_style_border_width(wrapper, 0, 0); lv_obj_set_style_border_width(wrapper, 0, 0);
lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN);
// The cards stretch, the start/stop button is centered
lv_obj_set_flex_align(wrapper, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START);
createModeSettingWidgets(ctx, wrapper); auto service = service::screenshot::optScreenshotService();
createFilePathWidgets(ctx, wrapper); if (service != nullptr && service->getMode() == service::screenshot::Mode::Apps) {
createTimerSettingsWidgets(ctx, wrapper); ctx->mode = CaptureMode::AppStart;
}
createModeWidgets(ctx, wrapper);
createSettingsWidgets(ctx, wrapper);
createStartStopButton(ctx, wrapper);
updateScreenshotMode(ctx); updateScreenshotMode(ctx);

View File

@ -1,163 +0,0 @@
#include <app/event.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <app/scheduler.h>
#include <app/start.h>
#include <lvgl_window_manager/window_manager.h>
#include <Tactility/app/AppGrid.h>
#include <lvgl/icons/shared.h>
#include <lvgl/theme.h>
#include <tactility/check.h>
#include <lvgl.h>
#include <algorithm>
#include <cstring>
#include <vector>
namespace tt::app::settings {
namespace {
struct IconEntry {
const char* id;
const char* icon;
};
constexpr IconEntry ICONS[] = {
{"tactility.appearance", LVGL_ICON_SHARED_PALETTE},
{"tactility.apppackagelist", LVGL_ICON_SHARED_DEPLOYED_CODE},
{"tactility.audiosettings", LVGL_ICON_SHARED_MUSIC_NOTE},
{"tactility.btmanage", LVGL_ICON_SHARED_BLUETOOTH},
{"tactility.development", LVGL_ICON_SHARED_LOGO_DEV},
{"tactility.display", LVGL_ICON_SHARED_DISPLAY_SETTINGS},
{"tactility.gpssettings", LVGL_ICON_SHARED_NAVIGATION},
{"tactility.grovesettings", LVGL_ICON_SHARED_CABLE},
{"tactility.keyboardsettings", LVGL_ICON_SHARED_KEYBOARD_ALT},
{"tactility.ledstripsettings", LVGL_ICON_SHARED_LIGHTSTRIP},
{"tactility.localesettings", LVGL_ICON_SHARED_LANGUAGE},
{"tactility.power", LVGL_ICON_SHARED_POWER_SETTINGS_NEW},
{"tactility.timedatesettings", LVGL_ICON_SHARED_CALENDAR_MONTH},
{"tactility.touchcalibration", LVGL_ICON_SHARED_CIRCLE},
{"tactility.trackballsettings", LVGL_ICON_SHARED_DEVICES},
{"tactility.usbsettings", LVGL_ICON_SHARED_USB},
{"tactility.wifimanage", LVGL_ICON_SHARED_WIFI},
};
const char* appIcon(const ::AppManifest* manifest) {
for (const auto& entry : ICONS) {
if (!strcmp(manifest->id, entry.id)) return entry.icon;
}
return LVGL_ICON_SHARED_DEPLOYED_CODE;
}
void onAppClicked(const ::AppManifest& manifest, void*) {
// Fire-and-forget top-level navigation, same as the Launcher's own app-launch buttons.
uint32_t instanceId = 0;
// Re-resolved by id against the live ledger, not the (possibly stale) cached manifest above:
// fails gracefully if the app was uninstalled since this button was built, instead of handing
// app_manager_start_internal() a pointer it would store for the new instance's whole lifetime.
AppStartContext context;
if (app_start_context_from_id(manifest.id, &context) == ERROR_NONE) {
app_start_with_context(&context, &instanceId);
}
}
void collectManifest(const ::AppManifest* manifest, void* context) {
auto* manifests = static_cast<std::vector<::AppManifest>*>(context);
manifests->push_back(*manifest);
}
std::vector<AppGridItem> collectItems(void*) {
std::vector<::AppManifest> collected;
app_manager_for_each_manifest(collectManifest, &collected);
std::ranges::sort(collected, [](const ::AppManifest& a, const ::AppManifest& b) {
return strcmp(a.name, b.name) < 0;
});
std::vector<AppGridItem> items;
for (const auto& manifest : collected) {
if (manifest.category == APP_CATEGORY_SETTINGS && (manifest.flags & APP_MANIFEST_FLAG_HIDDEN) == 0) {
items.push_back({ manifest, appIcon(&manifest), false });
}
}
return items;
}
struct Context {
uint32_t appInstanceId;
AppGrid grid { AppGrid::Callbacks {
.collect = collectItems,
.onClicked = onAppClicked,
.onLongPressed = nullptr,
.onKey = nullptr,
.userData = nullptr
} };
};
void onBackPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
app_event_emit_close(ctx->appInstanceId);
}
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);
if (!lvgl_theme_is_mono()) {
ctx->grid.setIconColor(AppGrid::IconColor::Secondary);
}
ctx->grid.setSwipeNavigation(true);
ctx->grid.createWidgetsWithBottomBar(parent);
ctx->grid.addBarButton(LVGL_ICON_SHARED_CLOSE, onBackPressed, ctx);
}
int32_t appMain(int argc, char* argv[]) {
uint32_t appInstanceId = app_scheduler_current_app_id();
Context ctx { appInstanceId };
TaskEventGroup event_group {};
task_event_group_construct(&event_group);
AppEventSubscription sub {};
check(app_event_subscribe(&sub, &event_group) == ERROR_NONE);
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
while (true) {
task_event_group_wait_any(&event_group, nullptr, portMAX_DELAY);
bool shouldClose = false;
AppEvent event {};
while (app_event_poll(&sub, &event) == ERROR_NONE) {
if (event.type == APP_EVENT_CLOSE) {
shouldClose = true;
break;
}
}
if (shouldClose) break;
}
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.settings",
.name = "Settings",
.category = APP_CATEGORY_SYSTEM,
.location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast<void*>(appMain) },
.flags = APP_MANIFEST_FLAG_HIDDEN,
.stack = { .depth = 5120, .desired_memory_capability = 0 },
};
} // namespace

View File

@ -1,8 +1,11 @@
#include <tactility/check.h> #include "lvgl/icons/shared.h"
#include "lvgl/theme.h"
#include "tactility/time.h" #include "tactility/time.h"
#include <tactility/check.h>
#include <Tactility/DeprecatedPaths.h> #include <Tactility/DeprecatedPaths.h>
#include <Tactility/Tactility.h>
#include <Tactility/TactilityConfig.h> #include <Tactility/TactilityConfig.h>
#include <Tactility/Timer.h> #include <Tactility/Timer.h>
@ -14,11 +17,14 @@
#include <lvgl_window_manager/window_manager.h> #include <lvgl_window_manager/window_manager.h>
#include <algorithm> #include <algorithm>
#include <cstring> #include <strings.h>
#include <format> #include <format>
#include <utility> #include <utility>
#include <vector>
#include <string>
#include <lvgl/fonts.h> #include <lvgl/fonts.h>
#include <lvgl/grid_navigation.h>
#include <lvgl/lvgl.h> #include <lvgl/lvgl.h>
#include <lvgl/widgets/toolbar.h> #include <lvgl/widgets/toolbar.h>
@ -197,40 +203,81 @@ const char* getTaskState(const TaskStatus_t& task) {
} }
} }
void clearContainer(lv_obj_t* container) { const char* getTaskName(const TaskStatus_t& task) {
lv_obj_clean(container); return (task.pcTaskName == nullptr || task.pcTaskName[0] == 0) ? "(unnamed)" : task.pcTaskName;
} }
void addRtosTask(lv_obj_t* parent, const TaskStatus_t& task) { /**
auto* label = lv_label_create(parent); * Updates the rows in place, so the list keeps its height and scroll position, and the selected task stays selected.
const char* name = (task.pcTaskName == nullptr || task.pcTaskName[0] == 0) ? "(unnamed)" : task.pcTaskName; * @param[in] parent the list
lv_label_set_text_fmt(label, "%s (%s)", name, getTaskState(task)); * @param[in,out] taskNames the task name of each row
} */
void updateRtosTasks(lv_obj_t* parent, std::vector<std::string>& taskNames) {
void updateRtosTasks(lv_obj_t* parent) { lv_obj_t* selected = lvgl_grid_navigation_get_focused(parent);
clearContainer(parent); const int32_t selected_index = selected != nullptr ? lv_obj_get_index(selected) : -1;
const std::string selected_name = selected_index >= 0 && selected_index < static_cast<int32_t>(taskNames.size()) ? taskNames[selected_index] : "";
const bool show_selection = selected != nullptr && lv_obj_has_state(selected, LV_STATE_FOCUS_KEY);
UBaseType_t count = uxTaskGetNumberOfTasks(); UBaseType_t count = uxTaskGetNumberOfTasks();
auto* tasks = (TaskStatus_t*)malloc(sizeof(TaskStatus_t) * count); auto* tasks = (TaskStatus_t*)malloc(sizeof(TaskStatus_t) * count);
if (!tasks) { if (!tasks) {
lv_obj_clean(parent);
taskNames.clear();
auto* error_label = lv_label_create(parent); auto* error_label = lv_label_create(parent);
lv_label_set_text(error_label, "Failed to allocate memory for task list"); lv_label_set_text(error_label, "Failed to allocate memory for task list");
return; return;
} }
uint32_t totalRuntime = 0; uint32_t totalRuntime = 0;
UBaseType_t actual = uxTaskGetSystemState(tasks, count, &totalRuntime); UBaseType_t actual = uxTaskGetSystemState(tasks, count, &totalRuntime);
std::sort(tasks, tasks + actual, [](const TaskStatus_t& a, const TaskStatus_t& b) {
return strcasecmp(getTaskName(a), getTaskName(b)) < 0;
});
for (int i = 0; i < actual; ++i) { taskNames.clear();
addRtosTask(parent, tasks[i]); for (UBaseType_t i = 0; i < actual; ++i) {
auto* row = i < lv_obj_get_child_count(parent) ? lv_obj_get_child(parent, static_cast<int32_t>(i)) : lv_label_create(parent);
lv_label_set_text_fmt(row, "%s (%s)", getTaskName(tasks[i]), getTaskState(tasks[i]));
// Keys and encoders move through the tasks, which scrolls the list
lv_obj_add_flag(row, LV_OBJ_FLAG_CLICKABLE);
taskNames.emplace_back(getTaskName(tasks[i]));
}
free(tasks);
while (lv_obj_get_child_count(parent) > actual) {
lv_obj_delete(lv_obj_get_child(parent, -1));
} }
free(tasks); if (selected_index < 0 || taskNames.empty()) {
return;
}
// Follows the task, or keeps the position when the task is gone
auto it = std::ranges::find(taskNames, selected_name);
const auto index = it != taskNames.end() ? static_cast<int32_t>(it - taskNames.begin()) : std::min(selected_index, static_cast<int32_t>(taskNames.size()) - 1);
// Also re-applied when the row didn't change: deleting rows resets grid navigation's selection
auto* row = lv_obj_get_child(parent, index);
lv_gridnav_set_focused(parent, row, LV_ANIM_OFF);
const bool list_focused = lv_obj_get_group(parent) != nullptr && lv_group_get_focused(lv_obj_get_group(parent)) == parent;
if (!show_selection) {
lv_obj_remove_state(row, LV_STATE_FOCUS_KEY);
}
if (list_focused) {
lv_obj_scroll_to_view_recursive(row, LV_ANIM_OFF);
} else {
lv_obj_remove_state(row, LV_STATE_FOCUSED);
}
} }
#endif #endif
lv_obj_t* createTab(lv_obj_t* tabview, const char* name) { lv_obj_t* createTab(lv_obj_t* tabview, const char* name) {
auto* tab = lv_tabview_add_tab(tabview, name); auto* tab = lv_tabview_add_tab(tabview, name);
auto* tab_bar = lv_tabview_get_tab_bar(tabview);
auto* tab_button = lv_obj_get_child(tab_bar, static_cast<int32_t>(lv_obj_get_child_count(tab_bar)) - 1);
auto* shared_icon_font = lvgl_theme_is_compact() ? lvgl_get_shared_icon_default_font() : lvgl_get_shared_icon_large_font();
lv_obj_set_style_text_font(tab_button, shared_icon_font, LV_STATE_DEFAULT);
// Square buttons, so the theme's round selection indicator fits the icon
const int32_t button_size = lv_font_get_line_height(shared_icon_font) + 2 * lv_obj_get_style_pad_top(tab_button, LV_PART_MAIN);
lv_obj_set_flex_grow(tab_button, 0);
lv_obj_set_size(tab_button, button_size, button_size);
lv_obj_set_flex_flow(tab, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_flow(tab, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(tab, 0, LV_STATE_DEFAULT); lv_obj_set_style_pad_row(tab, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(tab, 0, LV_STATE_DEFAULT); lv_obj_set_style_border_width(tab, 0, LV_STATE_DEFAULT);
@ -250,6 +297,8 @@ struct Context {
MemoryBarWidgets systemStorageBar; MemoryBarWidgets systemStorageBar;
lv_obj_t* tasksContainer = nullptr; lv_obj_t* tasksContainer = nullptr;
/** The task name of each row in tasksContainer */
std::vector<std::string> taskNames;
lv_obj_t* psramContainer = nullptr; lv_obj_t* psramContainer = nullptr;
bool hasExternalMem = false; bool hasExternalMem = false;
@ -293,7 +342,7 @@ void updateStorage(Context* ctx) {
void updateTasks(Context* ctx) { void updateTasks(Context* ctx) {
#if configUSE_TRACE_FACILITY #if configUSE_TRACE_FACILITY
if (ctx->tasksContainer) { if (ctx->tasksContainer) {
updateRtosTasks(ctx->tasksContainer); // Tasks tab: show state updateRtosTasks(ctx->tasksContainer, ctx->taskNames); // Tasks tab: show state
} }
#endif #endif
} }
@ -303,6 +352,24 @@ void onBackPressed(lv_event_t* event) {
app_event_emit_close(ctx->appInstanceId); app_event_emit_close(ctx->appInstanceId);
} }
#if configUSE_TRACE_FACILITY
void onTabChanged(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
auto* tabview = lv_event_get_target_obj(event);
auto* active_tab = lv_obj_get_child(lv_tabview_get_content(tabview), static_cast<int32_t>(lv_tabview_get_tab_active(tabview)));
const bool tasks_shown = active_tab == lv_obj_get_parent(ctx->tasksContainer);
auto* group = lv_group_get_default();
if (group == nullptr) {
return;
}
if (tasks_shown && lv_obj_get_group(ctx->tasksContainer) == nullptr) {
lv_group_add_obj(group, ctx->tasksContainer);
} else if (!tasks_shown && lv_obj_get_group(ctx->tasksContainer) != nullptr) {
lv_group_remove_obj(ctx->tasksContainer);
}
}
#endif
void createWidgets(lv_obj_t* parent, void* userData) { void createWidgets(lv_obj_t* parent, void* userData) {
auto* ctx = static_cast<Context*>(userData); auto* ctx = static_cast<Context*>(userData);
@ -319,17 +386,25 @@ void createWidgets(lv_obj_t* parent, void* userData) {
lv_obj_set_width(wrapper, LV_PCT(100)); lv_obj_set_width(wrapper, LV_PCT(100));
lv_obj_set_flex_grow(wrapper, 1); lv_obj_set_flex_grow(wrapper, 1);
lv_obj_set_style_pad_all(wrapper, 0, LV_STATE_DEFAULT); lv_obj_set_style_pad_all(wrapper, 0, LV_STATE_DEFAULT);
// Space between the screen edges and the rounded tab bar and content
const int32_t edge_space = lvgl_theme_is_compact() ? 2 : LV_DPX(8);
lv_obj_set_style_pad_hor(wrapper, edge_space, LV_STATE_DEFAULT);
lv_obj_set_style_pad_bottom(wrapper, edge_space, LV_STATE_DEFAULT);
auto* tabview = lv_tabview_create(wrapper); auto* tabview = lv_tabview_create(wrapper);
lv_tabview_set_tab_bar_position(tabview, LV_DIR_LEFT); lv_tabview_set_tab_bar_position(tabview, LV_DIR_LEFT);
auto tab_bar_width = 6 * lvgl_get_text_font_height(FONT_SIZE_DEFAULT);
lv_tabview_set_tab_bar_size(tabview, tab_bar_width);
// Create tabs // Create tabs
auto* memory_tab = createTab(tabview, "Memory"); auto* memory_tab = createTab(tabview, LVGL_ICON_SHARED_MEMORY);
auto* storage_tab = createTab(tabview, "Storage"); auto* storage_tab = createTab(tabview, LVGL_ICON_SHARED_HARD_DISK);
auto* tasks_tab = createTab(tabview, "Tasks"); auto* tasks_tab = createTab(tabview, LVGL_ICON_SHARED_SELECT_WINDOW_2);
auto* about_tab = createTab(tabview, "About"); auto* about_tab = createTab(tabview, LVGL_ICON_SHARED_INFO);
// As wide as the buttons, which are spread over the bar's height
auto* tab_bar = lv_tabview_get_tab_bar(tabview);
lv_obj_set_width(tab_bar, LV_SIZE_CONTENT);
lv_obj_set_style_pad_hor(tab_bar, lvgl_theme_is_compact() ? 2 : LV_DPX(12), LV_STATE_DEFAULT);
lv_obj_set_flex_align(tab_bar, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
// Memory tab content // Memory tab content
ctx->internalMemBar = createMemoryBar(memory_tab, "Internal"); ctx->internalMemBar = createMemoryBar(memory_tab, "Internal");
@ -370,6 +445,10 @@ void createWidgets(lv_obj_t* parent, void* userData) {
lv_obj_set_style_border_width(ctx->tasksContainer, 0, LV_STATE_DEFAULT); lv_obj_set_style_border_width(ctx->tasksContainer, 0, LV_STATE_DEFAULT);
lv_obj_set_flex_flow(ctx->tasksContainer, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_flow(ctx->tasksContainer, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_bg_opa(ctx->tasksContainer, 0, LV_STATE_DEFAULT); lv_obj_set_style_bg_opa(ctx->tasksContainer, 0, LV_STATE_DEFAULT);
lvgl_grid_navigation_add(ctx->tasksContainer);
// Only the shown tab's list can be focused, as focusing another tab's content would scroll to it
lv_group_remove_obj(ctx->tasksContainer);
lv_obj_add_event_cb(tabview, onTabChanged, LV_EVENT_VALUE_CHANGED, ctx);
#endif #endif
// Build info // Build info

View File

@ -1,3 +1,4 @@
#include <Tactility/app/selectiondialog/SelectionDialog.h>
#include <Tactility/app/timedatesettings/TimeDateSettings.h> #include <Tactility/app/timedatesettings/TimeDateSettings.h>
#include <Tactility/app/timezone/TimeZone.h> #include <Tactility/app/timezone/TimeZone.h>
#include <Tactility/settings/SystemSettings.h> #include <Tactility/settings/SystemSettings.h>
@ -12,11 +13,17 @@
#include <lvgl_window_manager/window_manager.h> #include <lvgl_window_manager/window_manager.h>
#include <tactility/check.h> #include <tactility/check.h>
#include <tactility/concurrent/task_event_group.h>
#include <tactility/log.h> #include <tactility/log.h>
#include <lvgl/fonts.h>
#include <lvgl/lvgl.h> #include <lvgl/lvgl.h>
#include <lvgl/widgets/card.h>
#include <lvgl/widgets/toolbar.h> #include <lvgl/widgets/toolbar.h>
#include <string>
#include <vector>
namespace tt::app::timedatesettings { namespace tt::app::timedatesettings {
constexpr auto* TAG = "TimeDate"; constexpr auto* TAG = "TimeDate";
@ -25,13 +32,31 @@ extern const ::AppManifest manifest;
namespace { namespace {
constexpr const char* DATE_FORMATS[] = { "MM/DD/YYYY", "DD/MM/YYYY", "YYYY-MM-DD", "YYYY/MM/DD" };
struct Context { struct Context {
uint32_t appInstanceId; uint32_t appInstanceId;
lv_obj_t* timeZoneLabel = nullptr; TaskEventGroup* eventGroup = nullptr;
lv_obj_t* dateFormatDropdown = nullptr; uint32_t selectDateFormatBit = 0;
uint32_t pendingTimeZoneDialogId = 0; uint32_t pendingTimeZoneDialogId = 0;
uint32_t dateFormatDialogId = 0;
// Valid while the window is shown
lv_obj_t* timeZoneLabel = nullptr;
lv_obj_t* dateFormatLabel = nullptr;
}; };
std::string getDateFormat() {
settings::SystemSettings sysSettings;
if (settings::loadSystemSettings(sysSettings)) {
for (const auto* format : DATE_FORMATS) {
if (sysSettings.dateFormat == format) {
return format;
}
}
}
return DATE_FORMATS[0];
}
void onBackPressed(lv_event_t* event) { void onBackPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event)); auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
@ -49,18 +74,63 @@ void onTimeZonePressed(lv_event_t* event) {
ctx->pendingTimeZoneDialogId = timezone::start(ctx->appInstanceId, true); ctx->pendingTimeZoneDialogId = timezone::start(ctx->appInstanceId, true);
} }
void onDateFormatChanged(lv_event_t* event) { void onSelectDateFormatPressed(lv_event_t* event) {
auto* dropdown = static_cast<lv_obj_t*>(lv_event_get_target(event)); auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
auto index = lv_dropdown_get_selected(dropdown); task_event_group_signal(ctx->eventGroup, ctx->selectDateFormatBit);
}
const char* dateFormats[] = {"MM/DD/YYYY", "DD/MM/YYYY", "YYYY-MM-DD", "YYYY/MM/DD"}; void startDateFormatSelection(Context* ctx) {
std::string selected_format = dateFormats[index]; if (ctx->dateFormatDialogId != 0) {
return;
settings::SystemSettings sysSettings;
if (settings::loadSystemSettings(sysSettings)) {
sysSettings.dateFormat = selected_format;
settings::saveSystemSettings(sysSettings);
} }
const std::vector<std::string> items(std::begin(DATE_FORMATS), std::end(DATE_FORMATS));
ctx->dateFormatDialogId = selectiondialog::start(ctx->appInstanceId, "Date format", items);
}
void onDateFormatSelectionResult(Context* ctx, const AppEvent& event) {
ctx->dateFormatDialogId = 0;
// Other results mean that the dialog was dismissed
const int32_t selected = event.result.result;
if (selected >= 0 && selected < static_cast<int32_t>(std::size(DATE_FORMATS))) {
settings::SystemSettings sysSettings;
if (settings::loadSystemSettings(sysSettings)) {
sysSettings.dateFormat = DATE_FORMATS[selected];
settings::saveSystemSettings(sysSettings);
}
lvgl_lock();
if (ctx->dateFormatLabel != nullptr) {
lv_label_set_text(ctx->dateFormatLabel, getDateFormat().c_str());
}
lvgl_unlock();
}
}
/**
* Creates a title with the setting's value below it, which take the width that the row's button leaves.
* Texts that don't fit scroll.
* @return the value label
*/
lv_obj_t* createTitleAndValue(lv_obj_t* row, const char* title) {
auto* texts = lv_obj_create(row);
lv_obj_set_height(texts, LV_SIZE_CONTENT);
lv_obj_set_flex_grow(texts, 1);
lv_obj_set_flex_flow(texts, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_all(texts, 0, LV_STATE_DEFAULT);
lv_obj_set_style_pad_row(texts, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(texts, 0, LV_STATE_DEFAULT);
lv_obj_set_style_bg_opa(texts, LV_OPA_TRANSP, LV_STATE_DEFAULT);
lv_obj_remove_flag(texts, LV_OBJ_FLAG_SCROLLABLE);
auto* title_label = lv_label_create(texts);
lv_obj_set_width(title_label, LV_PCT(100));
lv_label_set_long_mode(title_label, LV_LABEL_LONG_MODE_SCROLL_CIRCULAR);
lv_label_set_text(title_label, title);
auto* value_label = lv_label_create(texts);
lv_obj_set_width(value_label, LV_PCT(100));
lv_obj_set_style_text_font(value_label, lvgl_get_text_font(FONT_SIZE_SMALL), LV_STATE_DEFAULT);
lv_label_set_long_mode(value_label, LV_LABEL_LONG_MODE_SCROLL_CIRCULAR);
return value_label;
} }
void createWidgets(lv_obj_t* parent, void* userData) { void createWidgets(lv_obj_t* parent, void* userData) {
@ -73,24 +143,32 @@ void createWidgets(lv_obj_t* parent, void* userData) {
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
auto* main_wrapper = lv_obj_create(parent); auto* main_wrapper = lv_obj_create(parent);
lv_obj_set_style_border_width(main_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_set_width(main_wrapper, LV_PCT(100)); lv_obj_set_width(main_wrapper, LV_PCT(100));
lv_obj_set_flex_grow(main_wrapper, 1); lv_obj_set_flex_grow(main_wrapper, 1);
// The rows are transparent, so the card provides the background
auto* card = lvgl_card_create(main_wrapper);
lv_obj_set_size(card, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN);
// 24-hour format toggle // 24-hour format toggle
auto* time_format_wrapper = lv_obj_create(main_wrapper); auto* time_format_row = lv_obj_create(card);
lv_obj_set_width(time_format_wrapper, LV_PCT(100)); lv_obj_set_size(time_format_row, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_height(time_format_wrapper, LV_SIZE_CONTENT); lv_obj_set_flex_flow(time_format_row, LV_FLEX_FLOW_ROW);
lv_obj_set_style_pad_all(time_format_wrapper, 8, 0); lv_obj_set_flex_align(time_format_row, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_border_width(time_format_wrapper, 0, 0); lv_obj_set_style_border_width(time_format_row, 0, LV_STATE_DEFAULT);
lv_obj_set_style_pad_all(time_format_row, 0, LV_STATE_DEFAULT);
lv_obj_set_style_bg_opa(time_format_row, LV_OPA_TRANSP, LV_STATE_DEFAULT);
lv_obj_remove_flag(time_format_row, LV_OBJ_FLAG_SCROLLABLE);
auto* time_24h_label = lv_label_create(time_format_wrapper); auto* time_24h_label = lv_label_create(time_format_row);
lv_label_set_text(time_24h_label, "24-hour format"); lv_label_set_text(time_24h_label, "24-hour format");
lv_obj_align(time_24h_label, LV_ALIGN_LEFT_MID, 4, 0); lv_obj_set_flex_grow(time_24h_label, 1);
auto* time_24h_switch = lv_switch_create(time_format_wrapper); auto* time_24h_switch = lv_switch_create(time_format_row);
lv_obj_align(time_24h_switch, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_add_event_cb(time_24h_switch, onTimeFormatChanged, LV_EVENT_VALUE_CHANGED, nullptr); lv_obj_add_event_cb(time_24h_switch, onTimeFormatChanged, LV_EVENT_VALUE_CHANGED, nullptr);
if (settings::isTimeFormat24Hour()) { if (settings::isTimeFormat24Hour()) {
lv_obj_add_state(time_24h_switch, LV_STATE_CHECKED); lv_obj_add_state(time_24h_switch, LV_STATE_CHECKED);
@ -98,57 +176,51 @@ void createWidgets(lv_obj_t* parent, void* userData) {
lv_obj_remove_state(time_24h_switch, LV_STATE_CHECKED); lv_obj_remove_state(time_24h_switch, LV_STATE_CHECKED);
} }
// Date format dropdown // "Date format MM/DD/YYYY [Select]"
auto* date_format_wrapper = lv_obj_create(main_wrapper); auto* date_format_row = lv_obj_create(card);
lv_obj_set_width(date_format_wrapper, LV_PCT(100)); lv_obj_set_size(date_format_row, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_height(date_format_wrapper, LV_SIZE_CONTENT); lv_obj_set_flex_flow(date_format_row, LV_FLEX_FLOW_ROW);
lv_obj_set_style_pad_all(date_format_wrapper, 8, 0); lv_obj_set_flex_align(date_format_row, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_border_width(date_format_wrapper, 0, 0); lv_obj_set_style_border_width(date_format_row, 0, LV_STATE_DEFAULT);
lv_obj_set_style_pad_all(date_format_row, 0, LV_STATE_DEFAULT);
lv_obj_set_style_bg_opa(date_format_row, LV_OPA_TRANSP, LV_STATE_DEFAULT);
lv_obj_remove_flag(date_format_row, LV_OBJ_FLAG_SCROLLABLE);
auto* date_format_label = lv_label_create(date_format_wrapper); ctx->dateFormatLabel = createTitleAndValue(date_format_row, "Date format");
lv_label_set_text(date_format_label, "Date format"); lv_label_set_text(ctx->dateFormatLabel, getDateFormat().c_str());
lv_obj_align(date_format_label, LV_ALIGN_LEFT_MID, 4, 0);
ctx->dateFormatDropdown = lv_dropdown_create(date_format_wrapper); auto* date_format_button = lv_button_create(date_format_row);
lv_obj_set_width(ctx->dateFormatDropdown, 150); lv_label_set_text(lv_label_create(date_format_button), "Change");
lv_obj_align(ctx->dateFormatDropdown, LV_ALIGN_RIGHT_MID, 0, 0); lv_obj_add_event_cb(date_format_button, onSelectDateFormatPressed, LV_EVENT_SHORT_CLICKED, ctx);
lv_dropdown_set_options(ctx->dateFormatDropdown, "MM/DD/YYYY\nDD/MM/YYYY\nYYYY-MM-DD\nYYYY/MM/DD");
settings::SystemSettings sysSettings; // "Timezone Europe/Brussels [Change]"
if (settings::loadSystemSettings(sysSettings)) {
int index = 0;
if (sysSettings.dateFormat == "DD/MM/YYYY") index = 1;
else if (sysSettings.dateFormat == "YYYY-MM-DD") index = 2;
else if (sysSettings.dateFormat == "YYYY/MM/DD") index = 3;
lv_dropdown_set_selected(ctx->dateFormatDropdown, index);
}
lv_obj_add_event_cb(ctx->dateFormatDropdown, onDateFormatChanged, LV_EVENT_VALUE_CHANGED, nullptr);
// Timezone selector auto* timezone_row = lv_obj_create(card);
lv_obj_set_size(timezone_row, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(timezone_row, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(timezone_row, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_border_width(timezone_row, 0, LV_STATE_DEFAULT);
lv_obj_set_style_pad_all(timezone_row, 0, LV_STATE_DEFAULT);
lv_obj_set_style_bg_opa(timezone_row, LV_OPA_TRANSP, LV_STATE_DEFAULT);
lv_obj_remove_flag(timezone_row, LV_OBJ_FLAG_SCROLLABLE);
auto* timezone_wrapper = lv_obj_create(main_wrapper); ctx->timeZoneLabel = createTitleAndValue(timezone_row, "Timezone");
lv_obj_set_width(timezone_wrapper, LV_PCT(100));
lv_obj_set_height(timezone_wrapper, LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(timezone_wrapper, 8, 0);
lv_obj_set_style_border_width(timezone_wrapper, 0, 0);
auto* timezone_label = lv_label_create(timezone_wrapper);
lv_label_set_text(timezone_label, "Timezone");
lv_obj_align(timezone_label, LV_ALIGN_LEFT_MID, 4, 0);
auto* timezone_button = lv_button_create(timezone_wrapper);
lv_obj_set_width(timezone_button, 150);
lv_obj_align(timezone_button, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_add_event_cb(timezone_button, onTimeZonePressed, LV_EVENT_SHORT_CLICKED, ctx);
ctx->timeZoneLabel = lv_label_create(timezone_button);
std::string timeZoneName = settings::getTimeZoneName(); std::string timeZoneName = settings::getTimeZoneName();
if (timeZoneName.empty()) { if (timeZoneName.empty()) {
timeZoneName = "not set"; timeZoneName = "not set";
} }
lv_obj_center(ctx->timeZoneLabel);
lv_label_set_text(ctx->timeZoneLabel, timeZoneName.c_str()); lv_label_set_text(ctx->timeZoneLabel, timeZoneName.c_str());
auto* timezone_button = lv_button_create(timezone_row);
lv_label_set_text(lv_label_create(timezone_button), "Change");
lv_obj_add_event_cb(timezone_button, onTimeZonePressed, LV_EVENT_SHORT_CLICKED, ctx);
}
void destroyWidgets(void* userData) {
auto* ctx = static_cast<Context*>(userData);
ctx->timeZoneLabel = nullptr;
ctx->dateFormatLabel = nullptr;
} }
int32_t appMain(int argc, char* argv[]) { int32_t appMain(int argc, char* argv[]) {
@ -158,15 +230,22 @@ int32_t appMain(int argc, char* argv[]) {
TaskEventGroup event_group {}; TaskEventGroup event_group {};
task_event_group_construct(&event_group); task_event_group_construct(&event_group);
ctx.eventGroup = &event_group;
check(task_event_group_claim_bit(&event_group, &ctx.selectDateFormatBit) == ERROR_NONE);
AppEventSubscription sub {}; AppEventSubscription sub {};
check(app_event_subscribe(&sub, &event_group) == ERROR_NONE); check(app_event_subscribe(&sub, &event_group) == ERROR_NONE);
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx); WindowId window = window_manager_create_ext(appInstanceId, createWidgets, destroyWidgets, &ctx);
bool shouldClose = false; bool shouldClose = false;
while (!shouldClose) { while (!shouldClose) {
task_event_group_wait_any(&event_group, nullptr, portMAX_DELAY); uint32_t flags = 0;
task_event_group_wait_any(&event_group, &flags, portMAX_DELAY);
if (flags & ctx.selectDateFormatBit) {
startDateFormatSelection(&ctx);
}
AppEvent event {}; AppEvent event {};
while (app_event_poll(&sub, &event) == ERROR_NONE) { while (app_event_poll(&sub, &event) == ERROR_NONE) {
@ -175,14 +254,18 @@ int32_t appMain(int argc, char* argv[]) {
shouldClose = true; shouldClose = true;
break; break;
case APP_EVENT_RESULT: case APP_EVENT_RESULT:
if (event.result.launch_id == ctx.pendingTimeZoneDialogId) { if (event.result.launch_id == ctx.dateFormatDialogId) {
onDateFormatSelectionResult(&ctx, event);
} else if (event.result.launch_id == ctx.pendingTimeZoneDialogId) {
ctx.pendingTimeZoneDialogId = 0; ctx.pendingTimeZoneDialogId = 0;
if (event.result.result == 0 /* Ok */) { if (event.result.result == 0 /* Ok */) {
const auto name = timezone::getLastName(); const auto name = timezone::getLastName();
LOG_I(TAG, "Result name=%s code=%s", name.c_str(), timezone::getLastCode().c_str()); LOG_I(TAG, "Result name=%s code=%s", name.c_str(), timezone::getLastCode().c_str());
if (!name.empty()) { if (!name.empty()) {
lvgl_lock(); lvgl_lock();
lv_label_set_text(ctx.timeZoneLabel, name.c_str()); if (ctx.timeZoneLabel != nullptr) {
lv_label_set_text(ctx.timeZoneLabel, name.c_str());
}
lvgl_unlock(); lvgl_unlock();
} }
} }

View File

@ -3,6 +3,10 @@
#include <lvgl/devices/device_context.h> #include <lvgl/devices/device_context.h>
#include <lvgl/devices/trackball.h> #include <lvgl/devices/trackball.h>
#include <lvgl/lvgl.h> #include <lvgl/lvgl.h>
#include <lvgl/grid_navigation.h>
#include <lvgl/widgets/card.h>
#include <lvgl/widgets/chip.h>
#include <lvgl/widgets/sliderbox.h>
#include <lvgl/widgets/toolbar.h> #include <lvgl/widgets/toolbar.h>
#include <tactility/check.h> #include <tactility/check.h>
@ -25,14 +29,16 @@ extern const ::AppManifest manifest;
constexpr auto* TAG = "TrackballSettings"; constexpr auto* TAG = "TrackballSettings";
// Convert mode to dropdown index (dropdown order: Keys=0, Pointer=1) struct ModeOption {
static uint32_t modeToDropdownIndex(LvglTrackballMode mode) { LvglTrackballMode mode;
switch (mode) { const char* name;
case LVGL_TRACKBALL_MODE_KEYS: return 0; };
case LVGL_TRACKBALL_MODE_POINTER: return 1;
} // The order of the mode chips
return 0; // default to Keys constexpr ModeOption MODE_OPTIONS[] = {
} { LVGL_TRACKBALL_MODE_KEYS, "Keys" },
{ LVGL_TRACKBALL_MODE_POINTER, "Pointer" },
};
static lv_indev_t* findFirstTrackballIndev() { static lv_indev_t* findFirstTrackballIndev() {
lv_indev_t* indev = lv_indev_get_next(nullptr); lv_indev_t* indev = lv_indev_get_next(nullptr);
@ -64,8 +70,14 @@ struct Context {
// to whatever is already attached. // to whatever is already attached.
lv_indev_t* trackballIndev = nullptr; lv_indev_t* trackballIndev = nullptr;
lv_obj_t* switchTrackball = nullptr; lv_obj_t* switchTrackball = nullptr;
lv_obj_t* trackballModeDropdown = nullptr; // Shown while the trackball is enabled
lv_obj_t* settingsCard = nullptr;
lv_obj_t* modeChips = nullptr;
// Shown in keys mode
lv_obj_t* keySensitivityGroup = nullptr;
lv_obj_t* keySensitivitySlider = nullptr; lv_obj_t* keySensitivitySlider = nullptr;
// Shown in pointer mode
lv_obj_t* pointerSensitivityGroup = nullptr;
lv_obj_t* pointerSensitivitySlider = nullptr; lv_obj_t* pointerSensitivitySlider = nullptr;
}; };
@ -87,47 +99,37 @@ void applyLive(Context* ctx) {
lvgl_unlock(); lvgl_unlock();
} }
void onTrackballSwitch(lv_event_t* e) { /** Shows the settings of the enabled trackball and its mode */
auto* ctx = static_cast<Context*>(lv_event_get_user_data(e)); void updateWidgets(Context* ctx) {
bool enabled = lv_obj_has_state(ctx->switchTrackball, LV_STATE_CHECKED); lv_obj_set_flag(ctx->settingsCard, LV_OBJ_FLAG_HIDDEN, !ctx->tbSettings.enabled);
ctx->tbSettings.enabled = enabled; const uint32_t chip_count = lv_obj_get_child_count(ctx->modeChips);
ctx->updated = true; for (uint32_t i = 0; i < chip_count; i++) {
applyLive(ctx); lv_obj_set_state(lv_obj_get_child(ctx->modeChips, static_cast<int32_t>(i)), LV_STATE_CHECKED, MODE_OPTIONS[i].mode == ctx->tbSettings.mode);
// Enable/disable controls based on trackball state
if (enabled) {
if (ctx->trackballModeDropdown) lv_obj_clear_state(ctx->trackballModeDropdown, LV_STATE_DISABLED);
if (ctx->keySensitivitySlider) lv_obj_clear_state(ctx->keySensitivitySlider, LV_STATE_DISABLED);
if (ctx->pointerSensitivitySlider) lv_obj_clear_state(ctx->pointerSensitivitySlider, LV_STATE_DISABLED);
} else {
if (ctx->trackballModeDropdown) lv_obj_add_state(ctx->trackballModeDropdown, LV_STATE_DISABLED);
if (ctx->keySensitivitySlider) lv_obj_add_state(ctx->keySensitivitySlider, LV_STATE_DISABLED);
if (ctx->pointerSensitivitySlider) lv_obj_add_state(ctx->pointerSensitivitySlider, LV_STATE_DISABLED);
} }
lv_obj_set_flag(ctx->keySensitivityGroup, LV_OBJ_FLAG_HIDDEN, ctx->tbSettings.mode != LVGL_TRACKBALL_MODE_KEYS);
lv_obj_set_flag(ctx->pointerSensitivityGroup, LV_OBJ_FLAG_HIDDEN, ctx->tbSettings.mode != LVGL_TRACKBALL_MODE_POINTER);
} }
void onTrackballModeChanged(lv_event_t* e) { void onTrackballSwitch(lv_event_t* e) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(e)); auto* ctx = static_cast<Context*>(lv_event_get_user_data(e));
uint32_t selected = lv_dropdown_get_selected(ctx->trackballModeDropdown); ctx->tbSettings.enabled = lv_obj_has_state(ctx->switchTrackball, LV_STATE_CHECKED);
// Validate selection matches expected enum values (dropdown order: Keys=0, Pointer=1)
LvglTrackballMode mode;
switch (selected) {
case 0: mode = LVGL_TRACKBALL_MODE_KEYS; break;
case 1: mode = LVGL_TRACKBALL_MODE_POINTER; break;
default: return; // Invalid selection, ignore
}
ctx->tbSettings.mode = mode;
ctx->updated = true; ctx->updated = true;
applyLive(ctx);
updateWidgets(ctx);
}
void onModeChipPressed(lv_event_t* e) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(e));
ctx->tbSettings.mode = MODE_OPTIONS[lv_obj_get_index(lv_event_get_target_obj(e))].mode;
ctx->updated = true;
// Apply mode change immediately // Apply mode change immediately
applyLive(ctx); applyLive(ctx);
updateWidgets(ctx);
} }
void onKeySensitivityChanged(lv_event_t* e) { void onKeySensitivityChanged(lv_event_t* e) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(e)); auto* ctx = static_cast<Context*>(lv_event_get_user_data(e));
int32_t value = lv_slider_get_value(ctx->keySensitivitySlider); int32_t value = lvgl_sliderbox_get_value(ctx->keySensitivitySlider);
ctx->tbSettings.key_sensitivity = static_cast<uint8_t>(value); ctx->tbSettings.key_sensitivity = static_cast<uint8_t>(value);
ctx->updated = true; ctx->updated = true;
@ -137,7 +139,7 @@ void onKeySensitivityChanged(lv_event_t* e) {
void onPointerSensitivityChanged(lv_event_t* e) { void onPointerSensitivityChanged(lv_event_t* e) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(e)); auto* ctx = static_cast<Context*>(lv_event_get_user_data(e));
int32_t value = lv_slider_get_value(ctx->pointerSensitivitySlider); int32_t value = lvgl_sliderbox_get_value(ctx->pointerSensitivitySlider);
ctx->tbSettings.pointer_sensitivity = static_cast<uint8_t>(value); ctx->tbSettings.pointer_sensitivity = static_cast<uint8_t>(value);
ctx->updated = true; ctx->updated = true;
@ -145,6 +147,24 @@ void onPointerSensitivityChanged(lv_event_t* e) {
applyLive(ctx); applyLive(ctx);
} }
/** A transparent column with a title and a speed slider */
lv_obj_t* createSpeedGroup(lv_obj_t* parent, const char* title, int32_t value, lv_event_cb_t onChanged, Context* ctx, lv_obj_t** outSlider) {
auto* group = lv_obj_create(parent);
lv_obj_set_size(group, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(group, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_all(group, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(group, 0, LV_STATE_DEFAULT);
lv_obj_set_style_bg_opa(group, LV_OPA_TRANSP, LV_STATE_DEFAULT);
lv_obj_remove_flag(group, LV_OBJ_FLAG_SCROLLABLE);
lv_label_set_text(lv_label_create(group), title);
*outSlider = lvgl_sliderbox_create(group, 1, 10, 1, value);
lv_obj_set_width(*outSlider, LV_PCT(100));
lvgl_sliderbox_add_value_changed_cb(*outSlider, onChanged, ctx);
return group;
}
void createWidgets(lv_obj_t* parent, void* userData) { void createWidgets(lv_obj_t* parent, void* userData) {
auto* ctx = static_cast<Context*>(userData); auto* ctx = static_cast<Context*>(userData);
@ -161,12 +181,13 @@ void createWidgets(lv_obj_t* parent, void* userData) {
if (ctx->trackballIndev == nullptr) { if (ctx->trackballIndev == nullptr) {
auto* wrapper = lv_obj_create(parent); auto* wrapper = lv_obj_create(parent);
lv_obj_set_style_border_width(wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_width(wrapper, LV_PCT(100)); lv_obj_set_width(wrapper, LV_PCT(100));
lv_obj_set_flex_grow(wrapper, 1); lv_obj_set_flex_grow(wrapper, 1);
lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(wrapper, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); auto* card = lvgl_card_create(wrapper);
auto* label = lv_label_create(wrapper); lv_obj_set_size(card, LV_PCT(100), LV_SIZE_CONTENT);
lv_label_set_text(label, "No trackball device found"); lv_label_set_text(lv_label_create(card), "No trackball device found.");
return; return;
} }
@ -180,72 +201,39 @@ void createWidgets(lv_obj_t* parent, void* userData) {
if (ctx->tbSettings.enabled) lv_obj_add_state(ctx->switchTrackball, LV_STATE_CHECKED); if (ctx->tbSettings.enabled) lv_obj_add_state(ctx->switchTrackball, LV_STATE_CHECKED);
auto* main_wrapper = lv_obj_create(parent); auto* main_wrapper = lv_obj_create(parent);
lv_obj_set_style_border_width(main_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_set_width(main_wrapper, LV_PCT(100)); lv_obj_set_width(main_wrapper, LV_PCT(100));
lv_obj_set_flex_grow(main_wrapper, 1); lv_obj_set_flex_grow(main_wrapper, 1);
// Trackball mode dropdown ctx->settingsCard = lvgl_card_create(main_wrapper);
auto* tb_mode_wrapper = lv_obj_create(main_wrapper); lv_obj_set_size(ctx->settingsCard, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_size(tb_mode_wrapper, LV_PCT(100), LV_SIZE_CONTENT); lv_obj_set_flex_flow(ctx->settingsCard, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_all(tb_mode_wrapper, 0, LV_STATE_DEFAULT); lv_obj_set_flex_align(ctx->settingsCard, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_border_width(tb_mode_wrapper, 0, LV_STATE_DEFAULT);
auto* tb_mode_label = lv_label_create(tb_mode_wrapper); // Mode: the chips are centered while they fit, and scroll when they don't
lv_label_set_text(tb_mode_label, "Mode"); ctx->modeChips = lv_obj_create(ctx->settingsCard);
lv_obj_align(tb_mode_label, LV_ALIGN_LEFT_MID, 0, 0); lv_obj_set_size(ctx->modeChips, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
lv_obj_set_style_max_width(ctx->modeChips, LV_PCT(100), LV_STATE_DEFAULT);
ctx->trackballModeDropdown = lv_dropdown_create(tb_mode_wrapper); lv_obj_set_flex_flow(ctx->modeChips, LV_FLEX_FLOW_ROW);
lv_dropdown_set_options(ctx->trackballModeDropdown, "Keys\nPointer"); lv_obj_set_scroll_dir(ctx->modeChips, LV_DIR_HOR);
lv_obj_align(ctx->trackballModeDropdown, LV_ALIGN_RIGHT_MID, 0, 0); lv_obj_set_style_bg_opa(ctx->modeChips, LV_OPA_TRANSP, LV_STATE_DEFAULT);
lv_dropdown_set_selected(ctx->trackballModeDropdown, modeToDropdownIndex(ctx->tbSettings.mode)); lv_obj_set_style_border_width(ctx->modeChips, 0, LV_STATE_DEFAULT);
lv_obj_add_event_cb(ctx->trackballModeDropdown, onTrackballModeChanged, LV_EVENT_VALUE_CHANGED, ctx); // The chips' margins leave room for their focus rings and space them apart
lv_obj_set_style_pad_all(ctx->modeChips, 0, LV_STATE_DEFAULT);
// Disable dropdown if trackball is disabled lv_obj_set_style_pad_column(ctx->modeChips, 0, LV_STATE_DEFAULT);
if (!ctx->tbSettings.enabled) { for (const auto& option : MODE_OPTIONS) {
lv_obj_add_state(ctx->trackballModeDropdown, LV_STATE_DISABLED); auto* chip = lvgl_chip_create(ctx->modeChips);
lv_label_set_text(lv_label_create(chip), option.name);
lv_obj_add_event_cb(chip, onModeChipPressed, LV_EVENT_SHORT_CLICKED, ctx);
} }
lvgl_grid_navigation_add(ctx->modeChips);
// Key sensitivity slider // The speed of the selected mode
auto* key_sens_wrapper = lv_obj_create(main_wrapper); ctx->keySensitivityGroup = createSpeedGroup(ctx->settingsCard, "Key speed", ctx->tbSettings.key_sensitivity, onKeySensitivityChanged, ctx, &ctx->keySensitivitySlider);
lv_obj_set_size(key_sens_wrapper, LV_PCT(100), LV_SIZE_CONTENT); ctx->pointerSensitivityGroup = createSpeedGroup(ctx->settingsCard, "Pointer speed", ctx->tbSettings.pointer_sensitivity, onPointerSensitivityChanged, ctx, &ctx->pointerSensitivitySlider);
lv_obj_set_style_pad_hor(key_sens_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(key_sens_wrapper, 0, LV_STATE_DEFAULT);
auto* key_sens_label = lv_label_create(key_sens_wrapper); updateWidgets(ctx);
lv_label_set_text(key_sens_label, "Key Speed");
lv_obj_align(key_sens_label, LV_ALIGN_LEFT_MID, 0, 0);
ctx->keySensitivitySlider = lv_slider_create(key_sens_wrapper);
lv_slider_set_range(ctx->keySensitivitySlider, 1, 10);
lv_slider_set_value(ctx->keySensitivitySlider, ctx->tbSettings.key_sensitivity, LV_ANIM_OFF);
lv_obj_set_width(ctx->keySensitivitySlider, LV_PCT(50));
lv_obj_align(ctx->keySensitivitySlider, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_add_event_cb(ctx->keySensitivitySlider, onKeySensitivityChanged, LV_EVENT_VALUE_CHANGED, ctx);
if (!ctx->tbSettings.enabled) {
lv_obj_add_state(ctx->keySensitivitySlider, LV_STATE_DISABLED);
}
// Pointer sensitivity slider
auto* ptr_sens_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(ptr_sens_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_hor(ptr_sens_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(ptr_sens_wrapper, 0, LV_STATE_DEFAULT);
auto* ptr_sens_label = lv_label_create(ptr_sens_wrapper);
lv_label_set_text(ptr_sens_label, "Pointer Speed");
lv_obj_align(ptr_sens_label, LV_ALIGN_LEFT_MID, 0, 0);
ctx->pointerSensitivitySlider = lv_slider_create(ptr_sens_wrapper);
lv_slider_set_range(ctx->pointerSensitivitySlider, 1, 10);
lv_slider_set_value(ctx->pointerSensitivitySlider, ctx->tbSettings.pointer_sensitivity, LV_ANIM_OFF);
lv_obj_set_width(ctx->pointerSensitivitySlider, LV_PCT(50));
lv_obj_align(ctx->pointerSensitivitySlider, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_add_event_cb(ctx->pointerSensitivitySlider, onPointerSensitivityChanged, LV_EVENT_VALUE_CHANGED, ctx);
if (!ctx->tbSettings.enabled) {
lv_obj_add_state(ctx->pointerSensitivitySlider, LV_STATE_DISABLED);
}
} }
// Mirrors the old onHide() behaviour: persist the settings (regardless of whether the app is // Mirrors the old onHide() behaviour: persist the settings (regardless of whether the app is

View File

@ -16,6 +16,9 @@
#include <lvgl.h> #include <lvgl.h>
#include <lvgl/lvgl.h> #include <lvgl/lvgl.h>
#include <lvgl/grid_navigation.h>
#include <lvgl/widgets/card.h>
#include <lvgl/widgets/chip.h>
#include <lvgl/widgets/toolbar.h> #include <lvgl/widgets/toolbar.h>
namespace tt::app::webserversettings { namespace tt::app::webserversettings {
@ -33,7 +36,15 @@ struct Context {
settings::webserver::WebServerSettings originalSettings; settings::webserver::WebServerSettings originalSettings;
bool updated = false; bool updated = false;
bool wifiSettingsChanged = false; bool wifiSettingsChanged = false;
lv_obj_t* dropdownWifiMode = nullptr; lv_obj_t* wifiModeChips = nullptr;
// Shown in access point mode
lv_obj_t* apGroup = nullptr;
// Shown when the access point isn't an open network
lv_obj_t* apPasswordRow = nullptr;
// Shown when authentication is required
lv_obj_t* authCard = nullptr;
// Shown when the web server is enabled
lv_obj_t* urlCard = nullptr;
lv_obj_t* textAreaApPassword = nullptr; lv_obj_t* textAreaApPassword = nullptr;
lv_obj_t* switchApOpenNetwork = nullptr; lv_obj_t* switchApOpenNetwork = nullptr;
lv_obj_t* switchWebServerEnabled = nullptr; lv_obj_t* switchWebServerEnabled = nullptr;
@ -53,10 +64,34 @@ void onBackPressed(lv_event_t* event) {
app_event_emit_close(ctx->appInstanceId); app_event_emit_close(ctx->appInstanceId);
} }
void onWifiModeChanged(lv_event_t* e) { /** Shows the widgets of the enabled features. Requires the LVGL lock. */
void updateVisibility(Context* ctx, bool accessPoint, bool openNetwork, bool authEnabled, bool serverEnabled) {
const uint32_t chip_count = lv_obj_get_child_count(ctx->wifiModeChips);
for (uint32_t i = 0; i < chip_count; i++) {
lv_obj_set_state(lv_obj_get_child(ctx->wifiModeChips, static_cast<int32_t>(i)), LV_STATE_CHECKED, (i == 1) == accessPoint);
}
lv_obj_set_flag(ctx->apGroup, LV_OBJ_FLAG_HIDDEN, !accessPoint);
lv_obj_set_flag(ctx->apPasswordRow, LV_OBJ_FLAG_HIDDEN, openNetwork);
lv_obj_set_flag(ctx->authCard, LV_OBJ_FLAG_HIDDEN, !authEnabled);
lv_obj_set_flag(ctx->urlCard, LV_OBJ_FLAG_HIDDEN, !serverEnabled);
}
void updateVisibility(Context* ctx) {
updateVisibility(ctx,
lv_obj_has_state(lv_obj_get_child(ctx->wifiModeChips, 1), LV_STATE_CHECKED),
lv_obj_has_state(ctx->switchApOpenNetwork, LV_STATE_CHECKED),
lv_obj_has_state(ctx->switchWebServerAuthEnabled, LV_STATE_CHECKED),
lv_obj_has_state(ctx->switchWebServerEnabled, LV_STATE_CHECKED));
}
void onWifiModeChipPressed(lv_event_t* e) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(e)); auto* ctx = static_cast<Context*>(lv_event_get_user_data(e));
auto* dropdown = static_cast<lv_obj_t*>(lv_event_get_target(e)); // Chip 0 is station mode, chip 1 is access point mode
auto index = lv_dropdown_get_selected(dropdown); const uint32_t index = lv_obj_get_index(lv_event_get_target_obj(e));
updateVisibility(ctx, index == 1,
lv_obj_has_state(ctx->switchApOpenNetwork, LV_STATE_CHECKED),
lv_obj_has_state(ctx->switchWebServerAuthEnabled, LV_STATE_CHECKED),
lv_obj_has_state(ctx->switchWebServerEnabled, LV_STATE_CHECKED));
getMainDispatcher().dispatch([ctx, index] { getMainDispatcher().dispatch([ctx, index] {
ctx->wsSettings.wifiMode = static_cast<settings::webserver::WiFiMode>(index); ctx->wsSettings.wifiMode = static_cast<settings::webserver::WiFiMode>(index);
ctx->updated = true; ctx->updated = true;
@ -70,6 +105,7 @@ void onWifiModeChanged(lv_event_t* e) {
void onWebServerEnabledSwitch(lv_event_t* e) { void onWebServerEnabledSwitch(lv_event_t* e) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(e)); auto* ctx = static_cast<Context*>(lv_event_get_user_data(e));
bool enabled = lv_obj_has_state(ctx->switchWebServerEnabled, LV_STATE_CHECKED); bool enabled = lv_obj_has_state(ctx->switchWebServerEnabled, LV_STATE_CHECKED);
updateVisibility(ctx);
getMainDispatcher().dispatch([ctx, enabled] { getMainDispatcher().dispatch([ctx, enabled] {
ctx->wsSettings.webServerEnabled = enabled; ctx->wsSettings.webServerEnabled = enabled;
ctx->updated = true; ctx->updated = true;
@ -91,22 +127,7 @@ void onWebServerEnabledSwitch(lv_event_t* e) {
void onWebServerAuthEnabledSwitch(lv_event_t* e) { void onWebServerAuthEnabledSwitch(lv_event_t* e) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(e)); auto* ctx = static_cast<Context*>(lv_event_get_user_data(e));
bool enabled = lv_obj_has_state(ctx->switchWebServerAuthEnabled, LV_STATE_CHECKED); bool enabled = lv_obj_has_state(ctx->switchWebServerAuthEnabled, LV_STATE_CHECKED);
updateVisibility(ctx);
if (ctx->textAreaWebServerUsername && ctx->textAreaWebServerPassword) {
if (enabled) {
lv_obj_remove_state(ctx->textAreaWebServerUsername, LV_STATE_DISABLED);
lv_obj_add_flag(ctx->textAreaWebServerUsername, LV_OBJ_FLAG_CLICKABLE);
lv_obj_remove_state(ctx->textAreaWebServerPassword, LV_STATE_DISABLED);
lv_obj_add_flag(ctx->textAreaWebServerPassword, LV_OBJ_FLAG_CLICKABLE);
} else {
lv_obj_add_state(ctx->textAreaWebServerUsername, LV_STATE_DISABLED);
lv_obj_remove_flag(ctx->textAreaWebServerUsername, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_state(ctx->textAreaWebServerPassword, LV_STATE_DISABLED);
lv_obj_remove_flag(ctx->textAreaWebServerPassword, LV_OBJ_FLAG_CLICKABLE);
}
}
getMainDispatcher().dispatch([ctx, enabled] { getMainDispatcher().dispatch([ctx, enabled] {
ctx->wsSettings.webServerAuthEnabled = enabled; ctx->wsSettings.webServerAuthEnabled = enabled;
@ -132,16 +153,7 @@ void onApPasswordChanged(lv_event_t* e) {
void onApOpenNetworkSwitch(lv_event_t* e) { void onApOpenNetworkSwitch(lv_event_t* e) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(e)); auto* ctx = static_cast<Context*>(lv_event_get_user_data(e));
bool openNetwork = lv_obj_has_state(ctx->switchApOpenNetwork, LV_STATE_CHECKED); bool openNetwork = lv_obj_has_state(ctx->switchApOpenNetwork, LV_STATE_CHECKED);
updateVisibility(ctx);
if (ctx->textAreaApPassword) {
if (openNetwork) {
lv_obj_add_state(ctx->textAreaApPassword, LV_STATE_DISABLED);
lv_obj_remove_flag(ctx->textAreaApPassword, LV_OBJ_FLAG_CLICKABLE);
} else {
lv_obj_remove_state(ctx->textAreaApPassword, LV_STATE_DISABLED);
lv_obj_add_flag(ctx->textAreaApPassword, LV_OBJ_FLAG_CLICKABLE);
}
}
getMainDispatcher().dispatch([ctx, openNetwork] { getMainDispatcher().dispatch([ctx, openNetwork] {
ctx->wsSettings.apOpenNetwork = openNetwork; ctx->wsSettings.apOpenNetwork = openNetwork;
@ -187,6 +199,51 @@ void updateUrlDisplay(Context* ctx) {
lv_label_set_text(ctx->labelUrlValue, url.c_str()); lv_label_set_text(ctx->labelUrlValue, url.c_str());
} }
lv_obj_t* createCard(lv_obj_t* parent) {
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;
}
/** A transparent column of rows, to show or hide them together */
lv_obj_t* createGroup(lv_obj_t* parent) {
auto* group = lv_obj_create(parent);
lv_obj_set_size(group, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(group, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_all(group, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(group, 0, LV_STATE_DEFAULT);
lv_obj_set_style_bg_opa(group, LV_OPA_TRANSP, LV_STATE_DEFAULT);
lv_obj_remove_flag(group, LV_OBJ_FLAG_SCROLLABLE);
return group;
}
/** A transparent row: "Title [content]" */
lv_obj_t* createRow(lv_obj_t* parent, const char* title) {
auto* row = lv_obj_create(parent);
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_all(row, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(row, 0, LV_STATE_DEFAULT);
lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, LV_STATE_DEFAULT);
lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE);
auto* label = lv_label_create(row);
lv_label_set_text(label, title);
lv_obj_set_flex_grow(label, 1);
return row;
}
lv_obj_t* createTextArea(lv_obj_t* row, const char* text, uint32_t maxLength, bool password) {
auto* text_area = lv_textarea_create(row);
lv_obj_set_width(text_area, LV_PCT(50));
lv_textarea_set_one_line(text_area, true);
lv_textarea_set_max_length(text_area, maxLength);
lv_textarea_set_password_mode(text_area, password);
lv_textarea_set_text(text_area, text);
return text_area;
}
void createWidgets(lv_obj_t* parent, void* userData) { void createWidgets(lv_obj_t* parent, void* userData) {
auto* ctx = static_cast<Context*>(userData); auto* ctx = static_cast<Context*>(userData);
@ -205,144 +262,80 @@ void createWidgets(lv_obj_t* parent, void* userData) {
lv_obj_add_event_cb(ctx->switchWebServerEnabled, onWebServerEnabledSwitch, LV_EVENT_VALUE_CHANGED, ctx); lv_obj_add_event_cb(ctx->switchWebServerEnabled, onWebServerEnabledSwitch, LV_EVENT_VALUE_CHANGED, ctx);
auto* main_wrapper = lv_obj_create(parent); auto* main_wrapper = lv_obj_create(parent);
lv_obj_set_style_border_width(main_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_set_width(main_wrapper, LV_PCT(100)); lv_obj_set_width(main_wrapper, LV_PCT(100));
lv_obj_set_flex_grow(main_wrapper, 1); lv_obj_set_flex_grow(main_wrapper, 1);
// WiFi Mode dropdown // Address, while the web server is enabled
auto* wifi_mode_wrapper = lv_obj_create(main_wrapper); ctx->urlCard = createCard(main_wrapper);
lv_obj_set_size(wifi_mode_wrapper, LV_PCT(100), LV_SIZE_CONTENT); ctx->labelUrl = lv_label_create(ctx->urlCard);
lv_obj_set_style_pad_all(wifi_mode_wrapper, 0, LV_STATE_DEFAULT); lv_label_set_text(ctx->labelUrl, "Web server address");
lv_obj_set_style_border_width(wifi_mode_wrapper, 0, LV_STATE_DEFAULT); ctx->labelUrlValue = lv_label_create(ctx->urlCard);
auto* wifi_mode_label = lv_label_create(wifi_mode_wrapper); updateUrlDisplay(ctx);
lv_label_set_text(wifi_mode_label, "WiFi Mode");
lv_obj_align(wifi_mode_label, LV_ALIGN_LEFT_MID, 0, 0);
ctx->dropdownWifiMode = lv_dropdown_create(wifi_mode_wrapper);
lv_obj_align(ctx->dropdownWifiMode, LV_ALIGN_RIGHT_MID, 0, 0);
lv_dropdown_set_options(ctx->dropdownWifiMode, "Station\nAccess Point");
lv_dropdown_set_selected(ctx->dropdownWifiMode, static_cast<uint32_t>(ctx->wsSettings.wifiMode));
lv_obj_add_event_cb(ctx->dropdownWifiMode, onWifiModeChanged, LV_EVENT_VALUE_CHANGED, ctx);
// AP Open Network toggle // WiFi mode, with the access point settings in access point mode
auto* ap_open_wrapper = lv_obj_create(main_wrapper); lv_label_set_text(lv_label_create(main_wrapper), "WiFi mode");
lv_obj_set_size(ap_open_wrapper, LV_PCT(100), LV_SIZE_CONTENT); auto* wifi_card = createCard(main_wrapper);
lv_obj_set_style_pad_all(ap_open_wrapper, 0, LV_STATE_DEFAULT); lv_obj_set_flex_align(wifi_card, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_border_width(ap_open_wrapper, 0, LV_STATE_DEFAULT);
auto* ap_open_label = lv_label_create(ap_open_wrapper); // The chips are centered while they fit, and scroll when they don't
lv_label_set_text(ap_open_label, "AP Open Network"); ctx->wifiModeChips = lv_obj_create(wifi_card);
lv_obj_align(ap_open_label, LV_ALIGN_LEFT_MID, 0, 0); lv_obj_set_size(ctx->wifiModeChips, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
ctx->switchApOpenNetwork = lv_switch_create(ap_open_wrapper); lv_obj_set_style_max_width(ctx->wifiModeChips, LV_PCT(100), LV_STATE_DEFAULT);
lv_obj_set_flex_flow(ctx->wifiModeChips, LV_FLEX_FLOW_ROW);
lv_obj_set_scroll_dir(ctx->wifiModeChips, LV_DIR_HOR);
lv_obj_set_style_bg_opa(ctx->wifiModeChips, LV_OPA_TRANSP, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(ctx->wifiModeChips, 0, LV_STATE_DEFAULT);
// The chips' margins leave room for their focus rings and space them apart
lv_obj_set_style_pad_all(ctx->wifiModeChips, 0, LV_STATE_DEFAULT);
lv_obj_set_style_pad_column(ctx->wifiModeChips, 0, LV_STATE_DEFAULT);
// In settings::webserver::WiFiMode order
for (const char* name : { "Station", "Access point" }) {
auto* chip = lvgl_chip_create(ctx->wifiModeChips);
lv_label_set_text(lv_label_create(chip), name);
lv_obj_add_event_cb(chip, onWifiModeChipPressed, LV_EVENT_SHORT_CLICKED, ctx);
}
lvgl_grid_navigation_add(ctx->wifiModeChips);
ctx->apGroup = createGroup(wifi_card);
auto* ap_open_row = createRow(ctx->apGroup, "Open network");
ctx->switchApOpenNetwork = lv_switch_create(ap_open_row);
if (ctx->wsSettings.apOpenNetwork) lv_obj_add_state(ctx->switchApOpenNetwork, LV_STATE_CHECKED); if (ctx->wsSettings.apOpenNetwork) lv_obj_add_state(ctx->switchApOpenNetwork, LV_STATE_CHECKED);
lv_obj_align(ctx->switchApOpenNetwork, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_add_event_cb(ctx->switchApOpenNetwork, onApOpenNetworkSwitch, LV_EVENT_VALUE_CHANGED, ctx); lv_obj_add_event_cb(ctx->switchApOpenNetwork, onApOpenNetworkSwitch, LV_EVENT_VALUE_CHANGED, ctx);
// AP Password ctx->apPasswordRow = createRow(ctx->apGroup, "Password");
auto* ap_pass_wrapper = lv_obj_create(main_wrapper); ctx->textAreaApPassword = createTextArea(ctx->apPasswordRow, ctx->wsSettings.apPassword.c_str(), 64, true);
lv_obj_set_size(ap_pass_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(ap_pass_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(ap_pass_wrapper, 0, LV_STATE_DEFAULT);
auto* ap_pass_label = lv_label_create(ap_pass_wrapper);
lv_label_set_text(ap_pass_label, "AP Password");
lv_obj_align(ap_pass_label, LV_ALIGN_LEFT_MID, 0, 0);
ctx->textAreaApPassword = lv_textarea_create(ap_pass_wrapper);
lv_obj_set_width(ctx->textAreaApPassword, 120);
lv_obj_align(ctx->textAreaApPassword, LV_ALIGN_RIGHT_MID, 0, 0);
lv_textarea_set_one_line(ctx->textAreaApPassword, true);
lv_textarea_set_max_length(ctx->textAreaApPassword, 64);
lv_textarea_set_password_mode(ctx->textAreaApPassword, true);
lv_textarea_set_text(ctx->textAreaApPassword, ctx->wsSettings.apPassword.c_str());
lv_obj_add_event_cb(ctx->textAreaApPassword, onApPasswordChanged, LV_EVENT_VALUE_CHANGED, ctx); lv_obj_add_event_cb(ctx->textAreaApPassword, onApPasswordChanged, LV_EVENT_VALUE_CHANGED, ctx);
// Disable password field if open network is enabled
if (ctx->wsSettings.apOpenNetwork) {
lv_obj_add_state(ctx->textAreaApPassword, LV_STATE_DISABLED);
lv_obj_remove_flag(ctx->textAreaApPassword, LV_OBJ_FLAG_CLICKABLE);
}
// Web Server Authentication Enable toggle // Authentication, with the credentials while it's required
auto* ws_auth_wrapper = lv_obj_create(main_wrapper); auto* auth_row = createRow(main_wrapper, "Require authentication");
lv_obj_set_size(ws_auth_wrapper, LV_PCT(100), LV_SIZE_CONTENT); ctx->switchWebServerAuthEnabled = lv_switch_create(auth_row);
lv_obj_set_style_pad_all(ws_auth_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(ws_auth_wrapper, 0, LV_STATE_DEFAULT);
auto* ws_auth_label = lv_label_create(ws_auth_wrapper);
lv_label_set_text(ws_auth_label, "Require Authentication");
lv_obj_align(ws_auth_label, LV_ALIGN_LEFT_MID, 0, 0);
ctx->switchWebServerAuthEnabled = lv_switch_create(ws_auth_wrapper);
if (ctx->wsSettings.webServerAuthEnabled) lv_obj_add_state(ctx->switchWebServerAuthEnabled, LV_STATE_CHECKED); if (ctx->wsSettings.webServerAuthEnabled) lv_obj_add_state(ctx->switchWebServerAuthEnabled, LV_STATE_CHECKED);
lv_obj_align(ctx->switchWebServerAuthEnabled, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_add_event_cb(ctx->switchWebServerAuthEnabled, onWebServerAuthEnabledSwitch, LV_EVENT_VALUE_CHANGED, ctx); lv_obj_add_event_cb(ctx->switchWebServerAuthEnabled, onWebServerAuthEnabledSwitch, LV_EVENT_VALUE_CHANGED, ctx);
// WebServer Username ctx->authCard = createCard(main_wrapper);
auto* ws_user_wrapper = lv_obj_create(main_wrapper); auto* user_row = createRow(ctx->authCard, "Username");
lv_obj_set_size(ws_user_wrapper, LV_PCT(100), LV_SIZE_CONTENT); ctx->textAreaWebServerUsername = createTextArea(user_row, ctx->wsSettings.webServerUsername.c_str(), 32, false);
lv_obj_set_style_pad_all(ws_user_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(ws_user_wrapper, 0, LV_STATE_DEFAULT);
auto* ws_user_label = lv_label_create(ws_user_wrapper);
lv_label_set_text(ws_user_label, "Username");
lv_obj_align(ws_user_label, LV_ALIGN_LEFT_MID, 0, 0);
ctx->textAreaWebServerUsername = lv_textarea_create(ws_user_wrapper);
if (!ctx->wsSettings.webServerAuthEnabled) {
lv_obj_add_state(ctx->textAreaWebServerUsername, LV_STATE_DISABLED);
lv_obj_remove_flag(ctx->textAreaWebServerUsername, LV_OBJ_FLAG_CLICKABLE);
}
lv_obj_set_width(ctx->textAreaWebServerUsername, 120);
lv_obj_align(ctx->textAreaWebServerUsername, LV_ALIGN_RIGHT_MID, 0, 0);
lv_textarea_set_one_line(ctx->textAreaWebServerUsername, true);
lv_textarea_set_max_length(ctx->textAreaWebServerUsername, 32);
lv_textarea_set_text(ctx->textAreaWebServerUsername, ctx->wsSettings.webServerUsername.c_str());
lv_obj_add_event_cb(ctx->textAreaWebServerUsername, onCredentialChanged, LV_EVENT_VALUE_CHANGED, ctx); lv_obj_add_event_cb(ctx->textAreaWebServerUsername, onCredentialChanged, LV_EVENT_VALUE_CHANGED, ctx);
auto* password_row = createRow(ctx->authCard, "Password");
// WebServer Password ctx->textAreaWebServerPassword = createTextArea(password_row, ctx->wsSettings.webServerPassword.c_str(), 64, true);
auto* ws_pass_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(ws_pass_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(ws_pass_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(ws_pass_wrapper, 0, LV_STATE_DEFAULT);
auto* ws_pass_label = lv_label_create(ws_pass_wrapper);
lv_label_set_text(ws_pass_label, "Password");
lv_obj_align(ws_pass_label, LV_ALIGN_LEFT_MID, 0, 0);
ctx->textAreaWebServerPassword = lv_textarea_create(ws_pass_wrapper);
if (!ctx->wsSettings.webServerAuthEnabled) {
lv_obj_add_state(ctx->textAreaWebServerPassword, LV_STATE_DISABLED);
lv_obj_remove_flag(ctx->textAreaWebServerPassword, LV_OBJ_FLAG_CLICKABLE);
}
lv_obj_set_width(ctx->textAreaWebServerPassword, 120);
lv_obj_align(ctx->textAreaWebServerPassword, LV_ALIGN_RIGHT_MID, 0, 0);
lv_textarea_set_one_line(ctx->textAreaWebServerPassword, true);
lv_textarea_set_max_length(ctx->textAreaWebServerPassword, 64);
lv_textarea_set_password_mode(ctx->textAreaWebServerPassword, true);
lv_textarea_set_text(ctx->textAreaWebServerPassword, ctx->wsSettings.webServerPassword.c_str());
lv_obj_add_event_cb(ctx->textAreaWebServerPassword, onCredentialChanged, LV_EVENT_VALUE_CHANGED, ctx); lv_obj_add_event_cb(ctx->textAreaWebServerPassword, onCredentialChanged, LV_EVENT_VALUE_CHANGED, ctx);
// URL Display
auto* url_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(url_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(url_wrapper, 10, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(url_wrapper, 1, LV_STATE_DEFAULT);
lv_obj_set_flex_flow(url_wrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_flex_cross_place(url_wrapper, LV_FLEX_ALIGN_START, 0);
ctx->labelUrl = lv_label_create(url_wrapper);
lv_label_set_text(ctx->labelUrl, "Web Server URL:");
ctx->labelUrlValue = lv_label_create(url_wrapper);
if (lv_display_get_color_format(lv_obj_get_display(parent)) == LV_COLOR_FORMAT_L8) {
lv_obj_set_style_text_color(ctx->labelUrlValue, lv_theme_get_color_secondary(ctx->labelUrlValue), LV_PART_MAIN);
} else {
lv_obj_set_style_text_color(ctx->labelUrlValue, lv_palette_main(LV_PALETTE_BLUE), 0);
}
updateUrlDisplay(ctx);
// Info text // Info text
auto* info_label = lv_label_create(main_wrapper); auto* info_label = lv_label_create(main_wrapper);
lv_label_set_long_mode(info_label, LV_LABEL_LONG_WRAP); lv_label_set_long_mode(info_label, LV_LABEL_LONG_WRAP);
lv_obj_set_width(info_label, LV_PCT(95)); lv_obj_set_width(info_label, LV_PCT(100));
if (lv_display_get_color_format(lv_obj_get_display(parent)) != LV_COLOR_FORMAT_L8) {
lv_obj_set_style_text_color(info_label, lv_palette_main(LV_PALETTE_GREY), 0);
}
lv_label_set_text(info_label, lv_label_set_text(info_label,
"WiFi Station credentials are managed separately.\n" "WiFi Station credentials are managed separately.\n"
"Use the WiFi menu to connect to networks.\n\n" "Use the WiFi menu to connect to networks.\n\n"
"AP mode uses the password configured above."); "AP mode uses the password configured above.");
updateVisibility(ctx,
ctx->wsSettings.wifiMode == settings::webserver::WiFiMode::AccessPoint,
ctx->wsSettings.apOpenNetwork,
ctx->wsSettings.webServerAuthEnabled,
ctx->wsSettings.webServerEnabled);
} }
int32_t appMain(int argc, char* argv[]) { int32_t appMain(int argc, char* argv[]) {

View File

@ -255,6 +255,9 @@ void createWidgets(lv_obj_t* parent, void* userData) {
ctx->autoConnectWrapper = lv_obj_create(wrapper); ctx->autoConnectWrapper = lv_obj_create(wrapper);
lv_obj_set_size(ctx->autoConnectWrapper, LV_PCT(100), LV_SIZE_CONTENT); lv_obj_set_size(ctx->autoConnectWrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(ctx->autoConnectWrapper, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(ctx->autoConnectWrapper, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_remove_flag(ctx->autoConnectWrapper, LV_OBJ_FLAG_SCROLLABLE);
lvgl::obj_set_style_bg_invisible(ctx->autoConnectWrapper); lvgl::obj_set_style_bg_invisible(ctx->autoConnectWrapper);
lv_obj_set_style_pad_all(ctx->autoConnectWrapper, 0, LV_STATE_DEFAULT); lv_obj_set_style_pad_all(ctx->autoConnectWrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(ctx->autoConnectWrapper, 0, LV_STATE_DEFAULT); lv_obj_set_style_border_width(ctx->autoConnectWrapper, 0, LV_STATE_DEFAULT);
@ -262,11 +265,10 @@ void createWidgets(lv_obj_t* parent, void* userData) {
auto* auto_connect_label = lv_label_create(ctx->autoConnectWrapper); auto* auto_connect_label = lv_label_create(ctx->autoConnectWrapper);
lv_label_set_text(auto_connect_label, "Auto-connect"); lv_label_set_text(auto_connect_label, "Auto-connect");
lv_obj_align(auto_connect_label, LV_ALIGN_LEFT_MID, 0, 0); lv_obj_set_flex_grow(auto_connect_label, 1);
ctx->autoConnectSwitch = lv_switch_create(ctx->autoConnectWrapper); ctx->autoConnectSwitch = lv_switch_create(ctx->autoConnectWrapper);
lv_obj_add_event_cb(ctx->autoConnectSwitch, onToggleAutoConnect, LV_EVENT_VALUE_CHANGED, ctx); lv_obj_add_event_cb(ctx->autoConnectSwitch, onToggleAutoConnect, LV_EVENT_VALUE_CHANGED, ctx);
lv_obj_align(ctx->autoConnectSwitch, LV_ALIGN_RIGHT_MID, 0, 0);
} }
int32_t appMain(int argc, char* argv[]) { int32_t appMain(int argc, char* argv[]) {

View File

@ -13,6 +13,8 @@
#include <wifi/wifi_settings.h> #include <wifi/wifi_settings.h>
#include <lvgl/lvgl.h> #include <lvgl/lvgl.h>
#include <lvgl/theme.h>
#include <lvgl/widgets/card.h>
#include <lvgl/widgets/spinner.h> #include <lvgl/widgets/spinner.h>
#include <lvgl/widgets/toolbar.h> #include <lvgl/widgets/toolbar.h>
@ -49,7 +51,7 @@ struct Context {
lv_obj_t* password_textarea = nullptr; lv_obj_t* password_textarea = nullptr;
lv_obj_t* password_error = nullptr; lv_obj_t* password_error = nullptr;
lv_obj_t* connect_button = nullptr; lv_obj_t* connect_button = nullptr;
lv_obj_t* remember_switch = nullptr; lv_obj_t* remember_checkbox = nullptr;
lv_obj_t* connecting_spinner = nullptr; lv_obj_t* connecting_spinner = nullptr;
lv_obj_t* connection_error = nullptr; lv_obj_t* connection_error = nullptr;
@ -114,13 +116,13 @@ void setLoading(Context* ctx, bool loading) {
lv_obj_remove_flag(ctx->connecting_spinner, LV_OBJ_FLAG_HIDDEN); lv_obj_remove_flag(ctx->connecting_spinner, LV_OBJ_FLAG_HIDDEN);
lv_obj_add_state(ctx->password_textarea, LV_STATE_DISABLED); lv_obj_add_state(ctx->password_textarea, LV_STATE_DISABLED);
lv_obj_add_state(ctx->ssid_textarea, LV_STATE_DISABLED); lv_obj_add_state(ctx->ssid_textarea, LV_STATE_DISABLED);
lv_obj_add_state(ctx->remember_switch, LV_STATE_DISABLED); lv_obj_add_state(ctx->remember_checkbox, LV_STATE_DISABLED);
} else { } else {
lv_obj_remove_flag(ctx->connect_button, LV_OBJ_FLAG_HIDDEN); lv_obj_remove_flag(ctx->connect_button, LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(ctx->connecting_spinner, LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(ctx->connecting_spinner, LV_OBJ_FLAG_HIDDEN);
lv_obj_remove_state(ctx->password_textarea, LV_STATE_DISABLED); lv_obj_remove_state(ctx->password_textarea, LV_STATE_DISABLED);
lv_obj_remove_state(ctx->ssid_textarea, LV_STATE_DISABLED); lv_obj_remove_state(ctx->ssid_textarea, LV_STATE_DISABLED);
lv_obj_remove_state(ctx->remember_switch, LV_STATE_DISABLED); lv_obj_remove_state(ctx->remember_checkbox, LV_STATE_DISABLED);
} }
} }
@ -161,7 +163,7 @@ void onConnectPressed(lv_event_t* event) {
return; return;
} }
bool store = lv_obj_get_state(ctx->remember_switch) & LV_STATE_CHECKED; bool store = lv_obj_get_state(ctx->remember_checkbox) & LV_STATE_CHECKED;
setLoading(ctx, true); setLoading(ctx, true);
@ -198,26 +200,23 @@ void createBottomButtons(Context* ctx, lv_obj_t* parent) {
lv_obj_set_width(button_container, LV_PCT(100)); lv_obj_set_width(button_container, LV_PCT(100));
lv_obj_set_height(button_container, LV_SIZE_CONTENT); lv_obj_set_height(button_container, LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(button_container, 0, LV_STATE_DEFAULT); lv_obj_set_style_pad_all(button_container, 0, LV_STATE_DEFAULT);
lv_obj_set_style_pad_gap(button_container, 0, LV_STATE_DEFAULT); lv_obj_set_style_pad_gap(button_container, 4, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(button_container, 0, LV_STATE_DEFAULT); lv_obj_set_style_border_width(button_container, 0, LV_STATE_DEFAULT);
lv_obj_set_style_bg_opa(button_container, LV_OPA_TRANSP, LV_STATE_DEFAULT);
// "[x] Remember [Connect]", with everything centered vertically
lv_obj_set_flex_flow(button_container, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(button_container, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
ctx->remember_switch = lv_switch_create(button_container); ctx->remember_checkbox = lv_checkbox_create(button_container);
lv_obj_add_state(ctx->remember_switch, LV_STATE_CHECKED); lv_checkbox_set_text(ctx->remember_checkbox, "Remember");
lv_obj_align(ctx->remember_switch, LV_ALIGN_LEFT_MID, 0, 0); lv_obj_add_state(ctx->remember_checkbox, LV_STATE_CHECKED);
auto* remember_label = lv_label_create(button_container);
lv_label_set_text(remember_label, "Remember");
lv_obj_align(remember_label, LV_ALIGN_CENTER, 0, 0);
lv_obj_align_to(remember_label, ctx->remember_switch, LV_ALIGN_OUT_RIGHT_MID, 4, 0);
ctx->connecting_spinner = lvgl_spinner_create(button_container); ctx->connecting_spinner = lvgl_spinner_create(button_container);
lv_obj_align(ctx->connecting_spinner, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_add_flag(ctx->connecting_spinner, LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(ctx->connecting_spinner, LV_OBJ_FLAG_HIDDEN);
ctx->connect_button = lv_btn_create(button_container); ctx->connect_button = lv_btn_create(button_container);
auto* connect_label = lv_label_create(ctx->connect_button); auto* connect_label = lv_label_create(ctx->connect_button);
lv_label_set_text(connect_label, "Connect"); lv_label_set_text(connect_label, "Connect");
lv_obj_align(ctx->connect_button, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_add_event_cb(ctx->connect_button, onConnectPressed, LV_EVENT_SHORT_CLICKED, ctx); lv_obj_add_event_cb(ctx->connect_button, onConnectPressed, LV_EVENT_SHORT_CLICKED, ctx);
} }
@ -230,11 +229,42 @@ void destroyWidgets(void* userData) {
ctx->password_textarea = nullptr; ctx->password_textarea = nullptr;
ctx->password_error = nullptr; ctx->password_error = nullptr;
ctx->connect_button = nullptr; ctx->connect_button = nullptr;
ctx->remember_switch = nullptr; ctx->remember_checkbox = nullptr;
ctx->connecting_spinner = nullptr; ctx->connecting_spinner = nullptr;
ctx->connection_error = nullptr; ctx->connection_error = nullptr;
} }
/** A transparent row: "Title [content]" */
lv_obj_t* createRow(lv_obj_t* parent, const char* title) {
auto* row = lv_obj_create(parent);
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_all(row, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(row, 0, LV_STATE_DEFAULT);
lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, LV_STATE_DEFAULT);
lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE);
auto* label = lv_label_create(row);
lv_label_set_text(label, title);
lv_obj_set_flex_grow(label, 1);
return row;
}
/** A hidden label in the theme's error colour */
lv_obj_t* createErrorLabel(lv_obj_t* parent) {
auto* label = lv_label_create(parent);
lv_obj_set_width(label, LV_PCT(100));
lv_label_set_long_mode(label, LV_LABEL_LONG_MODE_WRAP);
// The monochrome theme has no error colour
if (!lvgl_theme_is_mono()) {
LvglThemeSettings theme_settings;
lvgl_theme_get_settings(&theme_settings);
lv_obj_set_style_text_color(label, theme_settings.color_error, LV_STATE_DEFAULT);
}
lv_obj_add_flag(label, LV_OBJ_FLAG_HIDDEN);
return label;
}
// TODO: Standardize dialogs // TODO: Standardize dialogs
void createWidgets(lv_obj_t* parent, void* userData) { void createWidgets(lv_obj_t* parent, void* userData) {
auto* ctx = static_cast<Context*>(userData); auto* ctx = static_cast<Context*>(userData);
@ -247,76 +277,39 @@ void createWidgets(lv_obj_t* parent, void* userData) {
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
auto* wrapper = lv_obj_create(parent); auto* wrapper = lv_obj_create(parent);
lv_obj_set_style_border_width(wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_width(wrapper, LV_PCT(100)); lv_obj_set_width(wrapper, LV_PCT(100));
lv_obj_set_flex_grow(wrapper, 1); lv_obj_set_flex_grow(wrapper, 1);
lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN);
auto* card = lvgl_card_create(wrapper);
lv_obj_set_size(card, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN);
// SSID // SSID
auto* ssid_wrapper = lv_obj_create(wrapper); auto* ssid_row = createRow(card, "Network");
lv_obj_set_width(ssid_wrapper, LV_PCT(100)); ctx->ssid_textarea = lv_textarea_create(ssid_row);
lv_obj_set_height(ssid_wrapper, LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(ssid_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_pad_gap(ssid_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(ssid_wrapper, 0, LV_STATE_DEFAULT);
auto* ssid_label_wrapper = lv_obj_create(ssid_wrapper);
lv_obj_set_width(ssid_label_wrapper, LV_PCT(50));
lv_obj_set_height(ssid_label_wrapper, LV_SIZE_CONTENT);
lv_obj_align(ssid_label_wrapper, LV_ALIGN_LEFT_MID, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(ssid_label_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_pad_left(ssid_label_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_pad_right(ssid_label_wrapper, 0, LV_STATE_DEFAULT);
auto* ssid_label = lv_label_create(ssid_label_wrapper);
lv_label_set_text(ssid_label, "Network:");
ctx->ssid_textarea = lv_textarea_create(ssid_wrapper);
lv_textarea_set_one_line(ctx->ssid_textarea, true); lv_textarea_set_one_line(ctx->ssid_textarea, true);
lv_obj_align(ctx->ssid_textarea, LV_ALIGN_RIGHT_MID, 0, 0); lv_obj_set_width(ctx->ssid_textarea, LV_PCT(60));
lv_obj_set_width(ctx->ssid_textarea, LV_PCT(50));
ctx->ssid_error = lv_label_create(wrapper); ctx->ssid_error = createErrorLabel(card);
lv_obj_set_style_text_color(ctx->ssid_error, lv_color_make(255, 50, 50), LV_STATE_DEFAULT);
lv_obj_add_flag(ctx->ssid_error, LV_OBJ_FLAG_HIDDEN);
// Password // Password
auto* password_wrapper = lv_obj_create(wrapper); auto* password_row = createRow(card, "Password");
lv_obj_set_width(password_wrapper, LV_PCT(100)); ctx->password_textarea = lv_textarea_create(password_row);
lv_obj_set_height(password_wrapper, LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(password_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_pad_gap(password_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(password_wrapper, 0, LV_STATE_DEFAULT);
auto* password_label_wrapper = lv_obj_create(password_wrapper);
lv_obj_set_width(password_label_wrapper, LV_PCT(50));
lv_obj_set_height(password_label_wrapper, LV_SIZE_CONTENT);
lv_obj_align_to(password_label_wrapper, password_wrapper, LV_ALIGN_LEFT_MID, 0, 0);
lv_obj_set_style_border_width(password_label_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_pad_left(password_label_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_pad_right(password_label_wrapper, 0, LV_STATE_DEFAULT);
auto* password_label = lv_label_create(password_label_wrapper);
lv_label_set_text(password_label, "Password:");
ctx->password_textarea = lv_textarea_create(password_wrapper);
lv_textarea_set_one_line(ctx->password_textarea, true); lv_textarea_set_one_line(ctx->password_textarea, true);
lv_textarea_set_password_mode(ctx->password_textarea, true); lv_textarea_set_password_mode(ctx->password_textarea, true);
lv_obj_align(ctx->password_textarea, LV_ALIGN_RIGHT_MID, 0, 0); lv_obj_set_width(ctx->password_textarea, LV_PCT(60));
lv_obj_set_width(ctx->password_textarea, LV_PCT(50));
ctx->password_error = lv_label_create(wrapper); ctx->password_error = createErrorLabel(card);
lv_obj_set_style_text_color(ctx->password_error, lv_color_make(255, 50, 50), LV_STATE_DEFAULT);
lv_obj_add_flag(ctx->password_error, LV_OBJ_FLAG_HIDDEN);
// Connection error // Connection error
ctx->connection_error = lv_label_create(wrapper); ctx->connection_error = createErrorLabel(card);
lv_obj_set_style_text_color(ctx->connection_error, lv_color_make(255, 50, 50), LV_STATE_DEFAULT);
lv_obj_add_flag(ctx->connection_error, LV_OBJ_FLAG_HIDDEN);
// Bottom buttons // Bottom buttons
createBottomButtons(ctx, wrapper); createBottomButtons(ctx, card);
// Init from app parameters // Init from app parameters
if (!ctx->initialSsid.empty()) { if (!ctx->initialSsid.empty()) {

View File

@ -13,6 +13,7 @@
#include <tactility/log.h> #include <tactility/log.h>
#include <lvgl/lvgl.h> #include <lvgl/lvgl.h>
#include <lvgl/widgets/card.h>
#include <wifi/wifi_settings.h> #include <wifi/wifi_settings.h>
@ -107,15 +108,34 @@ void View::showDetails(lv_event_t* event) {
} }
} }
void View::createSsidListItem(const WifiApRecord& record, bool isConnecting, size_t index) { /** Creates a title with a card below it, and returns the list in the card */
static lv_obj_t* createSection(lv_obj_t* parent, const char* title) {
auto* label = lv_label_create(parent);
lv_label_set_text(label, title);
auto* card = lvgl_card_create(parent);
lv_obj_set_size(card, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(card, 0, LV_STATE_DEFAULT);
// The list items' pressed and focused backgrounds follow the card's rounded corners
lv_obj_set_style_clip_corner(card, true, LV_STATE_DEFAULT);
// The card provides the background
auto* list = lv_list_create(card);
lv_obj_set_size(list, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_bg_opa(list, LV_OPA_TRANSP, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(list, 0, LV_STATE_DEFAULT);
return list;
}
void View::createSsidListItem(lv_obj_t* list, const WifiApRecord& record, bool isConnecting, size_t index) {
if (isConnecting) { if (isConnecting) {
auto* button = lv_list_add_button(networks_list, LV_SYMBOL_WIFI, record.ssid); auto* button = lv_list_add_button(list, LV_SYMBOL_WIFI, record.ssid);
lv_obj_add_event_cb(button, showDetails, LV_EVENT_SHORT_CLICKED, this); lv_obj_add_event_cb(button, showDetails, LV_EVENT_SHORT_CLICKED, this);
} else { } else {
const std::string auth_info = (record.authentication_type == WIFI_AUTHENTICATION_TYPE_OPEN) ? "(open) " : " "; const std::string auth_info = (record.authentication_type == WIFI_AUTHENTICATION_TYPE_OPEN) ? "(open) " : " ";
const auto percentage = mapRssiToPercentage(record.rssi); const auto percentage = mapRssiToPercentage(record.rssi);
const auto label = std::format("{} {}{}%", std::string(record.ssid), auth_info, percentage); const auto label = std::format("{} {}{}%", std::string(record.ssid), auth_info, percentage);
auto* button = lv_list_add_button(networks_list, nullptr, label.c_str()); auto* button = lv_list_add_button(list, nullptr, label.c_str());
lv_obj_set_user_data(button, reinterpret_cast<void*>(index)); lv_obj_set_user_data(button, reinterpret_cast<void*>(index));
if (wifi_settings_contains(record.ssid)) { if (wifi_settings_contains(record.ssid)) {
lv_obj_add_event_cb(button, showDetails, LV_EVENT_SHORT_CLICKED, this); lv_obj_add_event_cb(button, showDetails, LV_EVENT_SHORT_CLICKED, this);
@ -142,22 +162,20 @@ void View::updateNetworkList() {
// Enable on boot // Enable on boot
// An unstyled row insets the wrapper like the list items' text: margins would make a full-width item overflow the list auto* enable_on_boot_wrapper = lv_obj_create(networks_list);
auto* enable_on_boot_row = lv_obj_create(networks_list);
lv_obj_remove_style_all(enable_on_boot_row);
lv_obj_set_size(enable_on_boot_row, LV_PCT(100), LV_SIZE_CONTENT);
const int32_t enable_on_boot_inset = (lvgl_get_ui_density() == LVGL_UI_DENSITY_COMPACT) ? 2 : LV_DPX(16);
lv_obj_set_style_pad_hor(enable_on_boot_row, enable_on_boot_inset, LV_STATE_DEFAULT);
auto* enable_on_boot_wrapper = lv_obj_create(enable_on_boot_row);
lv_obj_set_size(enable_on_boot_wrapper, LV_PCT(100), LV_SIZE_CONTENT); lv_obj_set_size(enable_on_boot_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(enable_on_boot_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(enable_on_boot_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_bg_opa(enable_on_boot_wrapper, LV_OPA_TRANSP, LV_STATE_DEFAULT);
lv_obj_set_flex_flow(enable_on_boot_wrapper, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(enable_on_boot_wrapper, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_remove_flag(enable_on_boot_wrapper, LV_OBJ_FLAG_SCROLLABLE);
auto* enable_label = lv_label_create(enable_on_boot_wrapper); auto* enable_label = lv_label_create(enable_on_boot_wrapper);
lv_label_set_text(enable_label, "Enable on boot"); lv_label_set_text(enable_label, "Enable on boot");
lv_obj_align(enable_label, LV_ALIGN_LEFT_MID, 0, 0); lv_obj_set_flex_grow(enable_label, 1);
enable_on_boot_switch = lv_switch_create(enable_on_boot_wrapper); enable_on_boot_switch = lv_switch_create(enable_on_boot_wrapper);
lv_obj_align(enable_on_boot_switch, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_add_event_cb(enable_on_boot_switch, onEnableOnBootSwitchChanged, LV_EVENT_VALUE_CHANGED, bindings); lv_obj_add_event_cb(enable_on_boot_switch, onEnableOnBootSwitchChanged, LV_EVENT_VALUE_CHANGED, bindings);
lv_obj_add_event_cb(enable_on_boot_wrapper, onEnableOnBootParentClicked, LV_EVENT_SHORT_CLICKED, enable_on_boot_switch); lv_obj_add_event_cb(enable_on_boot_wrapper, onEnableOnBootParentClicked, LV_EVENT_SHORT_CLICKED, enable_on_boot_switch);
@ -180,15 +198,14 @@ void View::updateNetworkList() {
for (int i = 0; i < ap_records.size(); ++i) { for (int i = 0; i < ap_records.size(); ++i) {
auto& record = ap_records[i]; auto& record = ap_records[i];
if (record.ssid == connection_target) { if (record.ssid == connection_target) {
lv_list_add_text(networks_list, "Connected"); createSsidListItem(createSection(networks_list, "Connected"), record, false, i);
createSsidListItem(record, false, i);
added_connected = true; added_connected = true;
break; break;
} }
} }
} }
lv_list_add_text(networks_list, "Other networks"); auto* networks_section = createSection(networks_list, "Networks");
std::set<std::string> used_ssids; std::set<std::string> used_ssids;
if (!ap_records.empty()) { if (!ap_records.empty()) {
for (int i = 0; i < ap_records.size(); ++i) { for (int i = 0; i < ap_records.size(); ++i) {
@ -200,7 +217,7 @@ void View::updateNetworkList() {
!connection_target.empty(); !connection_target.empty();
bool skip = connection_target_match && added_connected; bool skip = connection_target_match && added_connected;
if (!skip) { if (!skip) {
createSsidListItem(record, is_connecting, i); createSsidListItem(networks_section, record, is_connecting, i);
} }
used_ssids.insert(record.ssid); used_ssids.insert(record.ssid);
} }
@ -211,8 +228,7 @@ void View::updateNetworkList() {
lv_obj_add_flag(networks_list, LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(networks_list, LV_OBJ_FLAG_HIDDEN);
} else { } else {
lv_obj_clear_flag(networks_list, LV_OBJ_FLAG_HIDDEN); lv_obj_clear_flag(networks_list, LV_OBJ_FLAG_HIDDEN);
lv_obj_t* label = lv_label_create(networks_list); lv_list_add_text(networks_section, "No networks found.");
lv_label_set_text(label, "No networks found.");
} }
connect_to_hidden = lv_button_create(networks_list); connect_to_hidden = lv_button_create(networks_list);
@ -296,9 +312,11 @@ void View::init(uint32_t newAppInstanceId, lv_obj_t* parent) {
// Networks // Networks
networks_list = lv_list_create(parent); networks_list = lv_obj_create(parent);
lv_obj_set_flex_grow(networks_list, 1); lv_obj_set_flex_grow(networks_list, 1);
lv_obj_set_width(networks_list, LV_PCT(100)); lv_obj_set_width(networks_list, LV_PCT(100));
lv_obj_set_flex_flow(networks_list, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_border_width(networks_list, 0, LV_STATE_DEFAULT);
} }
void View::update() { void View::update() {

View File

@ -197,8 +197,8 @@ lv_obj_t* statusbar_create(lv_obj_t* parent) {
lv_obj_set_style_pad_hor(obj, 2, LV_STATE_DEFAULT); lv_obj_set_style_pad_hor(obj, 2, LV_STATE_DEFAULT);
lv_obj_center(obj); lv_obj_center(obj);
auto icon_size = lvgl_get_statusbar_icon_font_height(); auto icon_size = lvgl_get_statusbar_icon_font_height();
auto ui_density = lvgl_get_ui_density(); const bool is_compact = lvgl_theme_is_compact();
auto icon_padding = (ui_density != LVGL_UI_DENSITY_COMPACT) ? static_cast<uint32_t>(icon_size * 0.2f) : 2; auto icon_padding = !is_compact ? static_cast<uint32_t>(icon_size * 0.2f) : 2;
lv_display_t* display = lv_obj_get_display(obj); lv_display_t* display = lv_obj_get_display(obj);
DisplayShape shape; DisplayShape shape;
@ -252,7 +252,7 @@ lv_obj_t* statusbar_create(lv_obj_t* parent) {
// The monochrome theme's focus ring doesn't contrast with the statusbar, so the icons get their own // The monochrome theme's focus ring doesn't contrast with the statusbar, so the icons get their own
const bool own_focus_ring = lvgl_theme_is_mono(); const bool own_focus_ring = lvgl_theme_is_mono();
const int32_t focus_width = (ui_density != LVGL_UI_DENSITY_COMPACT) ? 2 : 1; const int32_t focus_width = !is_compact ? 2 : 1;
const lv_color_t focus_color = lv_obj_get_style_text_color(obj, LV_PART_MAIN); const lv_color_t focus_color = lv_obj_get_style_text_color(obj, LV_PART_MAIN);
statusbar_data.mutex.lock(MAX_TICKS); statusbar_data.mutex.lock(MAX_TICKS);

View File

@ -11,6 +11,9 @@ void configureTheme(const settings::appearance::AppearanceSettings& settings) {
theme_settings.is_dark = settings.themeMode == settings::appearance::ThemeMode::Dark; theme_settings.is_dark = settings.themeMode == settings::appearance::ThemeMode::Dark;
} }
theme_settings.is_mono = settings.monoTheme; theme_settings.is_mono = settings.monoTheme;
if (settings.densityMode != settings::appearance::DensityMode::DeviceDefault) {
theme_settings.is_compact = settings.densityMode == settings::appearance::DensityMode::Compact;
}
theme_settings.animations_enabled = settings.animationsEnabled; theme_settings.animations_enabled = settings.animationsEnabled;
if (settings.primaryColor.has_value()) { if (settings.primaryColor.has_value()) {
theme_settings.color_primary = lv_color_hex(*settings.primaryColor); theme_settings.color_primary = lv_color_hex(*settings.primaryColor);
@ -21,6 +24,10 @@ void configureTheme(const settings::appearance::AppearanceSettings& settings) {
if (settings.errorColor.has_value()) { if (settings.errorColor.has_value()) {
theme_settings.color_error = lv_color_hex(*settings.errorColor); theme_settings.color_error = lv_color_hex(*settings.errorColor);
} }
if (settings.surfaceColor.has_value()) {
theme_settings.surface_override = true;
theme_settings.color_surface = lv_color_hex(settings::appearance::getSurfaceColor(*settings.surfaceColor, settings.surfaceTintLevel, theme_settings.is_dark));
}
lvgl_theme_set_settings(&theme_settings); lvgl_theme_set_settings(&theme_settings);
} }

View File

@ -18,10 +18,31 @@ constexpr auto* SETTINGS_KEY_REGULAR_FONT_PATH = "regularFontPath";
constexpr auto* SETTINGS_KEY_MONO_FONT_PATH = "monoFontPath"; constexpr auto* SETTINGS_KEY_MONO_FONT_PATH = "monoFontPath";
constexpr auto* SETTINGS_KEY_THEME_MODE = "themeMode"; constexpr auto* SETTINGS_KEY_THEME_MODE = "themeMode";
constexpr auto* SETTINGS_KEY_MONO_THEME = "monoTheme"; constexpr auto* SETTINGS_KEY_MONO_THEME = "monoTheme";
constexpr auto* SETTINGS_KEY_DENSITY = "density";
constexpr auto* SETTINGS_KEY_ANIMATIONS = "animations"; constexpr auto* SETTINGS_KEY_ANIMATIONS = "animations";
constexpr auto* SETTINGS_KEY_PRIMARY_COLOR = "primaryColor"; constexpr auto* SETTINGS_KEY_PRIMARY_COLOR = "primaryColor";
constexpr auto* SETTINGS_KEY_SECONDARY_COLOR = "secondaryColor"; constexpr auto* SETTINGS_KEY_SECONDARY_COLOR = "secondaryColor";
constexpr auto* SETTINGS_KEY_ERROR_COLOR = "errorColor"; constexpr auto* SETTINGS_KEY_ERROR_COLOR = "errorColor";
constexpr auto* SETTINGS_KEY_SURFACE_COLOR = "surfaceColor";
constexpr auto* SETTINGS_KEY_SURFACE_TINT_LEVEL = "surfaceTintLevel";
// The share of the surface colour per tint level, in percent
constexpr uint32_t SURFACE_TINT_PERCENTAGES[SURFACE_TINT_LEVEL_COUNT] = { 15, 25, 40 };
static uint32_t mixColor(uint32_t color, uint32_t base, uint32_t colorPercentage) {
uint32_t result = 0;
for (int shift = 0; shift <= 16; shift += 8) {
const uint32_t channel = (((color >> shift) & 0xFF) * colorPercentage + ((base >> shift) & 0xFF) * (100 - colorPercentage) + 50) / 100;
result |= channel << shift;
}
return result;
}
uint32_t getSurfaceColor(uint32_t color, uint8_t tintLevel, bool isDark) {
const uint32_t base = isDark ? 0x000000 : 0xFFFFFF;
const uint8_t level = tintLevel < SURFACE_TINT_LEVEL_COUNT ? tintLevel : SURFACE_TINT_LEVEL_COUNT - 1;
return mixColor(color, base, SURFACE_TINT_PERCENTAGES[level]);
}
static std::optional<uint32_t> parseColor(const std::map<std::string, std::string>& map, const char* key) { static std::optional<uint32_t> parseColor(const std::map<std::string, std::string>& map, const char* key) {
const auto entry = map.find(key); const auto entry = map.find(key);
@ -60,6 +81,17 @@ static const char* toString(ThemeMode mode) {
} }
} }
static const char* toString(DensityMode mode) {
switch (mode) {
case DensityMode::Regular:
return "regular";
case DensityMode::Compact:
return "compact";
default:
return "default";
}
}
static std::string getSettingsFilePath() { static std::string getSettingsFilePath() {
char path[256]; char path[256];
if (app_paths_get_user_data_path("tactility.appearance", "appearance.properties", path, sizeof(path)) != ERROR_NONE) { if (app_paths_get_user_data_path("tactility.appearance", "appearance.properties", path, sizeof(path)) != ERROR_NONE) {
@ -99,12 +131,28 @@ bool load(AppearanceSettings& settings) {
if (const auto entry = map.find(SETTINGS_KEY_MONO_THEME); entry != map.end()) { if (const auto entry = map.find(SETTINGS_KEY_MONO_THEME); entry != map.end()) {
settings.monoTheme = entry->second == "true"; settings.monoTheme = entry->second == "true";
} }
if (const auto entry = map.find(SETTINGS_KEY_DENSITY); entry != map.end()) {
if (entry->second == "regular") {
settings.densityMode = DensityMode::Regular;
} else if (entry->second == "compact") {
settings.densityMode = DensityMode::Compact;
}
}
if (const auto entry = map.find(SETTINGS_KEY_ANIMATIONS); entry != map.end()) { if (const auto entry = map.find(SETTINGS_KEY_ANIMATIONS); entry != map.end()) {
settings.animationsEnabled = entry->second != "false"; settings.animationsEnabled = entry->second != "false";
} }
settings.primaryColor = parseColor(map, SETTINGS_KEY_PRIMARY_COLOR); settings.primaryColor = parseColor(map, SETTINGS_KEY_PRIMARY_COLOR);
settings.secondaryColor = parseColor(map, SETTINGS_KEY_SECONDARY_COLOR); settings.secondaryColor = parseColor(map, SETTINGS_KEY_SECONDARY_COLOR);
settings.errorColor = parseColor(map, SETTINGS_KEY_ERROR_COLOR); settings.errorColor = parseColor(map, SETTINGS_KEY_ERROR_COLOR);
settings.surfaceColor = parseColor(map, SETTINGS_KEY_SURFACE_COLOR);
if (const auto entry = map.find(SETTINGS_KEY_SURFACE_TINT_LEVEL); entry != map.end()) {
const std::string& text = entry->second;
uint32_t level = 0;
const auto [end, error] = std::from_chars(text.data(), text.data() + text.size(), level);
if (error == std::errc {} && end == text.data() + text.size() && level < SURFACE_TINT_LEVEL_COUNT) {
settings.surfaceTintLevel = static_cast<uint8_t>(level);
}
}
return true; return true;
} }
@ -127,10 +175,13 @@ bool save(const AppearanceSettings& settings) {
map[SETTINGS_KEY_MONO_FONT_PATH] = settings.monoFontPath; map[SETTINGS_KEY_MONO_FONT_PATH] = settings.monoFontPath;
map[SETTINGS_KEY_THEME_MODE] = toString(settings.themeMode); map[SETTINGS_KEY_THEME_MODE] = toString(settings.themeMode);
map[SETTINGS_KEY_MONO_THEME] = settings.monoTheme ? "true" : "false"; map[SETTINGS_KEY_MONO_THEME] = settings.monoTheme ? "true" : "false";
map[SETTINGS_KEY_DENSITY] = toString(settings.densityMode);
map[SETTINGS_KEY_ANIMATIONS] = settings.animationsEnabled ? "true" : "false"; map[SETTINGS_KEY_ANIMATIONS] = settings.animationsEnabled ? "true" : "false";
putColor(map, SETTINGS_KEY_PRIMARY_COLOR, settings.primaryColor); putColor(map, SETTINGS_KEY_PRIMARY_COLOR, settings.primaryColor);
putColor(map, SETTINGS_KEY_SECONDARY_COLOR, settings.secondaryColor); putColor(map, SETTINGS_KEY_SECONDARY_COLOR, settings.secondaryColor);
putColor(map, SETTINGS_KEY_ERROR_COLOR, settings.errorColor); putColor(map, SETTINGS_KEY_ERROR_COLOR, settings.errorColor);
putColor(map, SETTINGS_KEY_SURFACE_COLOR, settings.surfaceColor);
map[SETTINGS_KEY_SURFACE_TINT_LEVEL] = std::to_string(settings.surfaceTintLevel);
return file::savePropertiesFile(settings_path, map); return file::savePropertiesFile(settings_path, map);
} }