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 source.models import *
from .exception import DevicetreeException
from .printing import print_warning
def write_include(file, include: IncludeC, verbose: bool):
if verbose:
@ -59,6 +60,35 @@ def find_phandle(devices: list[Device], phandle: str):
return f"&{get_device_node_name_safe(device)}"
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:
type = property.type
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(file, device, bindings, devices, type_name)
# 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"\t.address = {address_value},\n")
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")
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():
print("Running test_compile_missing_config...")
with tempfile.TemporaryDirectory() as output_dir:
@ -322,7 +402,9 @@ if __name__ == "__main__":
test_minmax_out_of_range_default_fails,
test_minmax_symbolic_value_skips_validation,
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

View File

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

View File

@ -7,6 +7,7 @@
## 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)
- 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.

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)` (`_Znaj` / `_ZdaPvj`)
- `operator new[](unsigned int, std::nothrow_t const&)` (`_ZnajRKSt9nothrow_t`)
- `operator delete(void*)` / `operator delete[](void*)` (`_ZdlPv` / `_ZdaPv`) - the unsized forms,
used instead of the above when the compiler determines no size is needed
- `std::nothrow`

View File

@ -31,6 +31,7 @@ extern "C" {
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* _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)
// 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.
@ -572,6 +573,7 @@ static const ModuleSymbol SYMBOLS[] = {
DEFINE_MODULE_SYMBOL(_Znwj), // operator new(unsigned int)
DEFINE_MODULE_SYMBOL(_ZdlPvj), // operator delete(void*, 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(_ZdlPv), // operator delete(void*)
DEFINE_MODULE_SYMBOL(_ZdaPv), // operator delete[](void*)

View File

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

View File

@ -9,7 +9,7 @@ extern "C" {
#endif
/** 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
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_FORUM "\xEE\xA2\xAF"
#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_HUB "\xEE\xA7\xB4"
#define LVGL_ICON_SHARED_INFO "\xEE\xA2\x8E"
#define LVGL_ICON_SHARED_IMAGE "\xEE\x8F\xB4"
#define LVGL_ICON_SHARED_KEYBOARD_ARROW_UP "\xEE\x8C\x96"
#define LVGL_ICON_SHARED_LIGHTBULB "\xEE\xA4\x8F"
@ -31,6 +33,7 @@
#define LVGL_ICON_SHARED_LISTS "\xEE\xA6\xB9"
#define LVGL_ICON_SHARED_MAIL "\xEE\x85\x99"
#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_MORE_VERT "\xEE\x97\x94"
#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_REFRESH "\xEE\x97\x95"
#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_TOOLBAR "\xEE\xA7\xB7"
#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_CAMERA "\xEE\x8E\xAF"
#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);
/**
* @brief Gets the desired UI density for the target hardware.
* The density is defined in the `device.properties` of a hardware device.
* This setting is read by CMakeLists.txt and passed as a target compile definition of the LVGL module.
* @brief Gets the UI density of the theme settings.
* @deprecated Use lvgl_theme_is_compact() from <lvgl/theme.h>
* @return the UI density
*/
__attribute__((deprecated("Use lvgl_theme_is_compact()")))
enum UiDensity lvgl_get_ui_density(void);
#ifdef __cplusplus

View File

@ -17,6 +17,11 @@ typedef struct {
lv_color_t color_primary;
lv_color_t color_secondary;
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;
/**
@ -37,6 +42,13 @@ void lvgl_theme_get_default_settings(LvglThemeSettings* settings);
*/
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.
* @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
// 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) {
lv_indev_t* indev = lv_indev_active();
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;
}
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) {
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);
// 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_key, LV_EVENT_KEY, nullptr);
lv_obj_add_flag(container, GRID_NAVIGATION_FLAG);
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_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_key);
lv_obj_remove_flag(container, GRID_NAVIGATION_FLAG);
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) {
lv_obj_t* focused = lv_group_get_focused(group);
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);
if (child != nullptr) {
lv_gridnav_set_focused(focused, child, LV_ANIM_ON);
scroll_to_focused_child(focused);
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);
if (child != nullptr) {
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",
"forum",
"gamepad",
"hard_disk",
"help",
"hub",
"info",
"image",
"keyboard_arrow_up",
"lightbulb",
@ -33,6 +35,7 @@ const char* const lvgl_icon_shared_names[] = {
"lists",
"mail",
"menu",
"memory",
"mop",
"more_vert",
"music_note",
@ -41,6 +44,7 @@ const char* const lvgl_icon_shared_names[] = {
"power_settings_new",
"refresh",
"search",
"select_window_2",
"settings",
"toolbar",
"navigation",
@ -52,6 +56,7 @@ const char* const lvgl_icon_shared_names[] = {
"logo_dev",
"camera",
"lightstrip",
"phone_android",
};
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
#include <lvgl/lvgl.h>
#include <lvgl/theme.h>
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_set_settings),
DEFINE_MODULE_SYMBOL(lvgl_theme_is_mono),
DEFINE_MODULE_SYMBOL(lvgl_theme_is_compact),
// lvgl_badge
DEFINE_MODULE_SYMBOL(lvgl_badge_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_secondary = material_config.color_secondary;
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) {
@ -48,6 +51,12 @@ bool lvgl_theme_is_mono() {
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() {
switch (active_theme) {
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) {
LvglThemeSettings 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 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_secondary = settings.color_secondary;
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);
}
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_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_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_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 COLOR_SURFACE_CONTAINER_LOW (lv_color_mix(theme->config.color_primary, theme->config.is_dark ? NEUTRAL_10 : NEUTRAL_96, TINT_NEUTRAL))
#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_HIGH (lv_color_mix(theme->config.color_primary, theme->config.is_dark ? NEUTRAL_17 : NEUTRAL_92, 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 SURFACE_LIFT (theme->config.is_dark ? lv_color_hex(0xFFFFFF) : lv_color_hex(0x000000))
#define SURFACE_SINK (theme->config.is_dark ? lv_color_hex(0x000000) : lv_color_hex(0xFFFFFF))
#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_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 (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_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))
@ -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_VER (theme->config.is_compact ? 2 : LV_DPX_CALC(theme->disp_dpi, 6))
#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_GAP (theme->config.is_compact ? 3 : 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 ? 4 : LV_DPX_CALC(theme->disp_dpi, 6))
#define BADGE_COLOR COLOR_SECONDARY
#define BADGE_SIZE (theme->config.is_compact ? 4 : LV_DPX_CALC(theme->disp_dpi, 6))
#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_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 APP_BAR_COLOR (lv_color_mix(theme->config.color_primary, NEUTRAL_22, TINT_NEUTRAL))
#define APP_BAR_CONTENT_COLOR lv_color_white()
#define APP_BAR_COLOR COLOR_SURFACE_CONTAINER_LOW
#define APP_BAR_CONTENT_COLOR COLOR_ON_SURFACE
#define APP_BAR_PRESSED_OPACITY (LV_OPA_20)
#define APP_BAR_PAD_HOR (theme->config.is_compact ? 0 : 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_WIDTH DENSITY_BORDER
#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_SELECTED_LABEL_COLOR COLOR_PRIMARY
#define TAB_INDICATOR_COLOR COLOR_PRIMARY
#define TAB_INDICATOR_WIDTH (theme->config.is_compact ? 2 : LV_DPX_CALC(theme->disp_dpi, 3))
#define TAB_PAD (theme->config.is_compact ? 2 : SPACE_1)
#define TAB_SHAPE SHAPE_CONTROL
#define TAB_SELECTED_COLOR COLOR_SECONDARY_CONTAINER
#define TAB_SELECTED_LABEL_COLOR COLOR_ON_SECONDARY_CONTAINER
#define TAB_PAGE_PAD DENSITY_CONTAINER_PAD
#define DIALOG_COLOR COLOR_SURFACE_CONTAINER_HIGH
#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_secondary, b->color_secondary) &&
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_normal == b->font_normal &&
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_pad_hor(&theme->styles.chip, CHIP_PAD_HOR);
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);
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_ver(&theme->styles.button, BUTTON_PAD_VER);
lv_style_set_pad_gap(&theme->styles.button, BUTTON_GAP);
lv_style_set_margin_all(&theme->styles.button, FOCUS_WIDTH + FOCUS_OFFSET);
#endif
#if LV_USE_BUTTON
@ -923,6 +933,7 @@ static void style_init(my_theme_t * theme)
style_init_reset(&theme->styles.switch_track);
lv_style_set_width(&theme->styles.switch_track, SWITCH_WIDTH);
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_opa(&theme->styles.switch_track, LV_OPA_COVER);
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);
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
style_init_reset(&theme->styles.slider_track_size);
@ -1222,25 +1234,27 @@ static void style_init(my_theme_t * theme)
#if LV_USE_TABVIEW
style_init_reset(&theme->styles.tabs);
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, DENSITY_BORDER);
lv_style_set_border_side(&theme->styles.tabs, LV_BORDER_SIDE_BOTTOM);
lv_style_set_bg_color(&theme->styles.tabs, TABS_COLOR);
lv_style_set_bg_opa(&theme->styles.tabs, LV_OPA_COVER);
lv_style_set_border_width(&theme->styles.tabs, 0);
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
#if LV_USE_BUTTON && LV_USE_TABVIEW
style_init_reset(&theme->styles.tab);
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_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
#if LV_USE_BUTTON && LV_USE_TABVIEW
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_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
#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->color_primary = lv_color_hex(0x00BBFF);
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_normal = 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);
return;
}
/* label */
lv_obj_add_style(obj, &theme->styles.focus_ring, LV_STATE_FOCUS_KEY);
return;
}
#endif

View File

@ -16,6 +16,8 @@ typedef struct {
lv_color_t color_primary;
lv_color_t color_secondary;
lv_color_t color_error;
bool surface_override;
lv_color_t color_surface;
const lv_font_t * font_small;
const lv_font_t * font_normal;
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_VER (theme->config.is_compact ? 2 : SPACE_2)
#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_GAP (theme->config.is_compact ? 3 : 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 ? 4 : LV_DPX_CALC(theme->disp_dpi, 6))
#define BADGE_COLOR COLOR_ON_SURFACE
#define BADGE_SIZE (theme->config.is_compact ? 4 : LV_DPX_CALC(theme->disp_dpi, 6))
#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_BORDER_COLOR COLOR_OUTLINE
#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_GAP (theme->config.is_compact ? 2 : SPACE_1)
#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_GAP (theme->config.is_compact ? 3 : SPACE_4)
#define LIST_DIVIDER_COLOR COLOR_OUTLINE
#define TABS_DIVIDER_COLOR COLOR_OUTLINE
#define TAB_INDICATOR_COLOR COLOR_PRIMARY
#define TAB_INDICATOR_WIDTH (theme->config.is_compact ? LINE_MEDIUM : LINE_THICK)
#define TABS_PAD (theme->config.is_compact ? 2 : SPACE_1)
#define TABS_GAP (theme->config.is_compact ? 2 : SPACE_1)
#define TAB_PAD (theme->config.is_compact ? 2 : SPACE_1)
#define TAB_SHAPE SHAPE_CONTROL
#define TAB_PAGE_PAD DENSITY_CONTAINER_PAD
#define DIALOG_COLOR COLOR_SURFACE
#define DIALOG_BORDER_COLOR COLOR_OUTLINE
@ -327,9 +327,6 @@ typedef struct {
#if LV_USE_BUTTON && LV_USE_TABVIEW
lv_style_t tab;
#endif
#if LV_USE_BUTTON && LV_USE_TABVIEW
lv_style_t tab_selected;
#endif
#if LV_USE_TABVIEW
lv_style_t tab_page;
#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_pad_hor(&theme->styles.chip, CHIP_PAD_HOR);
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
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_ver(&theme->styles.button, BUTTON_PAD_VER);
lv_style_set_pad_gap(&theme->styles.button, BUTTON_GAP);
lv_style_set_margin_all(&theme->styles.button, FOCUS_WIDTH + FOCUS_OFFSET);
#endif
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_text_color(&theme->styles.app_bar, APP_BAR_TITLE_COLOR);
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, BORDER);
lv_style_set_border_side(&theme->styles.app_bar, LV_BORDER_SIDE_BOTTOM);
lv_style_set_border_width(&theme->styles.app_bar, 0);
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_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);
lv_style_set_width(&theme->styles.switch_track, SWITCH_WIDTH);
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_opa(&theme->styles.switch_track, LV_OPA_COVER);
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);
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
style_init_reset(&theme->styles.slider_track_size);
@ -897,9 +896,9 @@ static void style_init(my_theme_t * theme)
#if LV_USE_TABVIEW
style_init_reset(&theme->styles.tabs);
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, BORDER);
lv_style_set_border_side(&theme->styles.tabs, LV_BORDER_SIDE_BOTTOM);
lv_style_set_border_width(&theme->styles.tabs, 0);
lv_style_set_pad_all(&theme->styles.tabs, TABS_PAD);
lv_style_set_pad_gap(&theme->styles.tabs, TABS_GAP);
#endif
#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_text_color(&theme->styles.tab, COLOR_ON_SURFACE);
lv_style_set_border_width(&theme->styles.tab, 0);
lv_style_set_radius(&theme->styles.tab, 0);
#endif
#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);
lv_style_set_radius(&theme->styles.tab, TAB_SHAPE);
lv_style_set_pad_all(&theme->styles.tab, TAB_PAD);
#endif
#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)) {
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.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.edit_ring, LV_STATE_EDITED);
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);
return;
}
/* label */
lv_obj_add_style(obj, &theme->styles.focus_ring, LV_STATE_FOCUS_KEY);
return;
}
#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_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_style_pad_all(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);
}

View File

@ -7,6 +7,7 @@
#include <lvgl/grid_navigation.h>
#include <lvgl/insets.h>
#include <lvgl/lvgl.h>
#include <lvgl/theme.h>
#include <lvgl/widgets/icon_button.h>
#include <lvgl/widgets/spinner.h>
@ -16,16 +17,16 @@
constexpr auto* TAG = "lvgl_toolbar";
static uint32_t getToolbarHeight(UiDensity uiDensity) {
if (uiDensity == LVGL_UI_DENSITY_COMPACT) {
static uint32_t getToolbarHeight(bool isCompact) {
if (isCompact) {
return lvgl_get_text_font_height(FONT_SIZE_DEFAULT) * 1.6f;
} else {
return lvgl_get_text_font_height(FONT_SIZE_LARGE) * 2.2f;
}
}
static const _lv_font_t* getToolbarFont(UiDensity uiDensity) {
if (uiDensity == LVGL_UI_DENSITY_COMPACT) {
static const _lv_font_t* getToolbarFont(bool isCompact) {
if (isCompact) {
return lvgl_get_text_font(FONT_SIZE_DEFAULT);
} else {
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 */
static uint32_t getActionButtonSize(UiDensity uiDensity) {
auto toolbar_height = getToolbarHeight(uiDensity);
auto padding = (uiDensity != LVGL_UI_DENSITY_COMPACT) ? (uint32_t)(toolbar_height * 0.2f) : 8;
static uint32_t getActionButtonSize(bool isCompact) {
auto toolbar_height = getToolbarHeight(isCompact);
auto padding = !isCompact ? (uint32_t)(toolbar_height * 0.2f) : 8;
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);
}
static lv_obj_t* create_action_button(lv_obj_t* parent, UiDensity ui_density) {
auto button_size = getActionButtonSize(ui_density);
static lv_obj_t* create_action_button(lv_obj_t* parent, bool is_compact) {
auto button_size = getActionButtonSize(is_compact);
lv_obj_t* button = lvgl_icon_button_create(parent);
lv_obj_set_size(button, button_size, button_size);
return button;
}
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_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);
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
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);
lv_obj_align(toolbar->close_button_image, LV_ALIGN_CENTER, 0, 0);
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_long_mode(toolbar->title_label, LV_LABEL_LONG_MODE_SCROLL);
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");
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_t* button_content;
if (isImage) {

View File

@ -14,7 +14,10 @@
animationsEnabled: bool = true;
colorPrimary: color = #00bbff;
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");
fontNormal: 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-on-error: if($isDark, mix($colorError, black, 50%), white);
// Surfaces: neutral tones tinted with the primary color
@color-surface: mix($colorPrimary, if($isDark, @neutral-6, @neutral-98), @tint-neutral);
// Surfaces: neutral tones tinted with the primary color.
// 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-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);
@color-surface-container-low: mix($colorPrimary, if($isDark, @neutral-10, @neutral-96), @tint-neutral);
@color-surface-container: mix($colorPrimary, if($isDark, @neutral-12, @neutral-94), @tint-neutral);
@color-surface-container-high: mix($colorPrimary, if($isDark, @neutral-17, @neutral-92), @tint-neutral);
@color-surface-container-highest: mix($colorPrimary, if($isDark, @neutral-22, @neutral-90), @tint-neutral);
// With a surface override, the containers are the surface lifted towards the text colour, in the same steps as the neutral tones
@surface-lift: if($isDark, #FFFFFF, #000000);
@surface-sink: if($isDark, #000000, #FFFFFF);
@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-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-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);
@ -203,8 +211,8 @@
// 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-dot-size: if($isCompact, 4, 6dp);
@page-indicator-gap: if($isCompact, 3, 6dp);
@page-indicator-dot-size: if($isCompact, 6, 8dp);
@page-indicator-gap: if($isCompact, 4, 6dp);
// Badge (lvgl_badge_create): a small dot that marks an item, e.g. a favourite
@badge-color: @color-secondary;
@ -250,10 +258,9 @@
@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);
// Toolbar: Material 2 top app bar, filled with a dark neutral tone for contrast with the content.
// The tone is the same in light and dark mode. The statusbar uses a darker neutral tone with the same tint.
@app-bar-color: mix($colorPrimary, @neutral-22, @tint-neutral);
@app-bar-content-color: white;
// Toolbar: Material 3 top app bar, in the same colour as the app content and the statusbar (which shows the window's container colour)
@app-bar-color: @color-surface-container-low;
@app-bar-content-color: @color-on-surface;
@app-bar-pressed-opacity: 20%;
@app-bar-pad-hor: if($isCompact, 0, @space-1);
@app-bar-gap: if($isCompact, 2, @space-1);
@ -373,12 +380,15 @@
@list-divider-width: @density-border;
@list-subheader-text-color: @color-on-surface-variant;
// Tabs: Material 3 primary tabs
@tabs-divider-color: @color-outline-variant;
// Tabs: the tab bar is a Material 3 navigation bar with a pill-shaped active indicator
@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-selected-label-color: @color-primary;
@tab-indicator-color: @color-primary;
@tab-indicator-width: if($isCompact, 2, 3dp);
@tab-pad: if($isCompact, 2, @space-1);
@tab-shape: @shape-control;
@tab-selected-color: @color-secondary-container;
@tab-selected-label-color: @color-on-secondary-container;
@tab-page-pad: @density-container-pad;
// Dialog (message box)
@ -613,6 +623,8 @@ chip {
radius: @chip-shape;
padHor: @chip-pad-hor;
padVer: @chip-pad-ver;
// Room for the focus ring, which the parent would clip
marginAll: @focus-width + @focus-offset;
}
chip_selected {
@ -630,6 +642,8 @@ button {
padHor: @button-pad-hor;
padVer: @button-pad-ver;
padGap: @button-gap;
// Room for the focus ring, which the parent would clip
marginAll: @focus-width + @focus-offset;
}
button_pressed {
@ -733,6 +747,8 @@ app_bar_field {
switch_track {
width: @switch-width;
height: @switch-height;
// Room for the focus ring, which the parent would clip
marginAll: @focus-width + @focus-offset;
bgColor: @switch-track-color;
bgOpa: cover;
borderColor: @switch-track-border-color;
@ -792,6 +808,8 @@ checkbox_box_selected {
sliderbox {
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 {
@ -996,23 +1014,25 @@ list_subheader {
}
tabs {
bgOpa: transp;
borderColor: @tabs-divider-color;
borderWidth: @density-border;
borderSide: bottom;
bgColor: @tabs-color;
bgOpa: cover;
borderWidth: 0;
radius: @card-shape;
padAll: @tabs-pad;
padGap: @tabs-gap;
}
tab {
bgOpa: transp;
textColor: @tab-label-color;
radius: 0;
radius: @tab-shape;
padAll: @tab-pad;
}
tab_selected {
bgColor: @tab-selected-color;
bgOpa: cover;
textColor: @tab-selected-label-color;
borderColor: @tab-indicator-color;
borderWidth: @tab-indicator-width;
borderSide: bottom;
}
tab_page {
@ -1414,6 +1434,11 @@ apply(page_indicator > label) {
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) {
page_indicator_dot: default;
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-color: @color-on-surface;
@page-indicator-dot-size: if($isCompact, 4, 6dp);
@page-indicator-gap: if($isCompact, 3, 6dp);
@page-indicator-dot-size: if($isCompact, 6, 8dp);
@page-indicator-gap: if($isCompact, 4, 6dp);
// Badge (lvgl_badge_create): a small dot that marks an item, e.g. a favourite
@badge-color: @color-on-surface;
@ -180,7 +180,6 @@
// Toolbar: top app bar with a divider, which shows the background like the statusbar does
@app-bar-title-color: @color-on-surface;
@app-bar-divider-color: @color-outline;
@app-bar-pad-hor: if($isCompact, 0, @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
@ -268,10 +267,11 @@
@list-item-gap: if($isCompact, 3, @space-4);
@list-divider-color: @color-outline;
// Tabs
@tabs-divider-color: @color-outline;
@tab-indicator-color: @color-primary;
@tab-indicator-width: if($isCompact, @line-medium, @line-thick);
// Tabs: the tab bar is a navigation bar, the selected tab is an inverted pill
@tabs-pad: if($isCompact, 2, @space-1);
@tabs-gap: if($isCompact, 2, @space-1);
@tab-pad: if($isCompact, 2, @space-1);
@tab-shape: @shape-control;
@tab-page-pad: @density-container-pad;
// Dialog
@ -441,6 +441,8 @@ chip {
radius: @chip-shape;
padHor: @chip-pad-hor;
padVer: @chip-pad-ver;
// Room for the focus ring, which the parent would clip
marginAll: @focus-width + @focus-offset;
}
button {
@ -453,6 +455,8 @@ button {
padHor: @button-pad-hor;
padVer: @button-pad-ver;
padGap: @button-gap;
// Room for the focus ring, which the parent would clip
marginAll: @focus-width + @focus-offset;
}
icon_button {
@ -494,9 +498,7 @@ app_bar {
bgOpa: transp;
textColor: @app-bar-title-color;
radius: 0;
borderColor: @app-bar-divider-color;
borderWidth: @border;
borderSide: bottom;
borderWidth: 0;
padHor: @app-bar-pad-hor;
padVer: 0;
padGap: @app-bar-gap;
@ -516,6 +518,8 @@ app_bar_focus_ring {
switch_track {
width: @switch-width;
height: @switch-height;
// Room for the focus ring, which the parent would clip
marginAll: @focus-width + @focus-offset;
bgColor: @switch-track-color;
bgOpa: cover;
borderColor: @switch-track-border-color;
@ -565,6 +569,8 @@ checkbox_box_selected {
sliderbox {
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 {
@ -723,22 +729,17 @@ list_subheader {
tabs {
bgOpa: transp;
borderColor: @tabs-divider-color;
borderWidth: @border;
borderSide: bottom;
borderWidth: 0;
padAll: @tabs-pad;
padGap: @tabs-gap;
}
tab {
bgOpa: transp;
textColor: @color-on-surface;
borderWidth: 0;
radius: 0;
}
tab_selected {
borderColor: @tab-indicator-color;
borderWidth: @tab-indicator-width;
borderSide: bottom;
radius: @tab-shape;
padAll: @tab-pad;
}
tab_page {
@ -978,7 +979,7 @@ apply(obj) {
apply(tabview > *[0] > button) {
tab: default;
inverted: state.pressed;
tab_selected: state.checked;
inverted: state.checked;
focus_ring: state.focusKey;
edit_ring: state.edited;
}
@ -1077,6 +1078,11 @@ apply(page_indicator > label) {
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) {
page_indicator_dot: default;
page_indicator_dot_current: state.checked;

View File

@ -13,6 +13,13 @@ enum class ThemeMode {
Dark
};
enum class DensityMode {
/** Regular or compact, as configured for the device */
DeviceDefault,
Regular,
Compact
};
struct AppearanceSettings {
/** Default text font size in pixels, or 0 for the device's default size */
uint16_t fontSize = 0;
@ -23,15 +30,28 @@ struct AppearanceSettings {
ThemeMode themeMode = ThemeMode::DeviceDefault;
/** Use the monochrome theme, also on colour displays */
bool monoTheme = false;
DensityMode densityMode = DensityMode::DeviceDefault;
bool animationsEnabled = true;
/** Theme colours as 0xRRGGBB, or empty for the theme's default colour */
std::optional<uint32_t> primaryColor;
std::optional<uint32_t> secondaryColor;
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;
};
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);
AppearanceSettings loadOrGetDefault();

View File

@ -48,9 +48,12 @@ public:
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; }
/** 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. */
void setSwipeNavigation(bool enabled) { swipeNavigation = enabled; }
@ -89,8 +92,8 @@ private:
lv_obj_t* prevButton = nullptr;
lv_obj_t* nextButton = nullptr;
lv_obj_t* pageIndicator = nullptr;
lv_obj_t* pageButtons = nullptr;
lv_obj_t* barButtons = nullptr;
lv_obj_t* bottomBar = nullptr;
lv_obj_t* barSpacer = nullptr;
void createGrid(lv_obj_t* parent);
void goToPreviousPage();
@ -105,6 +108,7 @@ private:
static void onPrevPressed(lv_event_t* e);
static void onNextPressed(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 onTileLongPressed(lv_event_t* e);
static void onTileKey(lv_event_t* e);

View File

@ -26,7 +26,7 @@ class View final {
void updateScanning();
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);

View File

@ -1,6 +1,7 @@
#pragma once
#include <string>
#include <vector>
#include <cstdint>
struct Device;
@ -9,7 +10,8 @@ namespace tt::app::i2cscanner {
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);

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 updateNetworkList();
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 connect(lv_event_t* event);

View File

@ -61,7 +61,6 @@ namespace app {
namespace power { extern const ::AppManifest manifest; }
namespace poweroff { extern const ::AppManifest manifest; }
namespace selectiondialog { extern const ::AppManifest manifest; }
namespace settings { extern const ::AppManifest manifest; }
namespace setup { extern const ::AppManifest manifest; }
namespace shell { extern const ::AppManifest manifest; extern const ::AppManifest sh_manifest; }
namespace systeminfo { extern const ::AppManifest manifest; }
@ -131,7 +130,6 @@ static void registerInternalApps() {
if (device_exists_of_type(&POWER_SUPPLY_TYPE)) {
app_manager_add(&app::poweroff::manifest);
}
app_manager_add(&app::settings::manifest);
app_manager_add(&app::selectiondialog::manifest);
app_manager_add(&app::setup::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) {
auto* section = lv_obj_create(parent);
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) {
// The width of the visible buttons on one side of the bar's spacer, including the gaps between them
int32_t getSideWidth(lv_obj_t* bar, bool rightSide) {
int32_t width = 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++) {
lv_obj_t* child = lv_obj_get_child(section, static_cast<int32_t>(i));
if (!lv_obj_has_flag(child, LV_OBJ_FLAG_HIDDEN)) {
lv_obj_t* child = lv_obj_get_child(bar, static_cast<int32_t>(i));
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);
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) {
auto* bottom_bar = static_cast<lv_obj_t*>(data);
auto* left_section = lv_obj_get_child(bottom_bar, 0);
auto* indicator_section = lv_obj_get_child(bottom_bar, 1);
auto* page_buttons = lv_obj_get_child(bottom_bar, 2);
auto* indicator = lv_obj_get_child(indicator_section, 0);
lv_obj_t* indicator = nullptr;
const uint32_t count = lv_obj_get_child_count(bottom_bar);
for (uint32_t i = 0; i < count && indicator == nullptr; i++) {
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) {
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 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);
@ -230,7 +226,8 @@ void AppGrid::populate() {
pageCount = page_count;
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(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) {
// The side sections grow equally, so the page indicator stays centered
auto* bottom_bar = createBarSection(parent, LV_FLEX_ALIGN_START);
lv_obj_set_width(bottom_bar, LV_PCT(100));
lvgl_obj_add_edge_padding(bottom_bar);
barButtons = createBarSection(bottom_bar, LV_FLEX_ALIGN_START);
lv_obj_set_flex_grow(barButtons, 1);
auto* indicator_section = createBarSection(bottom_bar, LV_FLEX_ALIGN_CENTER);
pageButtons = createBarSection(bottom_bar, LV_FLEX_ALIGN_END);
lv_obj_set_flex_grow(pageButtons, 1);
lv_obj_add_event_cb(bottom_bar, onBottomBarSizeChanged, LV_EVENT_SIZE_CHANGED, nullptr);
lv_obj_add_event_cb(bottom_bar, onBottomBarDeleted, LV_EVENT_DELETE, nullptr);
bottomBar = lv_obj_create(parent);
lv_obj_set_size(bottomBar, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(bottomBar, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(bottomBar, 0, LV_STATE_DEFAULT);
lv_obj_set_flex_flow(bottomBar, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(bottomBar, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_remove_flag(bottomBar, LV_OBJ_FLAG_SCROLLABLE);
lvgl_obj_add_edge_padding(bottomBar);
lv_obj_add_event_cb(bottomBar, onBottomBarSizeChanged, LV_EVENT_SIZE_CHANGED, nullptr);
lv_obj_add_event_cb(bottomBar, onBottomBarDeleted, LV_EVENT_DELETE, nullptr);
createGrid(parent);
lv_obj_move_foreground(bottom_bar);
lv_obj_move_foreground(bottomBar);
pageIndicator = lvgl_page_indicator_create(indicator_section);
prevButton = lvgl_icon_button_create(pageButtons);
// The bar buttons go before the spacer, which pushes the page buttons to the right
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_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_obj_add_event_cb(nextButton, onNextPressed, LV_EVENT_SHORT_CLICKED, this);
// The arrow keys move between the page buttons
lvgl_grid_navigation_add(pageButtons);
pageIndicator = lvgl_page_indicator_create(bottomBar);
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.
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) {
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);
lv_obj_set_style_text_font(label, lvgl_get_shared_icon_default_font(), LV_STATE_DEFAULT);
lv_label_set_text(label, icon);

View File

@ -20,6 +20,7 @@
#include <tactility/log.h>
#include <lvgl.h>
#include <lvgl/fonts.h>
#include <lvgl/lvgl.h>
#include <lvgl/theme.h>
#include <lvgl/widgets/card.h>
@ -74,8 +75,11 @@ constexpr NamedColor PALETTE_COLORS[] = {
{ "Grey", LV_PALETTE_GREY },
};
constexpr size_t COLOR_COUNT = 3;
constexpr const char* COLOR_TITLES[COLOR_COUNT] = { "Primary color", "Secondary color", "Error color" };
constexpr size_t COLOR_COUNT = 4;
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 {
lv_obj_t* row = nullptr;
@ -118,7 +122,11 @@ struct Context {
AppStream selectStream {};
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
lv_obj_t* content = nullptr;
lv_obj_t* applyButton = nullptr;
lv_obj_t* spinner = nullptr;
FontRowWidgets regularRow;
@ -126,9 +134,17 @@ struct Context {
lv_obj_t* errorLabel = nullptr;
lv_obj_t* lightChip = nullptr;
lv_obj_t* darkChip = nullptr;
lv_obj_t* regularDensityChip = nullptr;
lv_obj_t* compactDensityChip = nullptr;
lv_obj_t* regularThemeChip = 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];
/** 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 */
bool isMonoDisplay = false;
};
@ -177,6 +193,23 @@ bool isDeviceDefaultDark() {
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) {
switch (settings.themeMode) {
case settings::appearance::ThemeMode::Light:
@ -197,22 +230,39 @@ void setChecked(lv_obj_t* object, bool checked) {
}
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) {
const bool dark = isDark(ctx->pendingSettings);
setChecked(ctx->lightChip, !dark);
setChecked(ctx->darkChip, dark);
const bool compact = isCompact(ctx->pendingSettings);
setChecked(ctx->regularDensityChip, !compact);
setChecked(ctx->compactDensityChip, compact);
if (ctx->regularThemeChip != nullptr) {
setChecked(ctx->regularThemeChip, !ctx->pendingSettings.monoTheme);
setChecked(ctx->monoThemeChip, ctx->pendingSettings.monoTheme);
}
// The mono theme doesn't use colours
for (size_t i = 0; i < COLOR_COUNT; i++) {
if (ctx->colorRows[i].row != nullptr) {
setHidden(ctx->colorRows[i].row, ctx->pendingSettings.monoTheme);
if (ctx->colorsCard != nullptr) {
setHidden(ctx->colorsTitle, ctx->pendingSettings.monoTheme);
setHidden(ctx->colorsCard, ctx->pendingSettings.monoTheme);
for (size_t i = 0; i < COLOR_COUNT; i++) {
updateColorRow(ctx, i);
}
}
updateSurfaceTintRow(ctx);
}
/** 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);
}
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) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
ctx->pendingSettings.monoTheme = false;
@ -259,10 +327,16 @@ void onMonoThemePressed(lv_event_t* event) {
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) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
auto* checkbox = static_cast<lv_obj_t*>(lv_event_get_target(event));
ctx->pendingSettings.animationsEnabled = lv_obj_has_state(checkbox, LV_STATE_CHECKED);
auto* animations_switch = static_cast<lv_obj_t*>(lv_event_get_target(event));
ctx->pendingSettings.animationsEnabled = lv_obj_has_state(animations_switch, LV_STATE_CHECKED);
updateWidgets(ctx);
}
@ -278,22 +352,35 @@ std::optional<uint32_t>& getColor(settings::appearance::AppearanceSettings& sett
return settings.primaryColor;
case 1:
return settings.secondaryColor;
case SURFACE_COLOR_INDEX:
return settings.surfaceColor;
default:
return settings.errorColor;
}
}
/** The surface has no default colour of its own: the theme derives it */
uint32_t getDefaultColor(size_t index) {
LvglThemeSettings defaults;
lvgl_theme_get_default_settings(&defaults);
const lv_color_t colors[COLOR_COUNT] = { defaults.color_primary, defaults.color_secondary, defaults.color_error };
return lv_color_to_u32(colors[index]) & 0xFFFFFF;
const lv_color_t color = index == 0 ? defaults.color_primary : (index == 1 ? defaults.color_secondary : defaults.color_error);
return lv_color_to_u32(color) & 0xFFFFFF;
}
void updateColorRow(Context* ctx, size_t index) {
const auto& widgets = ctx->colorRows[index];
const auto& color = getColor(ctx->pendingSettings, index);
lv_obj_set_style_bg_color(widgets.swatch, lv_color_hex(color.value_or(getDefaultColor(index))), LV_STATE_DEFAULT);
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) {
@ -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_align(row, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_column(row, lv_obj_get_style_pad_column(parent, LV_PART_MAIN), LV_STATE_DEFAULT);
// Room for the focus outline of the buttons, which is clipped to the row in compact mode
lv_obj_set_style_pad_ver(row, 3, LV_STATE_DEFAULT);
lv_obj_set_style_pad_hor(row, 3, LV_STATE_DEFAULT);
lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE);
return row;
}
@ -390,8 +474,10 @@ lv_obj_t* createSection(lv_obj_t* parent, const char* title) {
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) {
auto* row = createRow(parent);
lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW_WRAP);
auto* label = lv_label_create(row);
lv_label_set_text(label, text);
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);
}
auto* animations_checkbox = lv_checkbox_create(card);
lv_checkbox_set_text(animations_checkbox, "Animations");
setChecked(animations_checkbox, ctx->pendingSettings.animationsEnabled);
lv_obj_add_event_cb(animations_checkbox, onAnimationsChanged, LV_EVENT_VALUE_CHANGED, ctx);
auto* density_row = createLabeledRow(card, "Density");
ctx->regularDensityChip = createChip(density_row, "Regular", onRegularDensityPressed, ctx);
ctx->compactDensityChip = createChip(density_row, "Compact", onCompactDensityPressed, ctx);
if (!ctx->isMonoDisplay) {
for (size_t i = 0; i < COLOR_COUNT; i++) {
ctx->colorRows[i] = createColorRow(card, i, ctx);
auto* animations_row = createLabeledRow(card, "Animations");
auto* animations_switch = lv_switch_create(animations_row);
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 widgets;
// "Title [Default]"
auto* title_row = createRow(parent);
auto* label = lv_label_create(title_row);
lv_label_set_text(label, title);
lv_obj_set_flex_grow(label, 1);
lv_obj_set_style_pad_ver(label, 0, LV_STATE_DEFAULT);
widgets.defaultButton = createButton(title_row, "Default", onDefault, ctx);
// "Title [Default] [Select]"
// "file.ttf"
auto* row = createRow(parent);
// " file.ttf [Select]"
auto* file_row = createRow(parent);
widgets.fileLabel = lv_label_create(file_row);
lv_obj_set_flex_grow(widgets.fileLabel, 1);
lv_label_set_long_mode(widgets.fileLabel, LV_LABEL_LONG_MODE_DOTS);
lv_obj_set_style_pad_left(widgets.fileLabel, 12, LV_STATE_DEFAULT);
createButton(file_row, "Select", onSelect, ctx);
// The title with the file name below it, which take the width that the buttons leave. Texts that don't fit scroll.
auto* texts = lv_obj_create(row);
lv_obj_remove_style_all(texts);
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_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);
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);
auto* content = lv_obj_create(parent);
ctx->content = content;
lv_obj_set_width(content, LV_PCT(100));
lv_obj_set_flex_grow(content, 1);
lv_obj_set_flex_flow(content, LV_FLEX_FLOW_COLUMN);
@ -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));
ctx->isMonoDisplay = color_format == LV_COLOR_FORMAT_I1 || color_format == LV_COLOR_FORMAT_L8;
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* 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);
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);
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);
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) {
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->spinner = nullptr;
ctx->regularRow = {};
@ -563,11 +690,19 @@ void destroyWidgets(void* userData) {
ctx->errorLabel = nullptr;
ctx->lightChip = nullptr;
ctx->darkChip = nullptr;
ctx->regularDensityChip = nullptr;
ctx->compactDensityChip = nullptr;
ctx->regularThemeChip = nullptr;
ctx->monoThemeChip = nullptr;
ctx->colorsTitle = nullptr;
ctx->colorsCard = nullptr;
for (auto& row : ctx->colorRows) {
row = {};
}
ctx->surfaceTintRow = nullptr;
for (auto& chip : ctx->surfaceTintChips) {
chip = nullptr;
}
}
int32_t applyThreadMain(void* context) {

View File

@ -20,6 +20,7 @@
#include <tactility/log.h>
#include <lvgl/lvgl.h>
#include <lvgl/widgets/card.h>
#include <lvgl/widgets/spinner.h>
#include <lvgl/widgets/toolbar.h>
@ -94,12 +95,19 @@ void onRefreshPressed(lv_event_t* e) {
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) {
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_obj_align(label, LV_ALIGN_CENTER, 0, 0);
lv_obj_remove_flag(ctx->refreshButton, LV_OBJ_FLAG_HIDDEN);
}
@ -129,9 +137,17 @@ void showApps(Context* ctx) {
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_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++) {
auto& entry = ctx->entries[i];
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);
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_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;
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.
lvgl_toolbar_set_nav_action(ctx->toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
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_flex_grow(wrapper, 1);
lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN);

View File

@ -15,6 +15,7 @@
#include <TactilityCpp/Allocator.h>
#include <lvgl/widgets/card.h>
#include <lvgl/widgets/toolbar.h>
#include <lvgl.h>
#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.
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
lv_obj_t* list = lv_list_create(parent);
lv_obj_set_width(list, LV_PCT(100));
lv_obj_set_flex_grow(list, 1);
auto* content = lv_obj_create(parent);
lv_obj_set_width(content, LV_PCT(100));
lv_obj_set_flex_grow(content, 1);
lv_obj_set_flex_flow(content, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_border_width(content, 0, LV_STATE_DEFAULT);
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).
ctx->packages.clear();
@ -84,25 +90,24 @@ void createWidgets(lv_obj_t* parent, void* userData) {
return strcmp(left.id, right.id) < 0;
});
for (const auto& package : ctx->packages) {
createPackageWidget(&package, list);
if (ctx->packages.empty()) {
auto* no_apps_label = lv_label_create(card);
lv_label_set_text(no_apps_label, "No apps installed.");
return;
}
if (ctx->packages.empty()) {
// lv_obj_align() is ignored for children of a flex-managed parent, so the empty-state
// label needs its own flex-growing wrapper to center within; the (empty) list is hidden
// 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);
// The list items' pressed and focused backgrounds follow the card's rounded corners
lv_obj_set_style_pad_all(card, 0, LV_STATE_DEFAULT);
lv_obj_set_style_clip_corner(card, true, LV_STATE_DEFAULT);
auto* empty_wrapper = lv_obj_create(parent);
lv_obj_set_width(empty_wrapper, LV_PCT(100));
lv_obj_set_flex_grow(empty_wrapper, 1);
lv_obj_set_flex_align(empty_wrapper, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_border_width(empty_wrapper, 0, LV_STATE_DEFAULT);
// The card provides the background
lv_obj_t* 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);
auto* no_apps_label = lv_label_create(empty_wrapper);
lv_label_set_text(no_apps_label, "No apps installed");
for (const auto& package : ctx->packages) {
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);
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_style_pad_all(wrapper, 0, 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);
lv_obj_remove_style_all(row);
lv_obj_set_size(row, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(row, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE);
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);
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);
lv_obj_align(sw, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_set_state(sw, LV_STATE_CHECKED, enabled);
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);
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);
lv_obj_align(sw, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_add_event_cb(sw, cb, LV_EVENT_VALUE_CHANGED, userData);
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) {
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);
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);
lv_obj_set_width(sliderBox, LV_PCT(50));
lv_obj_align(sliderBox, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_set_width(sliderBox, LV_PCT(75));
lvgl_sliderbox_add_value_changed_cb(sliderBox, cb, userData);
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);
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_width(main_wrapper, LV_PCT(100));
lv_obj_set_flex_grow(main_wrapper, 1);

View File

@ -12,6 +12,7 @@
#include <Tactility/Tactility.h>
#include <app/event.h>
#include <lvgl/widgets/card.h>
#include <lvgl/widgets/toolbar.h>
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);
}
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 label = record.name.empty()
? std::format("Unknown ({:02x}{:02x}{:02x}{:02x}{:02x}{:02x}) {}%",
@ -107,7 +127,7 @@ void View::createPeerListItem(const bluetooth::PeerRecord& record, bool isPaired
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 };
lv_obj_set_user_data(button, item_data);
@ -165,22 +185,20 @@ void View::updatePeerList() {
// 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_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);
auto* enable_on_boot_wrapper = lv_obj_create(peers_list);
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);
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);
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_wrapper, onEnableOnBootParentClicked, LV_EVENT_SHORT_CLICKED, enable_on_boot_switch);
@ -191,22 +209,21 @@ void View::updatePeerList() {
// Paired peers section
auto paired = state->getPairedPeers();
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) {
createPeerListItem(paired[i], true, i);
createPeerListItem(paired_section, paired[i], true, i);
}
}
// Scan results section
auto scan_results = state->getScanResults();
lv_list_add_text(peers_list, "Available");
auto* available_section = createSection(peers_list, "Available");
if (!scan_results.empty()) {
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()) {
auto* no_devices_label = lv_label_create(peers_list);
lv_label_set_text(no_devices_label, "No devices found.");
lv_list_add_text(available_section, "No devices found.");
}
// Never hide peers_list: it always contains the "Enable on boot" row.
// 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);
// 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_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() {

View File

@ -154,17 +154,19 @@ void createWidgets(lv_obj_t* parent, void* userData) {
// Auto-connect toggle row
auto* auto_connect_wrapper = lv_obj_create(wrapper);
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);
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);
auto* auto_connect_label = lv_label_create(auto_connect_wrapper);
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);
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) {
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);
auto* wrapper = inputWrapper;
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_style_pad_all(wrapper, 0, 0);
lv_obj_set_style_pad_column(wrapper, 4, 0);
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);
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);
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);
auto* btnLabel = lv_label_create(sendBtn);
@ -66,6 +66,7 @@ void ChatView::createInputBar(lv_obj_t* parent) {
void ChatView::createSettingsPanel(lv_obj_t* 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_flex_grow(settingsPanel, 1);
lv_obj_set_flex_flow(settingsPanel, LV_FLEX_FLOW_COLUMN);
@ -95,6 +96,7 @@ void ChatView::createSettingsPanel(lv_obj_t* parent) {
// Buttons
auto* btnRow = lv_obj_create(settingsPanel);
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_style_pad_all(btnRow, 0, 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) {
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_flex_grow(channelPanel, 1);
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);
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_style_pad_all(btnRow, 0, 0);
lv_obj_set_style_pad_column(btnRow, 8, 0);

View File

@ -19,6 +19,8 @@
#include <lvgl.h>
#include <lvgl/lvgl.h>
#include <lvgl/theme.h>
#include <lvgl/widgets/card.h>
#include <lvgl/widgets/toolbar.h>
#include <cstring>
@ -132,37 +134,47 @@ void createWidgets(lv_obj_t* parent, void* userData) {
// 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_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);
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_t* enable_label = lv_label_create(enable_wrapper);
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);
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()) {
lv_obj_add_state(ctx->enableOnBootSwitch, LV_STATE_CHECKED);
} else {
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_obj_set_width(warning_label, LV_PCT(100));
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) {
lv_obj_set_style_text_color(warning_label, lv_color_make(0xff, 0xff, 0x00), LV_STATE_DEFAULT);
// 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(warning_label, theme_settings.color_error, LV_STATE_DEFAULT);
}
updateViewState(ctx);

View File

@ -8,6 +8,7 @@
#include <tactility/log.h>
#include <Tactility/Tactility.h>
#include <Tactility/app/selectiondialog/SelectionDialog.h>
#ifdef ESP_PLATFORM
#include <Tactility/service/displayidle/DisplayIdleService.h>
#endif
@ -15,15 +16,25 @@
#include <Tactility/settings/DisplaySettings.h>
#include <app/event.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <app/scheduler.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.h>
#include <string>
#include <vector>
#ifdef ESP_PLATFORM
#include <sdkconfig.h>
#endif
@ -36,16 +47,48 @@ constexpr auto* TAG = "Display";
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 {
uint32_t appInstanceId;
settings::display::DisplaySettings displaySettings;
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* timeoutDropdown = nullptr;
lv_obj_t* screensaverDropdown = nullptr;
lv_obj_t* orientationDropdown = nullptr;
// Shown when the auto screen off is enabled: the timeout and screensaver
lv_obj_t* timeoutCard = 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* 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);
}
// 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) {
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* backlight = getBacklightDevice();
assert(backlight != nullptr);
int32_t slider_value = lv_slider_get_value(slider);
ctx->displaySettings.backlightDuty = static_cast<uint8_t>(slider_value);
const int32_t percent = lvgl_sliderbox_get_value(slider_box);
ctx->displaySettings.backlightDuty = static_cast<uint8_t>(percentToBrightness(backlight, percent));
ctx->displaySettingsUpdated = true;
backlight_set_brightness(backlight, ctx->displaySettings.backlightDuty);
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* dropdown = static_cast<lv_obj_t*>(lv_event_get_target(event));
uint32_t selected_index = lv_dropdown_get_selected(dropdown);
LOG_I(TAG, "Selected %u", (unsigned)selected_index);
auto* chip = lv_event_get_target_obj(event);
const auto selected_index = lv_obj_get_index(chip);
LOG_I(TAG, "Selected %d", (int)selected_index);
auto selected_orientation = static_cast<settings::display::Orientation>(selected_index);
if (selected_orientation != ctx->displaySettings.orientation) {
ctx->displaySettings.orientation = selected_orientation;
ctx->displaySettingsUpdated = true;
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);
ctx->displaySettings.autoRotateEnabled = enabled;
ctx->displaySettingsUpdated = true;
if (ctx->mountRotationDropdown) {
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);
}
}
updateOrientationVisibility(ctx);
}
void onMountRotationSet(lv_event_t* event) {
void onMountRotationChipPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
auto* dropdown = static_cast<lv_obj_t*>(lv_event_get_target(event));
uint32_t selected_index = lv_dropdown_get_selected(dropdown);
ctx->displaySettings.autoRotateMountRotation = static_cast<settings::display::Orientation>(selected_index);
auto* chip = lv_event_get_target_obj(event);
const auto orientation = static_cast<settings::display::Orientation>(lv_obj_get_index(chip));
ctx->displaySettings.autoRotateMountRotation = orientation;
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) {
@ -131,52 +247,106 @@ void onTimeoutSwitch(lv_event_t* event) {
bool enabled = lv_obj_has_state(sw, LV_STATE_CHECKED);
ctx->displaySettings.backlightTimeoutEnabled = enabled;
ctx->displaySettingsUpdated = true;
if (ctx->timeoutDropdown) {
if (enabled) {
lv_obj_clear_state(ctx->timeoutDropdown, LV_STATE_DISABLED);
if (ctx->screensaverDropdown) {
lv_obj_clear_state(ctx->screensaverDropdown, LV_STATE_DISABLED);
}
} else {
lv_obj_add_state(ctx->timeoutDropdown, LV_STATE_DISABLED);
if (ctx->screensaverDropdown) {
lv_obj_add_state(ctx->screensaverDropdown, LV_STATE_DISABLED);
}
if (ctx->timeoutCard) {
lv_obj_set_flag(ctx->timeoutCard, LV_OBJ_FLAG_HIDDEN, !enabled);
}
}
const char* getTimeoutName(uint32_t ms) {
for (const auto& option : TIMEOUT_OPTIONS) {
if (option.ms == ms) {
return option.name;
}
}
return "Custom";
}
void onTimeoutChanged(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
auto* dropdown = static_cast<lv_obj_t*>(lv_event_get_target(event));
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};
if (idx < (sizeof(values_ms)/sizeof(values_ms[0]))) {
ctx->displaySettings.backlightTimeoutMs = values_ms[idx];
ctx->displaySettingsUpdated = true;
const char* getScreensaverName(settings::display::ScreensaverType type) {
const auto index = static_cast<size_t>(type);
return index < std::size(SCREENSAVER_NAMES) ? SCREENSAVER_NAMES[index] : "";
}
/** Requires the LVGL lock */
void updateTimeoutLabels(Context* ctx) {
if (ctx->timeoutValueLabel != nullptr) {
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* dropdown = static_cast<lv_obj_t*>(lv_event_get_target(event));
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)) {
task_event_group_signal(ctx->eventGroup, ctx->selectTimeoutBit);
}
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;
}
auto selected_type = static_cast<settings::display::ScreensaverType>(idx);
if (selected_type != ctx->displaySettings.screensaverType) {
ctx->displaySettings.screensaverType = selected_type;
std::vector<std::string> items;
for (const auto& option : TIMEOUT_OPTIONS) {
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;
}
}
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) {
auto* ctx = static_cast<Context*>(userData);
ctx->displaySettings = settings::display::loadOrGetDefault();
bool has_imu = device_exists_of_type(&IMU_TYPE);
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);
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_width(main_wrapper, LV_PCT(100));
lv_obj_set_flex_grow(main_wrapper, 1);
@ -200,22 +371,16 @@ void createWidgets(lv_obj_t* parent, void* userData) {
if (backlight != nullptr) {
bool is_on_off_brightness = backlight_get_min_brightness(backlight) == 0 && backlight_get_max_brightness(backlight) == 1;
if (!is_on_off_brightness) {
auto* brightness_wrapper = lv_obj_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);
auto* brightness_label = lv_label_create(main_wrapper);
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);
lv_obj_set_width(brightness_slider, LV_PCT(50));
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);
auto* brightness_card = lvgl_card_create(main_wrapper);
lv_obj_set_size(brightness_card, LV_PCT(100), LV_SIZE_CONTENT);
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.
device_put(backlight);
@ -223,65 +388,41 @@ void createWidgets(lv_obj_t* parent, void* userData) {
// Orientation
auto* orientation_wrapper = lv_obj_create(main_wrapper);
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)
// Auto-rotate (IMU-driven), which decides what the card below shows
if (has_imu) {
auto* auto_rotate_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(auto_rotate_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(auto_rotate_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(auto_rotate_wrapper, 0, LV_STATE_DEFAULT);
auto* auto_rotate_row = lv_obj_create(main_wrapper);
lv_obj_set_size(auto_rotate_row, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(auto_rotate_row, LV_FLEX_FLOW_ROW);
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_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);
if (ctx->displaySettings.autoRotateEnabled) {
lv_obj_add_state(ctx->autoRotateSwitch, LV_STATE_CHECKED);
}
lv_obj_align(ctx->autoRotateSwitch, LV_ALIGN_RIGHT_MID, 0, 0);
ctx->autoRotateSwitch = lv_switch_create(auto_rotate_row);
lv_obj_set_state(ctx->autoRotateSwitch, LV_STATE_CHECKED, ctx->displaySettings.autoRotateEnabled);
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
// 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
@ -290,74 +431,46 @@ void createWidgets(lv_obj_t* parent, void* userData) {
if (backlight != nullptr) {
auto* timeout_wrapper = lv_obj_create(main_wrapper);
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_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);
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);
if (ctx->displaySettings.backlightTimeoutEnabled) {
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);
auto* timeout_select_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(timeout_select_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(timeout_select_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(timeout_select_wrapper, 0, LV_STATE_DEFAULT);
auto* timeout_card = lvgl_card_create(main_wrapper);
lv_obj_set_size(timeout_card, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(timeout_card, LV_FLEX_FLOW_COLUMN);
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);
lv_label_set_text(timeout_value_label, "Timeout");
lv_obj_align(timeout_value_label, LV_ALIGN_LEFT_MID, 0, 0);
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);
}
ctx->timeoutValueLabel = createValueRow(timeout_card, "Timeout", onChangeTimeoutPressed, ctx);
ctx->screensaverValueLabel = createValueRow(timeout_card, "Screensaver", onChangeScreensaverPressed, ctx);
updateTimeoutLabels(ctx);
}
}
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
// giving up its thread for a save/resume cycle, or closing for good) whenever they changed.
void persistIfUpdated(Context& ctx) {
@ -386,18 +499,31 @@ int32_t appMain(int argc, char* argv[]) {
uint32_t appInstanceId = app_scheduler_current_app_id();
Context ctx {};
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 {};
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 {};
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;
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 {};
while (app_event_poll(&sub, &event) == ERROR_NONE) {
@ -406,6 +532,17 @@ int32_t appMain(int argc, char* argv[]) {
persistIfUpdated(ctx);
shouldClose = true;
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:
break;
}

View File

@ -13,6 +13,9 @@
#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>
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* dropdown = static_cast<lv_obj_t*>(lv_event_get_target(e));
auto mode = static_cast<GroveMode>(lv_dropdown_get_selected(dropdown));
grove_set_mode(device, mode);
auto* chip = lv_event_get_target_obj(e);
if (grove_set_mode(device, getChipMode(chip)) == ERROR_NONE) {
updateChips(lv_obj_get_parent(chip), getChipMode(chip));
}
}
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);
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_width(main_wrapper, LV_PCT(100));
lv_obj_set_flex_grow(main_wrapper, 1);
for (auto* device : ctx->devices) {
auto* row = lv_obj_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);
auto* label = lv_label_create(main_wrapper);
lv_label_set_text(label, device->name);
lv_obj_align(label, LV_ALIGN_LEFT_MID, 0, 0);
auto* dropdown = lv_dropdown_create(row);
lv_dropdown_set_options(dropdown, "Disabled\nUART\nI2C");
lv_obj_align(dropdown, LV_ALIGN_RIGHT_MID, 0, 0);
// The chips are centered in the card while they fit, and scroll when they don't
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);
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;
grove_get_mode(device, &current);
lv_dropdown_set_selected(dropdown, static_cast<uint32_t>(current));
lv_obj_add_event_cb(dropdown, onModeChanged, LV_EVENT_VALUE_CHANGED, device);
updateChips(chips_row, current);
}
}

View File

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

View File

@ -24,7 +24,10 @@
#include <string>
#include <vector>
#include <lvgl/grid_navigation.h>
#include <lvgl/lvgl.h>
#include <lvgl/widgets/card.h>
#include <lvgl/widgets/chip.h>
#include <lvgl/widgets/toolbar.h>
namespace tt::app::i2cscanner {
@ -35,9 +38,6 @@ namespace {
constexpr auto* TAG = "I2cScanner";
constexpr auto* START_SCAN_TEXT = "Scan";
constexpr auto* STOP_SCAN_TEXT = "Stop scan";
struct Context {
uint32_t appInstanceId;
@ -47,10 +47,14 @@ struct Context {
// State
ScanState scanState = ScanStateInitial;
struct Device* portDevice = nullptr;
int32_t selectedBus = 0;
std::vector<uint8_t> scannedAddresses;
// Widgets
lv_obj_t* scanButtonLabelWidget = nullptr;
lv_obj_t* portDropdownWidget = nullptr;
/** Row and column sizes of the results grid, which must outlive the grid layout */
std::vector<int32_t> gridRows;
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;
};
@ -94,6 +98,37 @@ int32_t getLastBusIndex() {
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) {
if (ctx->mutex.lock(100 / portTICK_PERIOD_MS)) {
*outPort = ctx->portDevice;
@ -128,25 +163,54 @@ bool shouldStopScanTimer(Context* ctx) {
void updateViews(Context* ctx) {
if (ctx->mutex.lock(100 / portTICK_PERIOD_MS)) {
if (ctx->scanState == ScanStateScanning) {
lv_label_set_text(ctx->scanButtonLabelWidget, STOP_SCAN_TEXT);
lv_obj_remove_flag(ctx->portDropdownWidget, LV_OBJ_FLAG_CLICKABLE);
} else {
lv_label_set_text(ctx->scanButtonLabelWidget, START_SCAN_TEXT);
lv_obj_add_flag(ctx->portDropdownWidget, LV_OBJ_FLAG_CLICKABLE);
if (ctx->scanListWidget == nullptr) {
ctx->mutex.unlock();
return;
}
// 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);
if (ctx->scanState == ScanStateStopped) {
lv_obj_remove_flag(ctx->scanListWidget, LV_OBJ_FLAG_HIDDEN);
if (!ctx->scannedAddresses.empty()) {
for (auto address: ctx->scannedAddresses) {
std::string address_text = getAddressText(address);
lv_list_add_text(ctx->scanListWidget, address_text.c_str());
}
// A grid with the title in the first row, then an address and the devicetree device names per row
const size_t count = ctx->scannedAddresses.size();
ctx->gridColumns = { LV_GRID_CONTENT, LV_GRID_FR(1), LV_GRID_TEMPLATE_LAST };
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 {
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 {
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) {
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);
if (!shouldStopScanTimer(ctx)) {
addAddressToList(ctx, address);
@ -269,6 +333,7 @@ void selectBus(Context* ctx, int32_t selected) {
if (ctx->mutex.lock(100 / portTICK_PERIOD_MS)) {
ctx->scannedAddresses.clear();
ctx->portDevice = found_device;
ctx->selectedBus = selected;
ctx->scanState = ScanStateInitial;
ctx->mutex.unlock();
}
@ -288,20 +353,15 @@ void onBackPressed(lv_event_t* event) {
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* dropdown = static_cast<lv_obj_t*>(lv_event_get_target(event));
uint32_t selected = lv_dropdown_get_selected(dropdown);
selectBus(ctx, selected);
auto* chip = lv_event_get_target_obj(event);
selectBus(ctx, static_cast<int32_t>(reinterpret_cast<intptr_t>(lv_obj_get_user_data(chip))));
}
void onPressScan(lv_event_t* event) {
void onPressRefresh(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
if (ctx->scanState == ScanStateScanning) {
stopScanning(ctx);
} else {
startScanning(ctx);
}
startScanning(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);
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_width(main_wrapper, LV_PCT(100));
lv_obj_set_flex_grow(main_wrapper, 1);
auto* wrapper = lv_obj_create(main_wrapper);
lv_obj_set_width(wrapper, LV_PCT(100));
lv_obj_set_height(wrapper, LV_SIZE_CONTENT);
lv_obj_set_style_border_width(wrapper, 0, 0);
// Ensures showing selection state for keyboard/encoder devices
lv_obj_set_style_pad_all(wrapper, 6, LV_ALIGN_DEFAULT);
const std::vector<std::string> port_names = getPortNames();
if (port_names.empty()) {
lv_obj_set_flex_align(main_wrapper, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
auto* label = lv_label_create(main_wrapper);
lv_obj_set_width(label, LV_PCT(100));
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);
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;
ctx->refreshButton = lvgl_toolbar_add_image_button_action(toolbar, LV_SYMBOL_REFRESH, onPressRefresh, ctx);
auto* port_dropdown = lv_dropdown_create(wrapper);
std::string dropdown_items = getPortNamesForDropdown();
lv_dropdown_set_options(port_dropdown, dropdown_items.c_str());
lv_obj_set_width(port_dropdown, LV_PCT(48));
lv_obj_align(port_dropdown, LV_ALIGN_TOP_RIGHT, 0, 0);
lv_obj_add_event_cb(port_dropdown, onSelectBus, LV_EVENT_VALUE_CHANGED, ctx);
auto selected_bus = getLastBusIndex();
lv_dropdown_set_selected(port_dropdown, selected_bus);
ctx->portDropdownWidget = port_dropdown;
if (port_names.size() > 1) {
// Centered while the chips fit, and scrollable when they don't
lv_obj_set_flex_align(main_wrapper, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START);
auto* chips_row = lv_obj_create(main_wrapper);
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_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);
lv_obj_set_style_margin_top(scan_list, 8, 0);
lv_obj_set_width(scan_list, LV_PCT(100));
lv_obj_set_height(scan_list, LV_SIZE_CONTENT);
auto* scan_list = lvgl_card_create(main_wrapper);
lv_obj_set_size(scan_list, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_layout(scan_list, LV_LAYOUT_GRID);
lvgl_grid_navigation_add(scan_list);
lv_obj_add_flag(scan_list, LV_OBJ_FLAG_HIDDEN);
ctx->scanListWidget = scan_list;
struct Device* dummy;
if (getActivePortAtIndex(selected_bus, &dummy)) {
selectBus(ctx, selected_bus);
} else if (getActivePortAtIndex(0, &dummy)) {
lv_dropdown_set_selected(port_dropdown, 0);
selectBus(ctx, 0);
}
const int32_t last_bus = getLastBusIndex();
selectBus(ctx, getActivePortAtIndex(last_bus, &dummy) ? last_bus : 0);
}
// 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);
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_width(main_wrapper, LV_PCT(100));
lv_obj_set_flex_grow(main_wrapper, 1);
@ -129,15 +130,17 @@ void createWidgets(lv_obj_t* parent, void* userData) {
// Keyboard backlight toggle
auto* bl_wrapper = lv_obj_create(main_wrapper);
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_border_width(bl_wrapper, 0, LV_STATE_DEFAULT);
auto* bl_label = lv_label_create(bl_wrapper);
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);
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);
// Brightness slider
@ -160,15 +163,17 @@ void createWidgets(lv_obj_t* parent, void* userData) {
// Backlight timeout enable
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_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_border_width(to_enable_wrapper, 0, LV_STATE_DEFAULT);
auto* to_enable_label = lv_label_create(to_enable_wrapper);
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);
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);
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/manager.h>
#include <app/manifest.h>
#include <app/package_manifest.h>
#include <app/scheduler.h>
#include <app/start.h>
#include <algorithm>
#include <cstring>
#include <string>
#include <vector>
#include <lvgl.h>
@ -22,7 +19,7 @@
#include <tactility/log.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/settings/BootSettings.h>
#include <Tactility/Tactility.h>
@ -33,69 +30,27 @@ constexpr auto* TAG = "Launcher";
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);
void onAppClicked(const ::AppManifest& manifest, void* userData);
void onAppLongPressed(const ::AppManifest& manifest, 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 {
Favourites favourites;
Mode mode = Mode::Apps;
lv_obj_t* modeButtonIcon = nullptr;
AppGrid grid { AppGrid::Callbacks {
.collect = collectItems,
.onClicked = onAppClicked,
@ -106,29 +61,7 @@ struct Context {
};
std::vector<AppGridItem> collectItems(void* userData) {
const auto* ctx = static_cast<Context*>(userData);
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;
return getLauncherMode(static_cast<Context*>(userData)->mode).collect();
}
void startApp(const char* appId) {
@ -143,23 +76,38 @@ void onAppClicked(const ::AppManifest& manifest, void*) {
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) {
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) {
if (key == 'f' || key == 'F') {
toggleFavourite(static_cast<Context*>(userData), manifest.id, true);
auto* ctx = static_cast<Context*>(userData);
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) {
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_style_pad_row(parent, 0, LV_STATE_DEFAULT);
// The monochrome theme's primary color is black, also on a black background
if (!lvgl_theme_is_mono()) {
ctx->grid.setIconColor(AppGrid::IconColor::Primary);
}
applyIconColor(ctx);
ctx->grid.setSwipeNavigation(true);
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()) {
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_style_pad_all(row, 2, 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);
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;
}
@ -331,6 +334,7 @@ void createWidgets(lv_obj_t* parent, void* userData) {
lvgl_toolbar_set_nav_action(ctx->toolbar, LV_SYMBOL_CLOSE, onNavigationPressed, ctx);
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_flex_grow(ctx->mainPanel, 1);
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);
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_flex_grow(ctx->customPanel, 1);
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* outputSwitch = lv_switch_create(outputRow);
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);
auto* modeRow = createRow(ctx->mainPanel, "Mode");
auto* modeButton = lv_button_create(modeRow);
lv_obj_set_width(modeButton, LV_PCT(62));
lv_obj_align(modeButton, LV_ALIGN_RIGHT_MID, 0, 0);
ctx->modeValue = lv_label_create(modeButton);
lv_label_set_text(ctx->modeValue, patternName(ctx->settings.pattern));
lv_obj_center(ctx->modeValue);
@ -361,7 +364,6 @@ void createWidgets(lv_obj_t* parent, void* userData) {
auto* primaryRow = createRow(ctx->mainPanel, "Color");
auto* primaryButton = lv_button_create(primaryRow);
lv_obj_set_width(primaryButton, LV_PCT(62));
lv_obj_align(primaryButton, LV_ALIGN_RIGHT_MID, 0, 0);
ctx->primaryValue = lv_label_create(primaryButton);
lv_label_set_text(ctx->primaryValue, presetName(ctx->settings.primaryPreset));
lv_obj_center(ctx->primaryValue);
@ -373,7 +375,6 @@ void createWidgets(lv_obj_t* parent, void* userData) {
ctx->secondaryRow = createRow(ctx->mainPanel, "Second color");
auto* secondaryButton = lv_button_create(ctx->secondaryRow);
lv_obj_set_width(secondaryButton, LV_PCT(62));
lv_obj_align(secondaryButton, LV_ALIGN_RIGHT_MID, 0, 0);
ctx->secondaryValue = lv_label_create(secondaryButton);
lv_label_set_text(ctx->secondaryValue, presetName(ctx->settings.secondaryPreset));
lv_obj_center(ctx->secondaryValue);
@ -385,12 +386,12 @@ void createWidgets(lv_obj_t* parent, void* userData) {
auto* brightnessRow = createRow(ctx->mainPanel, "Brightness");
ctx->brightnessValue = lv_label_create(brightnessRow);
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);
lv_obj_set_width(ctx->brightnessSlider, LV_PCT(100));
lvgl_sliderbox_add_value_changed_cb(ctx->brightnessSlider, onBrightnessChanged, ctx);
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_flex_grow(ctx->choicePanel, 1);
lv_obj_set_flex_flow(ctx->choicePanel, LV_FLEX_FLOW_COLUMN);

View File

@ -1,8 +1,8 @@
#include <Tactility/Tactility.h>
#include <Tactility/RecursiveMutex.h>
#include <Tactility/StringUtils.h>
#include <Tactility/app/localesettings/TextResources.h>
#include <Tactility/app/selectiondialog/SelectionDialog.h>
#include <Tactility/settings/Language.h>
#include <Tactility/settings/SystemSettings.h>
@ -14,11 +14,17 @@
#include <lvgl_window_manager/window_manager.h>
#include <tactility/check.h>
#include <tactility/concurrent/task_event_group.h>
#include <lvgl/widgets/card.h>
#include <lvgl/widgets/toolbar.h>
#include <lvgl.h>
#include <lvgl/fonts.h>
#include <lvgl/lvgl.h>
#include <map>
#include <string>
#include <vector>
namespace tt::app::localesettings {
@ -38,14 +44,19 @@ struct Context {
uint32_t appInstanceId;
tt::i18n::TextResources textResources = tt::i18n::TextResources(TEXT_RESOURCE_PATH);
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;
std::map<settings::Language, std::string> languageMap;
};
std::string getLanguageOptions(Context* ctx) {
std::vector<std::string> getLanguageNames(Context* ctx) {
std::vector<std::string> items;
for (int i = 0; i < static_cast<int>(settings::Language::count); i++) {
switch (static_cast<settings::Language>(i)) {
@ -68,25 +79,44 @@ std::string getLanguageOptions(Context* ctx) {
break;
}
}
return string::join(items, "\n");
return items;
}
/** Requires the LVGL lock */
void updateViews(Context* ctx) {
ctx->textResources.load();
std::string language_options = getLanguageOptions(ctx);
lv_dropdown_set_options(ctx->languageDropdown, language_options.c_str());
lv_dropdown_set_selected(ctx->languageDropdown, static_cast<uint32_t>(settings::getLanguage()));
if (ctx->languageLabel == nullptr) {
return;
}
lv_label_set_text(ctx->languageLabel, ctx->textResources[i18n::Text::LANGUAGE].c_str());
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* dropdown = static_cast<lv_obj_t*>(lv_event_get_target(event));
auto index = lv_dropdown_get_selected(dropdown);
auto language = static_cast<settings::Language>(index);
settings::setLanguage(language);
task_event_group_signal(ctx->eventGroup, ctx->selectLanguageBit);
}
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
@ -114,29 +144,57 @@ void createWidgets(lv_obj_t* parent, void* userData) {
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
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_width(main_wrapper, LV_PCT(100));
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);
lv_obj_set_width(language_wrapper, LV_PCT(100));
lv_obj_set_height(language_wrapper, LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(language_wrapper, 8, 0);
lv_obj_set_style_border_width(language_wrapper, 0, 0);
// Transparent, so the card provides the background
auto* language_row = lv_obj_create(card);
lv_obj_set_size(language_row, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(language_row, 0, LV_STATE_DEFAULT);
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);
lv_label_set_text(languageLabel, ctx->textResources[i18n::Text::LANGUAGE].c_str());
lv_obj_align(languageLabel, LV_ALIGN_LEFT_MID, 4, 0);
// The title with the language below it, which take the width that the button leaves. Texts that don't fit scroll.
auto* texts = lv_obj_create(language_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);
ctx->languageDropdown = lv_dropdown_create(language_wrapper);
lv_obj_set_width(ctx->languageDropdown, 150);
lv_obj_align(ctx->languageDropdown, LV_ALIGN_RIGHT_MID, 0, 0);
std::string language_options = getLanguageOptions(ctx);
lv_dropdown_set_options(ctx->languageDropdown, language_options.c_str());
lv_dropdown_set_selected(ctx->languageDropdown, static_cast<uint32_t>(settings::getLanguage()));
lv_obj_add_event_cb(ctx->languageDropdown, onLanguageSet, LV_EVENT_VALUE_CHANGED, ctx);
ctx->languageLabel = lv_label_create(texts);
lv_obj_set_width(ctx->languageLabel, LV_PCT(100));
lv_label_set_long_mode(ctx->languageLabel, LV_LABEL_LONG_MODE_SCROLL_CIRCULAR);
ctx->languageValueLabel = lv_label_create(texts);
lv_obj_set_width(ctx->languageValueLabel, LV_PCT(100));
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[]) {
@ -146,26 +204,31 @@ int32_t appMain(int argc, char* argv[]) {
TaskEventGroup 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 {};
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;
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 {};
while (app_event_poll(&sub, &event) == ERROR_NONE) {
switch (event.type) {
case APP_EVENT_CLOSE:
shouldClose = true;
break;
default:
break;
if (event.type == APP_EVENT_CLOSE) {
shouldClose = true;
break;
} else if (event.type == APP_EVENT_RESULT && event.result.launch_id == ctx.languageSelectLaunchId) {
onLanguageSelectionResult(&ctx, event);
}
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_set_width(switch_container, LV_PCT(100));
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_gap(switch_container, 0, 0);
lvgl::obj_set_style_bg_invisible(switch_container);
lv_obj_t* label = lv_label_create(switch_container);
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_set_user_data(enable_switch, device);
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));
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_set_width(qc_container, LV_PCT(100));
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_gap(qc_container, 0, 0);
lvgl::obj_set_style_bg_invisible(qc_container);
lv_obj_t* label = lv_label_create(qc_container);
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_set_user_data(qc_switch, device);
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));
entry.quickChargeSwitch = qc_switch;
}

View File

@ -21,6 +21,9 @@
#include <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>
namespace tt::app::screenshot {
@ -31,12 +34,29 @@ extern const ::AppManifest manifest;
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 {
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* startStopButtonLabel = nullptr;
lv_obj_t* timerWrapper = nullptr;
// Shown in timer mode
lv_obj_t* delayRow = nullptr;
lv_obj_t* delayTextArea = nullptr;
std::unique_ptr<Timer> updateTimer;
};
@ -56,12 +76,11 @@ void updateScreenshotMode(Context* ctx) {
lv_label_set_text(label, "Start");
}
uint32_t selected = lv_dropdown_get_selected(ctx->modeDropdown);
if (selected == 0) { // Timer
lv_obj_remove_flag(ctx->timerWrapper, LV_OBJ_FLAG_HIDDEN);
} else {
lv_obj_add_flag(ctx->timerWrapper, LV_OBJ_FLAG_HIDDEN);
const uint32_t chip_count = lv_obj_get_child_count(ctx->modeChips);
for (uint32_t i = 0; i < chip_count; i++) {
lv_obj_set_state(lv_obj_get_child(ctx->modeChips, static_cast<int32_t>(i)), LV_STATE_CHECKED, MODE_OPTIONS[i].mode == ctx->mode);
}
lv_obj_set_flag(ctx->delayRow, LV_OBJ_FLAG_HIDDEN, ctx->mode != CaptureMode::Timer);
}
void onBackPressed(lv_event_t* event) {
@ -85,7 +104,6 @@ void onStartPressed(lv_event_t* event) {
return;
}
uint32_t selected = lv_dropdown_get_selected(ctx->modeDropdown);
const char* path = lv_textarea_get_text(ctx->pathTextArea);
error_t result = directory_make(path, true);
@ -94,7 +112,7 @@ void onStartPressed(lv_event_t* event) {
return;
}
if (selected == 0) {
if (ctx->mode == CaptureMode::Timer) {
LOG_I(TAG, "Start timed screenshots");
const char* delay_text = lv_textarea_get_text(ctx->delayTextArea);
int delay = atoi(delay_text);
@ -111,59 +129,62 @@ void onStartPressed(lv_event_t* event) {
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));
ctx->mode = MODE_OPTIONS[lv_obj_get_index(lv_event_get_target_obj(event))].mode;
updateScreenshotMode(ctx);
}
void createModeSettingWidgets(Context* ctx, lv_obj_t* parent) {
auto service = service::screenshot::optScreenshotService();
if (service == nullptr) {
LOG_E(TAG, "Service not found/running");
return;
}
auto* mode_wrapper = lv_obj_create(parent);
lv_obj_set_size(mode_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(mode_wrapper, 0, 0);
lv_obj_set_style_border_width(mode_wrapper, 0, 0);
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);
/** A transparent row in a card: "Title [content]" */
lv_obj_t* createRow(lv_obj_t* card, const char* title) {
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);
lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, LV_STATE_DEFAULT);
lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE);
lv_label_set_text(lv_label_create(row), title);
return row;
}
void createFilePathWidgets(Context* ctx, lv_obj_t* parent) {
auto* path_wrapper = lv_obj_create(parent);
lv_obj_set_size(path_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(path_wrapper, 0, 0);
lv_obj_set_style_border_width(path_wrapper, 0, 0);
lv_obj_set_flex_flow(path_wrapper, LV_FLEX_FLOW_ROW);
void createModeWidgets(Context* ctx, lv_obj_t* parent) {
auto* title = lv_label_create(parent);
lv_label_set_text(title, "Mode");
lv_obj_set_width(title, LV_PCT(100));
auto* label_wrapper = lv_obj_create(path_wrapper);
lv_obj_set_style_border_width(label_wrapper, 0, 0);
lv_obj_set_style_pad_all(label_wrapper, 0, 0);
lv_obj_set_size(label_wrapper, 44, 36);
auto* path_label = lv_label_create(label_wrapper);
lv_label_set_text(path_label, "Path:");
lv_obj_align(path_label, LV_ALIGN_LEFT_MID, 0, 0);
// The chips are centered in the card while they fit, and scroll when they don't
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);
lv_obj_set_flex_align(card, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
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_obj_set_flex_grow(ctx->pathTextArea, 1);
char data_path[FILE_MAX_PATH_STRING_LENGTH];
@ -173,41 +194,20 @@ void createFilePathWidgets(Context* ctx, lv_obj_t* parent) {
} else {
lv_textarea_set_text(ctx->pathTextArea, "Error: no data path");
}
}
void createTimerSettingsWidgets(Context* ctx, lv_obj_t* parent) {
ctx->timerWrapper = lv_obj_create(parent);
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);
ctx->delayRow = createRow(card, "Delay");
ctx->delayTextArea = lv_textarea_create(ctx->delayRow);
lv_textarea_set_one_line(ctx->delayTextArea, true);
lv_textarea_set_accepted_chars(ctx->delayTextArea, "0123456789");
lv_textarea_set_text(ctx->delayTextArea, "10");
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);
lv_obj_set_style_border_width(delay_unit_label_wrapper, 0, 0);
lv_obj_set_style_pad_all(delay_unit_label_wrapper, 0, 0);
lv_obj_set_size(delay_unit_label_wrapper, LV_SIZE_CONTENT, 36);
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 createStartStopButton(Context* ctx, lv_obj_t* parent) {
auto* button = lv_button_create(parent);
lv_obj_add_event_cb(button, onStartPressed, LV_EVENT_SHORT_CLICKED, ctx);
ctx->startStopButtonLabel = lv_label_create(button);
}
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_style_border_width(wrapper, 0, 0);
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);
createFilePathWidgets(ctx, wrapper);
createTimerSettingsWidgets(ctx, wrapper);
auto service = service::screenshot::optScreenshotService();
if (service != nullptr && service->getMode() == service::screenshot::Mode::Apps) {
ctx->mode = CaptureMode::AppStart;
}
createModeWidgets(ctx, wrapper);
createSettingsWidgets(ctx, wrapper);
createStartStopButton(ctx, wrapper);
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/check.h>
#include <Tactility/DeprecatedPaths.h>
#include <Tactility/Tactility.h>
#include <Tactility/TactilityConfig.h>
#include <Tactility/Timer.h>
@ -14,11 +17,14 @@
#include <lvgl_window_manager/window_manager.h>
#include <algorithm>
#include <cstring>
#include <strings.h>
#include <format>
#include <utility>
#include <vector>
#include <string>
#include <lvgl/fonts.h>
#include <lvgl/grid_navigation.h>
#include <lvgl/lvgl.h>
#include <lvgl/widgets/toolbar.h>
@ -197,40 +203,81 @@ const char* getTaskState(const TaskStatus_t& task) {
}
}
void clearContainer(lv_obj_t* container) {
lv_obj_clean(container);
const char* getTaskName(const TaskStatus_t& task) {
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);
const char* name = (task.pcTaskName == nullptr || task.pcTaskName[0] == 0) ? "(unnamed)" : task.pcTaskName;
lv_label_set_text_fmt(label, "%s (%s)", name, getTaskState(task));
}
void updateRtosTasks(lv_obj_t* parent) {
clearContainer(parent);
/**
* Updates the rows in place, so the list keeps its height and scroll position, and the selected task stays selected.
* @param[in] parent the list
* @param[in,out] taskNames the task name of each row
*/
void updateRtosTasks(lv_obj_t* parent, std::vector<std::string>& taskNames) {
lv_obj_t* selected = lvgl_grid_navigation_get_focused(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();
auto* tasks = (TaskStatus_t*)malloc(sizeof(TaskStatus_t) * count);
if (!tasks) {
lv_obj_clean(parent);
taskNames.clear();
auto* error_label = lv_label_create(parent);
lv_label_set_text(error_label, "Failed to allocate memory for task list");
return;
}
uint32_t totalRuntime = 0;
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) {
addRtosTask(parent, tasks[i]);
taskNames.clear();
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
lv_obj_t* createTab(lv_obj_t* tabview, const char* 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_style_pad_row(tab, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(tab, 0, LV_STATE_DEFAULT);
@ -250,6 +297,8 @@ struct Context {
MemoryBarWidgets systemStorageBar;
lv_obj_t* tasksContainer = nullptr;
/** The task name of each row in tasksContainer */
std::vector<std::string> taskNames;
lv_obj_t* psramContainer = nullptr;
bool hasExternalMem = false;
@ -293,7 +342,7 @@ void updateStorage(Context* ctx) {
void updateTasks(Context* ctx) {
#if configUSE_TRACE_FACILITY
if (ctx->tasksContainer) {
updateRtosTasks(ctx->tasksContainer); // Tasks tab: show state
updateRtosTasks(ctx->tasksContainer, ctx->taskNames); // Tasks tab: show state
}
#endif
}
@ -303,6 +352,24 @@ void onBackPressed(lv_event_t* event) {
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) {
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_flex_grow(wrapper, 1);
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);
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
auto* memory_tab = createTab(tabview, "Memory");
auto* storage_tab = createTab(tabview, "Storage");
auto* tasks_tab = createTab(tabview, "Tasks");
auto* about_tab = createTab(tabview, "About");
auto* memory_tab = createTab(tabview, LVGL_ICON_SHARED_MEMORY);
auto* storage_tab = createTab(tabview, LVGL_ICON_SHARED_HARD_DISK);
auto* tasks_tab = createTab(tabview, LVGL_ICON_SHARED_SELECT_WINDOW_2);
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
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_flex_flow(ctx->tasksContainer, LV_FLEX_FLOW_COLUMN);
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
// Build info

View File

@ -1,3 +1,4 @@
#include <Tactility/app/selectiondialog/SelectionDialog.h>
#include <Tactility/app/timedatesettings/TimeDateSettings.h>
#include <Tactility/app/timezone/TimeZone.h>
#include <Tactility/settings/SystemSettings.h>
@ -12,11 +13,17 @@
#include <lvgl_window_manager/window_manager.h>
#include <tactility/check.h>
#include <tactility/concurrent/task_event_group.h>
#include <tactility/log.h>
#include <lvgl/fonts.h>
#include <lvgl/lvgl.h>
#include <lvgl/widgets/card.h>
#include <lvgl/widgets/toolbar.h>
#include <string>
#include <vector>
namespace tt::app::timedatesettings {
constexpr auto* TAG = "TimeDate";
@ -25,13 +32,31 @@ extern const ::AppManifest manifest;
namespace {
constexpr const char* DATE_FORMATS[] = { "MM/DD/YYYY", "DD/MM/YYYY", "YYYY-MM-DD", "YYYY/MM/DD" };
struct Context {
uint32_t appInstanceId;
lv_obj_t* timeZoneLabel = nullptr;
lv_obj_t* dateFormatDropdown = nullptr;
TaskEventGroup* eventGroup = nullptr;
uint32_t selectDateFormatBit = 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) {
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);
}
void onDateFormatChanged(lv_event_t* event) {
auto* dropdown = static_cast<lv_obj_t*>(lv_event_get_target(event));
auto index = lv_dropdown_get_selected(dropdown);
void onSelectDateFormatPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
task_event_group_signal(ctx->eventGroup, ctx->selectDateFormatBit);
}
const char* dateFormats[] = {"MM/DD/YYYY", "DD/MM/YYYY", "YYYY-MM-DD", "YYYY/MM/DD"};
std::string selected_format = dateFormats[index];
settings::SystemSettings sysSettings;
if (settings::loadSystemSettings(sysSettings)) {
sysSettings.dateFormat = selected_format;
settings::saveSystemSettings(sysSettings);
void startDateFormatSelection(Context* ctx) {
if (ctx->dateFormatDialogId != 0) {
return;
}
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) {
@ -73,24 +143,32 @@ void createWidgets(lv_obj_t* parent, void* userData) {
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
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_width(main_wrapper, LV_PCT(100));
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
auto* time_format_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_width(time_format_wrapper, LV_PCT(100));
lv_obj_set_height(time_format_wrapper, LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(time_format_wrapper, 8, 0);
lv_obj_set_style_border_width(time_format_wrapper, 0, 0);
auto* time_format_row = lv_obj_create(card);
lv_obj_set_size(time_format_row, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(time_format_row, LV_FLEX_FLOW_ROW);
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_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_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);
lv_obj_align(time_24h_switch, LV_ALIGN_RIGHT_MID, 0, 0);
auto* time_24h_switch = lv_switch_create(time_format_row);
lv_obj_add_event_cb(time_24h_switch, onTimeFormatChanged, LV_EVENT_VALUE_CHANGED, nullptr);
if (settings::isTimeFormat24Hour()) {
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);
}
// Date format dropdown
// "Date format MM/DD/YYYY [Select]"
auto* date_format_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_width(date_format_wrapper, LV_PCT(100));
lv_obj_set_height(date_format_wrapper, LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(date_format_wrapper, 8, 0);
lv_obj_set_style_border_width(date_format_wrapper, 0, 0);
auto* date_format_row = lv_obj_create(card);
lv_obj_set_size(date_format_row, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(date_format_row, LV_FLEX_FLOW_ROW);
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_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);
lv_label_set_text(date_format_label, "Date format");
lv_obj_align(date_format_label, LV_ALIGN_LEFT_MID, 4, 0);
ctx->dateFormatLabel = createTitleAndValue(date_format_row, "Date format");
lv_label_set_text(ctx->dateFormatLabel, getDateFormat().c_str());
ctx->dateFormatDropdown = lv_dropdown_create(date_format_wrapper);
lv_obj_set_width(ctx->dateFormatDropdown, 150);
lv_obj_align(ctx->dateFormatDropdown, LV_ALIGN_RIGHT_MID, 0, 0);
lv_dropdown_set_options(ctx->dateFormatDropdown, "MM/DD/YYYY\nDD/MM/YYYY\nYYYY-MM-DD\nYYYY/MM/DD");
auto* date_format_button = lv_button_create(date_format_row);
lv_label_set_text(lv_label_create(date_format_button), "Change");
lv_obj_add_event_cb(date_format_button, onSelectDateFormatPressed, LV_EVENT_SHORT_CLICKED, ctx);
settings::SystemSettings sysSettings;
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 Europe/Brussels [Change]"
// 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);
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);
ctx->timeZoneLabel = createTitleAndValue(timezone_row, "Timezone");
std::string timeZoneName = settings::getTimeZoneName();
if (timeZoneName.empty()) {
timeZoneName = "not set";
}
lv_obj_center(ctx->timeZoneLabel);
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[]) {
@ -158,15 +230,22 @@ int32_t appMain(int argc, char* argv[]) {
TaskEventGroup 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 {};
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;
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 {};
while (app_event_poll(&sub, &event) == ERROR_NONE) {
@ -175,14 +254,18 @@ int32_t appMain(int argc, char* argv[]) {
shouldClose = true;
break;
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;
if (event.result.result == 0 /* Ok */) {
const auto name = timezone::getLastName();
LOG_I(TAG, "Result name=%s code=%s", name.c_str(), timezone::getLastCode().c_str());
if (!name.empty()) {
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();
}
}

View File

@ -3,6 +3,10 @@
#include <lvgl/devices/device_context.h>
#include <lvgl/devices/trackball.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 <tactility/check.h>
@ -25,14 +29,16 @@ extern const ::AppManifest manifest;
constexpr auto* TAG = "TrackballSettings";
// Convert mode to dropdown index (dropdown order: Keys=0, Pointer=1)
static uint32_t modeToDropdownIndex(LvglTrackballMode mode) {
switch (mode) {
case LVGL_TRACKBALL_MODE_KEYS: return 0;
case LVGL_TRACKBALL_MODE_POINTER: return 1;
}
return 0; // default to Keys
}
struct ModeOption {
LvglTrackballMode mode;
const char* name;
};
// The order of the mode chips
constexpr ModeOption MODE_OPTIONS[] = {
{ LVGL_TRACKBALL_MODE_KEYS, "Keys" },
{ LVGL_TRACKBALL_MODE_POINTER, "Pointer" },
};
static lv_indev_t* findFirstTrackballIndev() {
lv_indev_t* indev = lv_indev_get_next(nullptr);
@ -64,8 +70,14 @@ struct Context {
// to whatever is already attached.
lv_indev_t* trackballIndev = 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;
// Shown in pointer mode
lv_obj_t* pointerSensitivityGroup = nullptr;
lv_obj_t* pointerSensitivitySlider = nullptr;
};
@ -87,47 +99,37 @@ void applyLive(Context* ctx) {
lvgl_unlock();
}
void onTrackballSwitch(lv_event_t* e) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(e));
bool enabled = lv_obj_has_state(ctx->switchTrackball, LV_STATE_CHECKED);
ctx->tbSettings.enabled = enabled;
ctx->updated = true;
applyLive(ctx);
// 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);
/** Shows the settings of the enabled trackball and its mode */
void updateWidgets(Context* ctx) {
lv_obj_set_flag(ctx->settingsCard, LV_OBJ_FLAG_HIDDEN, !ctx->tbSettings.enabled);
const uint32_t chip_count = lv_obj_get_child_count(ctx->modeChips);
for (uint32_t i = 0; i < chip_count; i++) {
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);
}
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));
uint32_t selected = lv_dropdown_get_selected(ctx->trackballModeDropdown);
// 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->tbSettings.enabled = lv_obj_has_state(ctx->switchTrackball, LV_STATE_CHECKED);
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
applyLive(ctx);
updateWidgets(ctx);
}
void onKeySensitivityChanged(lv_event_t* 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->updated = true;
@ -137,7 +139,7 @@ void onKeySensitivityChanged(lv_event_t* e) {
void onPointerSensitivityChanged(lv_event_t* 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->updated = true;
@ -145,6 +147,24 @@ void onPointerSensitivityChanged(lv_event_t* e) {
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) {
auto* ctx = static_cast<Context*>(userData);
@ -161,12 +181,13 @@ void createWidgets(lv_obj_t* parent, void* userData) {
if (ctx->trackballIndev == nullptr) {
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_flex_grow(wrapper, 1);
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* label = lv_label_create(wrapper);
lv_label_set_text(label, "No trackball device found");
auto* card = lvgl_card_create(wrapper);
lv_obj_set_size(card, LV_PCT(100), LV_SIZE_CONTENT);
lv_label_set_text(lv_label_create(card), "No trackball device found.");
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);
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_width(main_wrapper, LV_PCT(100));
lv_obj_set_flex_grow(main_wrapper, 1);
// Trackball mode dropdown
auto* tb_mode_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(tb_mode_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(tb_mode_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(tb_mode_wrapper, 0, LV_STATE_DEFAULT);
ctx->settingsCard = lvgl_card_create(main_wrapper);
lv_obj_set_size(ctx->settingsCard, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(ctx->settingsCard, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(ctx->settingsCard, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
auto* tb_mode_label = lv_label_create(tb_mode_wrapper);
lv_label_set_text(tb_mode_label, "Mode");
lv_obj_align(tb_mode_label, LV_ALIGN_LEFT_MID, 0, 0);
ctx->trackballModeDropdown = lv_dropdown_create(tb_mode_wrapper);
lv_dropdown_set_options(ctx->trackballModeDropdown, "Keys\nPointer");
lv_obj_align(ctx->trackballModeDropdown, LV_ALIGN_RIGHT_MID, 0, 0);
lv_dropdown_set_selected(ctx->trackballModeDropdown, modeToDropdownIndex(ctx->tbSettings.mode));
lv_obj_add_event_cb(ctx->trackballModeDropdown, onTrackballModeChanged, LV_EVENT_VALUE_CHANGED, ctx);
// Disable dropdown if trackball is disabled
if (!ctx->tbSettings.enabled) {
lv_obj_add_state(ctx->trackballModeDropdown, LV_STATE_DISABLED);
// Mode: the chips are centered while they fit, and scroll when they don't
ctx->modeChips = lv_obj_create(ctx->settingsCard);
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);
// Key sensitivity slider
auto* key_sens_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(key_sens_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
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);
// The speed of the selected mode
ctx->keySensitivityGroup = createSpeedGroup(ctx->settingsCard, "Key speed", ctx->tbSettings.key_sensitivity, onKeySensitivityChanged, ctx, &ctx->keySensitivitySlider);
ctx->pointerSensitivityGroup = createSpeedGroup(ctx->settingsCard, "Pointer speed", ctx->tbSettings.pointer_sensitivity, onPointerSensitivityChanged, ctx, &ctx->pointerSensitivitySlider);
auto* key_sens_label = lv_label_create(key_sens_wrapper);
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);
}
updateWidgets(ctx);
}
// 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/lvgl.h>
#include <lvgl/grid_navigation.h>
#include <lvgl/widgets/card.h>
#include <lvgl/widgets/chip.h>
#include <lvgl/widgets/toolbar.h>
namespace tt::app::webserversettings {
@ -33,7 +36,15 @@ struct Context {
settings::webserver::WebServerSettings originalSettings;
bool updated = 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* switchApOpenNetwork = nullptr;
lv_obj_t* switchWebServerEnabled = nullptr;
@ -53,10 +64,34 @@ void onBackPressed(lv_event_t* event) {
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* dropdown = static_cast<lv_obj_t*>(lv_event_get_target(e));
auto index = lv_dropdown_get_selected(dropdown);
// Chip 0 is station mode, chip 1 is access point mode
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] {
ctx->wsSettings.wifiMode = static_cast<settings::webserver::WiFiMode>(index);
ctx->updated = true;
@ -70,6 +105,7 @@ void onWifiModeChanged(lv_event_t* e) {
void onWebServerEnabledSwitch(lv_event_t* e) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(e));
bool enabled = lv_obj_has_state(ctx->switchWebServerEnabled, LV_STATE_CHECKED);
updateVisibility(ctx);
getMainDispatcher().dispatch([ctx, enabled] {
ctx->wsSettings.webServerEnabled = enabled;
ctx->updated = true;
@ -91,22 +127,7 @@ void onWebServerEnabledSwitch(lv_event_t* e) {
void onWebServerAuthEnabledSwitch(lv_event_t* e) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(e));
bool enabled = lv_obj_has_state(ctx->switchWebServerAuthEnabled, LV_STATE_CHECKED);
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);
}
}
updateVisibility(ctx);
getMainDispatcher().dispatch([ctx, enabled] {
ctx->wsSettings.webServerAuthEnabled = enabled;
@ -132,16 +153,7 @@ void onApPasswordChanged(lv_event_t* e) {
void onApOpenNetworkSwitch(lv_event_t* e) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(e));
bool openNetwork = lv_obj_has_state(ctx->switchApOpenNetwork, LV_STATE_CHECKED);
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);
}
}
updateVisibility(ctx);
getMainDispatcher().dispatch([ctx, openNetwork] {
ctx->wsSettings.apOpenNetwork = openNetwork;
@ -187,6 +199,51 @@ void updateUrlDisplay(Context* ctx) {
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) {
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);
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_width(main_wrapper, LV_PCT(100));
lv_obj_set_flex_grow(main_wrapper, 1);
// WiFi Mode dropdown
auto* wifi_mode_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(wifi_mode_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(wifi_mode_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(wifi_mode_wrapper, 0, LV_STATE_DEFAULT);
auto* wifi_mode_label = lv_label_create(wifi_mode_wrapper);
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);
// Address, while the web server is enabled
ctx->urlCard = createCard(main_wrapper);
ctx->labelUrl = lv_label_create(ctx->urlCard);
lv_label_set_text(ctx->labelUrl, "Web server address");
ctx->labelUrlValue = lv_label_create(ctx->urlCard);
updateUrlDisplay(ctx);
// AP Open Network toggle
auto* ap_open_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(ap_open_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(ap_open_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(ap_open_wrapper, 0, LV_STATE_DEFAULT);
auto* ap_open_label = lv_label_create(ap_open_wrapper);
lv_label_set_text(ap_open_label, "AP Open Network");
lv_obj_align(ap_open_label, LV_ALIGN_LEFT_MID, 0, 0);
ctx->switchApOpenNetwork = lv_switch_create(ap_open_wrapper);
// WiFi mode, with the access point settings in access point mode
lv_label_set_text(lv_label_create(main_wrapper), "WiFi mode");
auto* wifi_card = createCard(main_wrapper);
lv_obj_set_flex_align(wifi_card, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
// The chips are centered while they fit, and scroll when they don't
ctx->wifiModeChips = lv_obj_create(wifi_card);
lv_obj_set_size(ctx->wifiModeChips, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
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);
lv_obj_align(ctx->switchApOpenNetwork, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_add_event_cb(ctx->switchApOpenNetwork, onApOpenNetworkSwitch, LV_EVENT_VALUE_CHANGED, ctx);
// AP Password
auto* ap_pass_wrapper = lv_obj_create(main_wrapper);
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());
ctx->apPasswordRow = createRow(ctx->apGroup, "Password");
ctx->textAreaApPassword = createTextArea(ctx->apPasswordRow, ctx->wsSettings.apPassword.c_str(), 64, true);
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
auto* ws_auth_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(ws_auth_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
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);
// Authentication, with the credentials while it's required
auto* auth_row = createRow(main_wrapper, "Require authentication");
ctx->switchWebServerAuthEnabled = lv_switch_create(auth_row);
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);
// WebServer Username
auto* ws_user_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(ws_user_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
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());
ctx->authCard = createCard(main_wrapper);
auto* user_row = createRow(ctx->authCard, "Username");
ctx->textAreaWebServerUsername = createTextArea(user_row, ctx->wsSettings.webServerUsername.c_str(), 32, false);
lv_obj_add_event_cb(ctx->textAreaWebServerUsername, onCredentialChanged, LV_EVENT_VALUE_CHANGED, ctx);
// WebServer Password
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());
auto* password_row = createRow(ctx->authCard, "Password");
ctx->textAreaWebServerPassword = createTextArea(password_row, ctx->wsSettings.webServerPassword.c_str(), 64, true);
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
auto* info_label = lv_label_create(main_wrapper);
lv_label_set_long_mode(info_label, LV_LABEL_LONG_WRAP);
lv_obj_set_width(info_label, LV_PCT(95));
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_obj_set_width(info_label, LV_PCT(100));
lv_label_set_text(info_label,
"WiFi Station credentials are managed separately.\n"
"Use the WiFi menu to connect to networks.\n\n"
"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[]) {

View File

@ -255,6 +255,9 @@ void createWidgets(lv_obj_t* parent, void* userData) {
ctx->autoConnectWrapper = lv_obj_create(wrapper);
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);
lv_obj_set_style_pad_all(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);
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);
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[]) {

View File

@ -13,6 +13,8 @@
#include <wifi/wifi_settings.h>
#include <lvgl/lvgl.h>
#include <lvgl/theme.h>
#include <lvgl/widgets/card.h>
#include <lvgl/widgets/spinner.h>
#include <lvgl/widgets/toolbar.h>
@ -49,7 +51,7 @@ struct Context {
lv_obj_t* password_textarea = nullptr;
lv_obj_t* password_error = 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* 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_add_state(ctx->password_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 {
lv_obj_remove_flag(ctx->connect_button, 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->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;
}
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);
@ -198,26 +200,23 @@ void createBottomButtons(Context* ctx, lv_obj_t* parent) {
lv_obj_set_width(button_container, LV_PCT(100));
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_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_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);
lv_obj_add_state(ctx->remember_switch, LV_STATE_CHECKED);
lv_obj_align(ctx->remember_switch, LV_ALIGN_LEFT_MID, 0, 0);
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->remember_checkbox = lv_checkbox_create(button_container);
lv_checkbox_set_text(ctx->remember_checkbox, "Remember");
lv_obj_add_state(ctx->remember_checkbox, LV_STATE_CHECKED);
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);
ctx->connect_button = lv_btn_create(button_container);
auto* connect_label = lv_label_create(ctx->connect_button);
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);
}
@ -230,11 +229,42 @@ void destroyWidgets(void* userData) {
ctx->password_textarea = nullptr;
ctx->password_error = nullptr;
ctx->connect_button = nullptr;
ctx->remember_switch = nullptr;
ctx->remember_checkbox = nullptr;
ctx->connecting_spinner = 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
void createWidgets(lv_obj_t* parent, void* 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);
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_flex_grow(wrapper, 1);
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
auto* ssid_wrapper = lv_obj_create(wrapper);
lv_obj_set_width(ssid_wrapper, LV_PCT(100));
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);
auto* ssid_row = createRow(card, "Network");
ctx->ssid_textarea = lv_textarea_create(ssid_row);
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(50));
lv_obj_set_width(ctx->ssid_textarea, LV_PCT(60));
ctx->ssid_error = lv_label_create(wrapper);
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);
ctx->ssid_error = createErrorLabel(card);
// Password
auto* password_wrapper = lv_obj_create(wrapper);
lv_obj_set_width(password_wrapper, LV_PCT(100));
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);
auto* password_row = createRow(card, "Password");
ctx->password_textarea = lv_textarea_create(password_row);
lv_textarea_set_one_line(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(50));
lv_obj_set_width(ctx->password_textarea, LV_PCT(60));
ctx->password_error = lv_label_create(wrapper);
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);
ctx->password_error = createErrorLabel(card);
// Connection error
ctx->connection_error = lv_label_create(wrapper);
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);
ctx->connection_error = createErrorLabel(card);
// Bottom buttons
createBottomButtons(ctx, wrapper);
createBottomButtons(ctx, card);
// Init from app parameters
if (!ctx->initialSsid.empty()) {

View File

@ -13,6 +13,7 @@
#include <tactility/log.h>
#include <lvgl/lvgl.h>
#include <lvgl/widgets/card.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) {
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);
} else {
const std::string auth_info = (record.authentication_type == WIFI_AUTHENTICATION_TYPE_OPEN) ? "(open) " : " ";
const auto percentage = mapRssiToPercentage(record.rssi);
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));
if (wifi_settings_contains(record.ssid)) {
lv_obj_add_event_cb(button, showDetails, LV_EVENT_SHORT_CLICKED, this);
@ -142,22 +162,20 @@ void View::updateNetworkList() {
// 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_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);
auto* enable_on_boot_wrapper = lv_obj_create(networks_list);
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);
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);
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_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) {
auto& record = ap_records[i];
if (record.ssid == connection_target) {
lv_list_add_text(networks_list, "Connected");
createSsidListItem(record, false, i);
createSsidListItem(createSection(networks_list, "Connected"), record, false, i);
added_connected = true;
break;
}
}
}
lv_list_add_text(networks_list, "Other networks");
auto* networks_section = createSection(networks_list, "Networks");
std::set<std::string> used_ssids;
if (!ap_records.empty()) {
for (int i = 0; i < ap_records.size(); ++i) {
@ -200,7 +217,7 @@ void View::updateNetworkList() {
!connection_target.empty();
bool skip = connection_target_match && added_connected;
if (!skip) {
createSsidListItem(record, is_connecting, i);
createSsidListItem(networks_section, record, is_connecting, i);
}
used_ssids.insert(record.ssid);
}
@ -211,8 +228,7 @@ void View::updateNetworkList() {
lv_obj_add_flag(networks_list, LV_OBJ_FLAG_HIDDEN);
} else {
lv_obj_clear_flag(networks_list, LV_OBJ_FLAG_HIDDEN);
lv_obj_t* label = lv_label_create(networks_list);
lv_label_set_text(label, "No networks found.");
lv_list_add_text(networks_section, "No networks found.");
}
connect_to_hidden = lv_button_create(networks_list);
@ -296,9 +312,11 @@ void View::init(uint32_t newAppInstanceId, lv_obj_t* parent) {
// Networks
networks_list = lv_list_create(parent);
networks_list = lv_obj_create(parent);
lv_obj_set_flex_grow(networks_list, 1);
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() {

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_center(obj);
auto icon_size = lvgl_get_statusbar_icon_font_height();
auto ui_density = lvgl_get_ui_density();
auto icon_padding = (ui_density != LVGL_UI_DENSITY_COMPACT) ? static_cast<uint32_t>(icon_size * 0.2f) : 2;
const bool is_compact = lvgl_theme_is_compact();
auto icon_padding = !is_compact ? static_cast<uint32_t>(icon_size * 0.2f) : 2;
lv_display_t* display = lv_obj_get_display(obj);
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
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);
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_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;
if (settings.primaryColor.has_value()) {
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()) {
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);
}

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_THEME_MODE = "themeMode";
constexpr auto* SETTINGS_KEY_MONO_THEME = "monoTheme";
constexpr auto* SETTINGS_KEY_DENSITY = "density";
constexpr auto* SETTINGS_KEY_ANIMATIONS = "animations";
constexpr auto* SETTINGS_KEY_PRIMARY_COLOR = "primaryColor";
constexpr auto* SETTINGS_KEY_SECONDARY_COLOR = "secondaryColor";
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) {
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() {
char path[256];
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()) {
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()) {
settings.animationsEnabled = entry->second != "false";
}
settings.primaryColor = parseColor(map, SETTINGS_KEY_PRIMARY_COLOR);
settings.secondaryColor = parseColor(map, SETTINGS_KEY_SECONDARY_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;
}
@ -127,10 +175,13 @@ bool save(const AppearanceSettings& settings) {
map[SETTINGS_KEY_MONO_FONT_PATH] = settings.monoFontPath;
map[SETTINGS_KEY_THEME_MODE] = toString(settings.themeMode);
map[SETTINGS_KEY_MONO_THEME] = settings.monoTheme ? "true" : "false";
map[SETTINGS_KEY_DENSITY] = toString(settings.densityMode);
map[SETTINGS_KEY_ANIMATIONS] = settings.animationsEnabled ? "true" : "false";
putColor(map, SETTINGS_KEY_PRIMARY_COLOR, settings.primaryColor);
putColor(map, SETTINGS_KEY_SECONDARY_COLOR, settings.secondaryColor);
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);
}