UX improvements (#686)

- Introduce "side" mode for system bar, versus classic statusbar on top with launcher bar below
- Automatic system bar mode detection with override by Kconfig/device.properties and secondary override by user via Appereance settings app
- Open a quick-settings menu from the status bar for available Wi-Fi, Bluetooth, GPS, and power controls.
- Apps can display overlays with backdrop or Escape-key dismissal.
- Added focus-container support and improved keyboard navigation, including Fn+Enter on Cardputer devices.
- Power-off controls are available on supported device cards.
- Updated status-bar layouts, simulator display dimensions, and toolbar navigation styling.
- I²C scanning now supports selecting individual buses to rescan.
- SD-card mount status is no longer shown in the status bar.
This commit is contained in:
Ken Van Hoeylandt 2026-10-11 00:32:27 +02:00 committed by GitHub
parent cb4ce5246d
commit b9a96ca26d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
67 changed files with 2032 additions and 233 deletions

View File

@ -6,3 +6,21 @@ Two kernel modules cover LVGL:
- **`lvgl-module`** (`Modules/lvgl-module/`, `<lvgl/*.h>`) owns LVGL's lifecycle: init/deinit, the LVGL task loop, and `lvgl_lock()`/`lvgl_try_lock()`/`lvgl_unlock()` mutex-based locking that any task must hold before touching LVGL objects. It bridges Tactility's device model to LVGL indevs (`lvgl/devices/*.h`: `display`, `pointer`, `keyboard`, `trackball`), and provides shared fonts (`lvgl/fonts.h`: Montserrat text sizes, Material Symbols icon sets for statusbar/launcher/shared use) and a few shared widgets (`lvgl/widgets/*.h`: `toolbar`, `spinner`, `sliderbox`). - **`lvgl-module`** (`Modules/lvgl-module/`, `<lvgl/*.h>`) owns LVGL's lifecycle: init/deinit, the LVGL task loop, and `lvgl_lock()`/`lvgl_try_lock()`/`lvgl_unlock()` mutex-based locking that any task must hold before touching LVGL objects. It bridges Tactility's device model to LVGL indevs (`lvgl/devices/*.h`: `display`, `pointer`, `keyboard`, `trackball`), and provides shared fonts (`lvgl/fonts.h`: Montserrat text sizes, Material Symbols icon sets for statusbar/launcher/shared use) and a few shared widgets (`lvgl/widgets/*.h`: `toolbar`, `spinner`, `sliderbox`).
- **`lvgl-window-manager-module`** (`Modules/lvgl-window-manager-module/`, `<lvgl_window_manager/*.h>`) manages a single stacked window per app instance on top of `lvgl-module`. `window_manager_start()`/`window_manager_stop()` create/tear down the root widget (plus optional chrome from a configured `WindowManagerScreenInitFn`); `window_manager_create()`/`window_manager_remove()` push/pop an app's window and (re)populate it via a `WindowCreateWidgetsFn`. Only the topmost window ever has live widgets - burying and resurfacing a window deletes and rebuilds its widget tree rather than hiding/showing it. That rebuild-on-remove path can run `create_widgets` on a *different* app's thread (whichever app's `window_manager_remove()` call caused this window to resurface), so `create_widgets` must only rebuild already-committed state, never decide what happens next - state transitions belong in the app's own `main()` event loop, driven by real `APP_EVENT_RESULT`s. - **`lvgl-window-manager-module`** (`Modules/lvgl-window-manager-module/`, `<lvgl_window_manager/*.h>`) manages a single stacked window per app instance on top of `lvgl-module`. `window_manager_start()`/`window_manager_stop()` create/tear down the root widget (plus optional chrome from a configured `WindowManagerScreenInitFn`); `window_manager_create()`/`window_manager_remove()` push/pop an app's window and (re)populate it via a `WindowCreateWidgetsFn`. Only the topmost window ever has live widgets - burying and resurfacing a window deletes and rebuilds its widget tree rather than hiding/showing it. That rebuild-on-remove path can run `create_widgets` on a *different* app's thread (whichever app's `window_manager_remove()` call caused this window to resurface), so `create_widgets` must only rebuild already-committed state, never decide what happens next - state transitions belong in the app's own `main()` event loop, driven by real `APP_EVENT_RESULT`s.
## Keyboard navigation
- Put groups of related focusable widgets (pills/chips, rows of buttons, toggle tiles) in a grid navigation container with `lvgl_grid_navigation_add()` (`lvgl/grid_navigation.h`). Only the container joins the input group: left/right moves within the group and up/down moves between groups.
- Never make checkable widgets (switches, checkable buttons) direct input group members: LVGL toggles them on arrow keys. Grid navigation forwards only Enter to them.
## Theming (LSS)
The themes in `Modules/lvgl-module/source/themes/lv_theme_material*.c/.h` are generated: never edit them directly. They're compiled from the LVGL Style Sheets in `Modules/lvgl-module/themes/*.lss` by the LSS tool, a git submodule at `Modules/lvgl-module/lss/` (see its `README.md` for the syntax).
- Styles are defined once and attached to widget types with `apply(<widget>) { <style>: <state/part>; }` rules. Custom widget classes of `lvgl-module` (e.g. `lvgl_card_class`) are declared with `@widget <name>: <class>;`.
- Change `material.lss` and `material_mono.lss` together, then regenerate (requires `pip install lark`):
```bash
cd Modules/lvgl-module
python lss/compile.py themes/material.lss --output source/themes
python lss/compile.py themes/material_mono.lss --output source/themes
```

5
.gitignore vendored
View File

@ -1,5 +1,4 @@
.idea/ .idea/
.DS_Store
build*/ build*/
!.github/actions/build*/ !.github/actions/build*/
@ -8,6 +7,8 @@ CMakeCache.txt
*.cbp *.cbp
CMakeFiles CMakeFiles
Data/data
release/ release/
sdkconfig sdkconfig
@ -29,3 +30,5 @@ sdkconfig.board.*.dev
.clinerules/ .clinerules/
__pycache__ __pycache__
.DS_Store
.cache

3
.gitmodules vendored
View File

@ -16,3 +16,6 @@
[submodule "Libraries/cJSON/cJSON"] [submodule "Libraries/cJSON/cJSON"]
path = Libraries/cJSON/cJSON path = Libraries/cJSON/cJSON
url = https://github.com/DaveGamble/cJSON.git url = https://github.com/DaveGamble/cJSON.git
[submodule "Modules/lvgl-module/lss"]
path = Modules/lvgl-module/lss
url = https://github.com/ByteWelder/LSS.git

View File

@ -9,3 +9,4 @@ lvgl-window-manager-module
service-module service-module
graphics-module graphics-module
wifi-module wifi-module
binfont-module

View File

@ -86,3 +86,4 @@ terminal eb8e
toolbar e9f7 toolbar e9f7
usb e1e0 usb e1e0
wifi e63e wifi e63e
wifi_off e648

View File

@ -1,10 +1,12 @@
/dts-v1/; /dts-v1/;
#include <tactility/bindings/root.h> #include <tactility/bindings/root.h>
#include <tactility/bindings/esp32_ble.h>
#include <tactility/bindings/esp32_gpio.h> #include <tactility/bindings/esp32_gpio.h>
#include <tactility/bindings/esp32_grove.h> #include <tactility/bindings/esp32_grove.h>
#include <tactility/bindings/esp32_i2c_master.h> #include <tactility/bindings/esp32_i2c_master.h>
#include <tactility/bindings/esp32_spi.h> #include <tactility/bindings/esp32_spi.h>
#include <tactility/bindings/esp32_usbdevice.h>
#include <tactility/bindings/esp32_sdspi.h> #include <tactility/bindings/esp32_sdspi.h>
#include <tactility/bindings/esp32_wifi.h> #include <tactility/bindings/esp32_wifi.h>
@ -22,7 +24,18 @@
wifi0 { wifi0 {
compatible = "espressif,esp32-wifi"; compatible = "espressif,esp32-wifi";
status = "disabled"; };
usbdevice0 {
compatible = "espressif,esp32-usbdevice";
usbdevicemsc0 {
compatible = "espressif,esp32-usbdevice-msc";
};
};
ble0 {
compatible = "espressif,esp32-ble";
}; };
// Top Stemma/Qwiic port 1 shares this bus with the RTC (SDA: pin 1 <-> SCL: pin 2). External // Top Stemma/Qwiic port 1 shares this bus with the RTC (SDA: pin 1 <-> SCL: pin 2). External

View File

@ -9,6 +9,7 @@ hardware.spiRamSpeed=80M
hardware.spiRamXipDisabled=true hardware.spiRamXipDisabled=true
hardware.esptoolFlashFreq=80M hardware.esptoolFlashFreq=80M
hardware.bluetooth=true hardware.bluetooth=true
hardware.tinyUsbMsc=true
storage.userDataLocation=SD storage.userDataLocation=SD

View File

@ -1,10 +1,12 @@
/dts-v1/; /dts-v1/;
#include <tactility/bindings/root.h> #include <tactility/bindings/root.h>
#include <tactility/bindings/esp32_ble.h>
#include <tactility/bindings/esp32_gpio.h> #include <tactility/bindings/esp32_gpio.h>
#include <tactility/bindings/esp32_grove.h> #include <tactility/bindings/esp32_grove.h>
#include <tactility/bindings/esp32_i2c_master.h> #include <tactility/bindings/esp32_i2c_master.h>
#include <tactility/bindings/esp32_spi.h> #include <tactility/bindings/esp32_spi.h>
#include <tactility/bindings/esp32_usbdevice.h>
#include <tactility/bindings/esp32_sdspi.h> #include <tactility/bindings/esp32_sdspi.h>
#include <tactility/bindings/esp32_wifi.h> #include <tactility/bindings/esp32_wifi.h>
@ -25,7 +27,18 @@
wifi0 { wifi0 {
compatible = "espressif,esp32-wifi"; compatible = "espressif,esp32-wifi";
status = "disabled"; };
usbdevice0 {
compatible = "espressif,esp32-usbdevice";
usbdevicemsc0 {
compatible = "espressif,esp32-usbdevice-msc";
};
};
ble0 {
compatible = "espressif,esp32-ble";
}; };
// Top Stemma/Qwiic port 1 shares this bus with the RTC (SDA: pin 1 <-> SCL: pin 2). External // Top Stemma/Qwiic port 1 shares this bus with the RTC (SDA: pin 1 <-> SCL: pin 2). External

View File

@ -9,6 +9,7 @@ hardware.spiRamSpeed=80M
hardware.spiRamXipDisabled=true hardware.spiRamXipDisabled=true
hardware.esptoolFlashFreq=80M hardware.esptoolFlashFreq=80M
hardware.bluetooth=true hardware.bluetooth=true
hardware.tinyUsbMsc=true
storage.userDataLocation=SD storage.userDataLocation=SD

View File

@ -29,7 +29,6 @@
wifi0 { wifi0 {
compatible = "espressif,esp32-wifi"; compatible = "espressif,esp32-wifi";
status = "disabled";
}; };
ble0 { ble0 {

View File

@ -18,5 +18,4 @@ display.dpi=227
lvgl.colorDepth=16 lvgl.colorDepth=16
lvgl.dpi=150 lvgl.dpi=150
lvgl.uiDensity=compact
lvgl.fontSize=16 lvgl.fontSize=16

View File

@ -16,3 +16,6 @@ display.dpi=139
lvgl.colorDepth=16 lvgl.colorDepth=16
lvgl.uiDensity=compact lvgl.uiDensity=compact
ui.systemBarsPortrait=split
ui.systemBarsLandscape=split

View File

@ -17,4 +17,7 @@ display.dpi=139
lvgl.colorDepth=16 lvgl.colorDepth=16
lvgl.uiDensity=compact lvgl.uiDensity=compact
ui.systemBarsPortrait=split
ui.systemBarsLandscape=split
sdkconfig.CONFIG_CODEC_DUMMY_SUPPORT=y sdkconfig.CONFIG_CODEC_DUMMY_SUPPORT=y

View File

@ -15,3 +15,6 @@ display.dpi=242
lvgl.colorDepth=16 lvgl.colorDepth=16
lvgl.uiDensity=compact lvgl.uiDensity=compact
ui.systemBarsPortrait=split
ui.systemBarsLandscape=split

View File

@ -17,3 +17,6 @@ display.dpi=242
lvgl.colorDepth=16 lvgl.colorDepth=16
lvgl.uiDensity=compact lvgl.uiDensity=compact
ui.systemBarsPortrait=split
ui.systemBarsLandscape=split

View File

@ -28,7 +28,6 @@
wifi0 { wifi0 {
compatible = "espressif,esp32-wifi"; compatible = "espressif,esp32-wifi";
status = "disabled";
}; };
ble0 { ble0 {

View File

@ -29,7 +29,7 @@ static Driver* const simulator_drivers[] = {
// These devices have no real bus to attach to (SDL has no notion of one), but every non-root // These devices have no real bus to attach to (SDL has no notion of one), but every non-root
// device is still expected to have a parent (see Device::parent) - they're parented to root once // device is still expected to have a parent (see Device::parent) - they're parented to root once
// it's available below. // it's available below.
static const SdlDisplayConfig sdl_display_config = { 640, 480 }; static const SdlDisplayConfig sdl_display_config = { 640, 320 };
static Device sdl_display_device {}; static Device sdl_display_device {};
static Device sdl_pointer_device {}; static Device sdl_pointer_device {};
static Device sdl_keyboard_device {}; static Device sdl_keyboard_device {};

View File

@ -20,3 +20,6 @@ display.dpi=143
lvgl.colorDepth=16 lvgl.colorDepth=16
lvgl.uiDensity=compact lvgl.uiDensity=compact
ui.systemBarsPortrait=split
ui.systemBarsLandscape=split

View File

@ -19,3 +19,6 @@ display.dpi=261
lvgl.colorDepth=16 lvgl.colorDepth=16
lvgl.uiDensity=compact lvgl.uiDensity=compact
ui.systemBarsPortrait=split
ui.systemBarsLandscape=split

View File

@ -20,3 +20,6 @@ display.dpi=265
lvgl.colorDepth=16 lvgl.colorDepth=16
lvgl.uiDensity=compact lvgl.uiDensity=compact
ui.systemBarsPortrait=split
ui.systemBarsLandscape=split

View File

@ -18,3 +18,6 @@ display.dpi=247
lvgl.colorDepth=16 lvgl.colorDepth=16
lvgl.uiDensity=compact lvgl.uiDensity=compact
ui.systemBarsPortrait=split
ui.systemBarsLandscape=split

View File

@ -7,9 +7,8 @@
## Higher Priority ## Higher Priority
- esp_lvgl_port settings has a large stack size (~9kB) to fix stackoverflow when LVGL events (e.g. button click) do actions like file operations do actions like file operations. Can we reduce the callstack?
- Improve posix support: use user home path and system /tmp folder. implement uart/i2c drivers properly. - Improve posix support: use user home path and system /tmp folder. implement uart/i2c drivers properly.
- Improve Setup: Add keyboard/keypad/encoder navigation explanation.
e.g. encoder long press on t-lora pager to get out of textarea
- NimBLE looses pairing key after reboot - NimBLE looses pairing key after reboot
- Pick different font to improve monochrome screen readability (or change the weight). - Pick different font to improve monochrome screen readability (or change the weight).
- Drivers/audio-codec-module is not a module. Move it somewhere else. Or make it an actual module. - Drivers/audio-codec-module is not a module. Move it somewhere else. Or make it an actual module.
@ -31,17 +30,14 @@
- netutils-module: ping, wget, ifconfig, nslookup, etc. - netutils-module: ping, wget, ifconfig, nslookup, etc.
- Core2: support power off via software - Core2: support power off via software
- Improve Setup: Show "Step done" screen - Improve Setup: Show "Step done" screen
- Make it more clear to end-users that an SD card is required to run Tactility - Make it clearer to end-users that an SD card is required to run Tactility
- Warn if file operations are done from prohibited tasks (e.g. lvgl task) - Warn if file operations are done from prohibited tasks (e.g. lvgl task)
- stopAppFromToolbar() in lvgl/Lvgl.cpp stops the top-most app. Change it so the toolbar knows for which app id it is created, so it can rely on that. - stopAppFromToolbar() in lvgl/Lvgl.cpp stops the top-most app. Change it so the toolbar knows for which app id it is created, so it can rely on that.
- Consider not unpacking `.app` files and executing them directly. Might want to cache file offsets. Cache file must be pinned to app version.
- lvgl-module's spinner relies on hard-coded spinner asset from Tactility main project. - lvgl-module's spinner relies on hard-coded spinner asset from Tactility main project.
- esp_lvgl_port settings has a large stack size (~9kB) to fix stackoverflow when LVGL events (e.g. button click) do actions like file operations do actions like file operations. Can we reduce the callstack?
- `struct Driver` has an `.owner`, but it's not always set. Either validate on Module construct that it matches, or otherwise set it during module start. The problem: NULL parent currently means that driver is not removable. This clashes with setting it dynamically. Consider some kind of flag to determine removability. - `struct Driver` has an `.owner`, but it's not always set. Either validate on Module construct that it matches, or otherwise set it during module start. The problem: NULL parent currently means that driver is not removable. This clashes with setting it dynamically. Consider some kind of flag to determine removability.
- Consider moving certain drivers into separate modules: audio, bt, wifi, etc - Consider moving certain drivers into separate modules: audio, bt, wifi, etc
- Consider using https://github.com/Graphify-Labs/graphify - Consider using https://github.com/Graphify-Labs/graphify
- Consider implementing LVGL gridnav in apps https://lvgl.io/docs/open/9.3/details/auxiliary-modules/gridnav.html - Consider implementing LVGL gridnav in apps https://lvgl.io/docs/open/9.3/details/auxiliary-modules/gridnav.html
- TactilityTool: Make API compatibility table (and check for compatibility in the tool itself)
- Refactor HttpServer into C code and move implementation to http-module - Refactor HttpServer into C code and move implementation to http-module
- Use GPS time to set/update the current time - Use GPS time to set/update the current time
- Make a URL handler. Use it for handling local files. Match file types with apps. - Make a URL handler. Use it for handling local files. Match file types with apps.
@ -69,7 +65,6 @@
- Show a warning screen if firmware encryption or secure boot are off when saving WiFi credentials. - Show a warning screen if firmware encryption or secure boot are off when saving WiFi credentials.
- Calculator app should show regular text input field on non-touch devices that have a keyboard (Cardputer, T-Lora Pager) - Calculator app should show regular text input field on non-touch devices that have a keyboard (Cardputer, T-Lora Pager)
- Allow for WSAD keys to navigate LVGL (this is extra nice for cardputer, but just handy in general) - Allow for WSAD keys to navigate LVGL (this is extra nice for cardputer, but just handy in general)
- Create a "How to" app for a device. It could explain things like keyboard navigation on first start.
- Make WiFi setup app that starts an access point and hosts a webpage to set up the device. - Make WiFi setup app that starts an access point and hosts a webpage to set up the device.
This will be useful for devices without a screen, a small screen or a non-touch screen. This will be useful for devices without a screen, a small screen or a non-touch screen.

View File

@ -256,6 +256,7 @@ static uint32_t scan_key(CardputerKeyboardInternal* internal) {
if (has_regular) { if (has_regular) {
switch (resolved_char) { switch (resolved_char) {
case '`': return CODEPOINT_ESCAPE; case '`': return CODEPOINT_ESCAPE;
case '\n': return CODEPOINT_ENTER;
case ',': return CODEPOINT_ARROW_LEFT; case ',': return CODEPOINT_ARROW_LEFT;
case '/': return CODEPOINT_ARROW_RIGHT; case '/': return CODEPOINT_ARROW_RIGHT;
case ';': return CODEPOINT_ARROW_UP; case ';': return CODEPOINT_ARROW_UP;

View File

@ -42,8 +42,8 @@ struct LcdInitCmd {
uint8_t len; uint8_t len;
}; };
// No MADCTL (0x36): esp_lcd_panel_init() already sets it from rgb_ele_order, and esp_lcd's own swap_xy/mirror rewrite it from that value
constexpr LcdInitCmd ST7789_INIT_CMDS[] = { constexpr LcdInitCmd ST7789_INIT_CMDS[] = {
{0x36, {0x08}, 1},
{0x3A, {0x05}, 1}, {0x3A, {0x05}, 1},
{0x20, {0}, 0}, {0x20, {0}, 0},
{0xB2, {0x0B, 0x0B, 0x00, 0x33, 0x33}, 5}, {0xB2, {0x0B, 0x0B, 0x00, 0x33, 0x33}, 5},

View File

@ -177,6 +177,11 @@ shared_symbol_code_point_names = [
"usb", # Power (settings) app "usb", # Power (settings) app
"wifi", # WiFi (settings) app "wifi", # WiFi (settings) app
"bluetooth", # Bluetooth (settings) app "bluetooth", # Bluetooth (settings) app
# System menu toggles
"wifi_off",
"bluetooth_disabled",
"location_on",
"location_off",
"terminal", "terminal",
"logo_dev", "logo_dev",
"camera", "camera",

View File

@ -48,7 +48,7 @@ void lvgl_keyboard_remove(lv_indev_t* indev);
lv_indev_t* lvgl_keyboard_find_by_device(struct Device* device); lv_indev_t* lvgl_keyboard_find_by_device(struct Device* device);
/** /**
* @brief Assigns the indev to the shared keyboard input group, so it can drive focus * @brief Assigns the indev to the active keyboard input group, so it can drive focus
* navigation and input for focused widgets. * navigation and input for focused widgets.
* @warning Caller must hold the LVGL lock. * @warning Caller must hold the LVGL lock.
*/ */
@ -60,6 +60,20 @@ void lvgl_keyboard_enable(lv_indev_t* indev);
*/ */
void lvgl_keyboard_disable(lv_indev_t* indev); void lvgl_keyboard_disable(lv_indev_t* indev);
/**
* @brief Makes @a group the group that receives keypad input: it becomes the default group (new
* widgets join it), and every keypad/encoder indev that is attached to a group is moved to it.
* Indevs enabled later are attached to it too.
* @warning Caller must hold the LVGL lock.
* @param[in] group the group to activate, or NULL to activate the shared keyboard input group
*/
void lvgl_keyboard_set_active_group(lv_group_t* group);
/**
* @return the shared keyboard input group, which is the default when no other group is active
*/
lv_group_t* lvgl_keyboard_get_app_group(void);
/** /**
* @brief Adds the textarea to the shared keyboard navigation group (so any keypad indev - * @brief Adds the textarea to the shared keyboard navigation group (so any keypad indev -
* hardware or on-screen - can type into it once it's focused), and, only when * hardware or on-screen - can type into it once it's focused), and, only when
@ -136,7 +150,7 @@ bool lvgl_software_keyboard_is_enabled();
struct LvglSoftwareKeyboard* lvgl_software_keyboard_get_last(); struct LvglSoftwareKeyboard* lvgl_software_keyboard_get_last();
/** /**
* @brief Attaches the shared keyboard navigation group to every currently registered keypad * @brief Attaches the active keyboard navigation group to every currently registered keypad
* indev, so they can be used to navigate the on-screen keyboard and focused widgets. * indev, so they can be used to navigate the on-screen keyboard and focused widgets.
* @warning Caller must hold the LVGL lock. * @warning Caller must hold the LVGL lock.
*/ */

View File

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

View File

@ -50,6 +50,10 @@
#define LVGL_ICON_SHARED_USB "\xEE\x87\xA0" #define LVGL_ICON_SHARED_USB "\xEE\x87\xA0"
#define LVGL_ICON_SHARED_WIFI "\xEE\x98\xBE" #define LVGL_ICON_SHARED_WIFI "\xEE\x98\xBE"
#define LVGL_ICON_SHARED_BLUETOOTH "\xEE\x86\xA7" #define LVGL_ICON_SHARED_BLUETOOTH "\xEE\x86\xA7"
#define LVGL_ICON_SHARED_WIFI_OFF "\xEE\x99\x88"
#define LVGL_ICON_SHARED_BLUETOOTH_DISABLED "\xEE\x86\xA9"
#define LVGL_ICON_SHARED_LOCATION_ON "\xEF\x87\x9B"
#define LVGL_ICON_SHARED_LOCATION_OFF "\xEE\x83\x87"
#define LVGL_ICON_SHARED_TERMINAL "\xEE\xAE\x8E" #define LVGL_ICON_SHARED_TERMINAL "\xEE\xAE\x8E"
#define LVGL_ICON_SHARED_LOGO_DEV "\xEE\xAB\x96" #define LVGL_ICON_SHARED_LOGO_DEV "\xEE\xAB\x96"
#define LVGL_ICON_SHARED_CAMERA "\xEE\x8E\xAF" #define LVGL_ICON_SHARED_CAMERA "\xEE\x8E\xAF"

View File

@ -0,0 +1,20 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include <lvgl.h>
#ifdef __cplusplus
extern "C" {
#endif
/**
* @brief Creates a focus container: a transparent container without padding that the theme only styles with a focus ring.
* Use it to make a group of widgets selectable as one, e.g. with a click handler on the container.
* @param[in] parent the parent object for the new container
* @return the created container
*/
lv_obj_t* lvgl_focus_container_create(lv_obj_t* parent);
#ifdef __cplusplus
}
#endif

@ -0,0 +1 @@
Subproject commit e9c58a2bb506c0e1ef4ef4bc8c91e996be036163

View File

@ -17,6 +17,8 @@ static LvglSoftwareKeyboard last_software_keyboard = {
}; };
static lv_group_t* keyboard_group; static lv_group_t* keyboard_group;
// The group that keypad indevs are attached to: keyboard_group, or the group of an overlay
static lv_group_t* active_group;
extern "C" { extern "C" {
@ -28,6 +30,7 @@ void lvgl_keyboard_on_start_lvgl() {
// but gets all widgets by default. This is a temporary work-around until a proper default group is // but gets all widgets by default. This is a temporary work-around until a proper default group is
// created to fix the trackball issue (see trackball.cpp and ideas.md, search for "group") // created to fix the trackball issue (see trackball.cpp and ideas.md, search for "group")
lv_group_set_default(keyboard_group); lv_group_set_default(keyboard_group);
active_group = keyboard_group;
lvgl_unlock(); lvgl_unlock();
} }
@ -41,6 +44,24 @@ void lvgl_keyboard_on_stop_lvgl() {
lvgl_unlock(); lvgl_unlock();
keyboard_group = nullptr; keyboard_group = nullptr;
active_group = nullptr;
}
void lvgl_keyboard_set_active_group(lv_group_t* group) {
active_group = group != nullptr ? group : keyboard_group;
lv_group_set_default(active_group);
auto* indev = lv_indev_get_next(nullptr);
while (indev != nullptr) {
const lv_indev_type_t type = lv_indev_get_type(indev);
if ((type == LV_INDEV_TYPE_KEYPAD || type == LV_INDEV_TYPE_ENCODER) && lv_indev_get_group(indev) != nullptr) {
lv_indev_set_group(indev, active_group);
}
indev = lv_indev_get_next(indev);
}
}
lv_group_t* lvgl_keyboard_get_app_group() {
return keyboard_group;
} }
// KeyboardKeyData::key is always a Unicode codepoint (see its doc comment / the CodePoint enum) - // KeyboardKeyData::key is always a Unicode codepoint (see its doc comment / the CodePoint enum) -
@ -176,8 +197,8 @@ lv_indev_t* lvgl_keyboard_find_by_device(Device* device) {
} }
void lvgl_keyboard_enable(lv_indev_t* indev) { void lvgl_keyboard_enable(lv_indev_t* indev) {
check(keyboard_group != nullptr); check(active_group != nullptr);
lv_indev_set_group(indev, keyboard_group); lv_indev_set_group(indev, active_group);
} }
void lvgl_keyboard_disable(lv_indev_t* indev) { void lvgl_keyboard_disable(lv_indev_t* indev) {
@ -295,10 +316,10 @@ void lvgl_keyboard_add_textarea(LvglSoftwareKeyboard* keyboard, lv_obj_t* textar
void lvgl_software_keyboard_activate(LvglSoftwareKeyboard* keyboard) { void lvgl_software_keyboard_activate(LvglSoftwareKeyboard* keyboard) {
auto* indev = lv_indev_get_next(nullptr); auto* indev = lv_indev_get_next(nullptr);
check(keyboard_group); check(active_group);
while (indev) { while (indev) {
if (lv_indev_get_type(indev) == LV_INDEV_TYPE_KEYPAD) { if (lv_indev_get_type(indev) == LV_INDEV_TYPE_KEYPAD) {
lv_indev_set_group(indev, keyboard_group); lv_indev_set_group(indev, active_group);
} }
indev = lv_indev_get_next(indev); indev = lv_indev_get_next(indev);
} }

View File

@ -64,7 +64,7 @@ static lv_obj_t* find_edge_row_child(lv_obj_t* container, bool top, lv_obj_t* re
if (!found) { if (!found) {
return nullptr; return nullptr;
} }
if (reference != nullptr && get_center_y(reference) == edge_y) { if (reference != nullptr && is_focusable(reference) && get_center_y(reference) == edge_y) {
return reference; return reference;
} }
const int32_t reference_x = reference != nullptr ? get_center_x(reference) : 0; const int32_t reference_x = reference != nullptr ? get_center_x(reference) : 0;
@ -95,6 +95,34 @@ static void on_container_key(lv_event_t* event) {
scroll_to_focused_child(lv_event_get_current_target_obj(event)); scroll_to_focused_child(lv_event_get_current_target_obj(event));
} }
static bool has_focusable_child(lv_obj_t* container) {
const uint32_t child_count = lv_obj_get_child_count(container);
for (uint32_t i = 0; i < child_count; i++) {
if (is_focusable(lv_obj_get_child(container, static_cast<int32_t>(i)))) {
return true;
}
}
return false;
}
// Grid navigation ignores the arrow keys when the container has nothing to focus (e.g. only an "empty" label),
// so they move the focus to the previous or next widget instead
static void on_container_key_preprocess(lv_event_t* event) {
lv_obj_t* container = lv_event_get_current_target_obj(event);
lv_group_t* group = lv_obj_get_group(container);
if (group == nullptr || has_focusable_child(container)) {
return;
}
const uint32_t key = lv_event_get_key(event);
if (key == LV_KEY_DOWN || key == LV_KEY_RIGHT) {
lv_group_focus_next(group);
lv_event_stop_processing(event);
} else if (key == LV_KEY_UP || key == LV_KEY_LEFT) {
lv_group_focus_prev(group);
lv_event_stop_processing(event);
}
}
// Grid navigation shows the focused child as selected whenever the container gets the focus. That's only // Grid navigation shows the focused child as selected whenever the container gets the focus. That's only
// right when a key moved the focus there, not when it moved because e.g. the previously focused widget was deleted. // right when a key moved the focus there, not when it moved because e.g. the previously focused widget was deleted.
static void on_container_focused(lv_event_t* event) { static void on_container_focused(lv_event_t* event) {
@ -114,6 +142,10 @@ static void on_container_focused(lv_event_t* event) {
} else if (key == LV_KEY_PREV || key == LV_KEY_LEFT) { } 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); child = find_focusable_child(container, static_cast<int32_t>(lv_obj_get_child_count(container)), false);
} }
// The restored child can be hidden by now, e.g. a page button when there's only one page
if (child == nullptr && focused != nullptr && !is_focusable(focused)) {
child = find_focusable_child(container, -1, true);
}
if (child != nullptr && child != focused) { if (child != nullptr && child != focused) {
lv_gridnav_set_focused(container, child, LV_ANIM_ON); lv_gridnav_set_focused(container, child, LV_ANIM_ON);
} }
@ -135,6 +167,7 @@ void lvgl_grid_navigation_add(lv_obj_t* container) {
// Added after grid navigation's own handler, so it runs after it // Added after grid navigation's own handler, so it runs after it
lv_obj_add_event_cb(container, on_container_focused, LV_EVENT_FOCUSED, nullptr); lv_obj_add_event_cb(container, on_container_focused, LV_EVENT_FOCUSED, nullptr);
lv_obj_add_event_cb(container, on_container_key, LV_EVENT_KEY, nullptr); lv_obj_add_event_cb(container, on_container_key, LV_EVENT_KEY, nullptr);
lv_obj_add_event_cb(container, on_container_key_preprocess, static_cast<lv_event_code_t>(LV_EVENT_KEY | LV_EVENT_PREPROCESS), nullptr);
const uint32_t child_count = lv_obj_get_child_count(container); const uint32_t child_count = lv_obj_get_child_count(container);
for (uint32_t i = 0; i < child_count; i++) { for (uint32_t i = 0; i < child_count; i++) {
@ -160,6 +193,7 @@ void lvgl_grid_navigation_remove(lv_obj_t* container) {
lv_obj_remove_event_cb(container, on_child_created); lv_obj_remove_event_cb(container, on_child_created);
lv_obj_remove_event_cb(container, on_container_focused); lv_obj_remove_event_cb(container, on_container_focused);
lv_obj_remove_event_cb(container, on_container_key); lv_obj_remove_event_cb(container, on_container_key);
lv_obj_remove_event_cb(container, on_container_key_preprocess);
if (group == nullptr) { if (group == nullptr) {
return; return;

View File

@ -52,6 +52,10 @@ const char* const lvgl_icon_shared_names[] = {
"usb", "usb",
"wifi", "wifi",
"bluetooth", "bluetooth",
"wifi_off",
"bluetooth_disabled",
"location_on",
"location_off",
"terminal", "terminal",
"logo_dev", "logo_dev",
"camera", "camera",

View File

@ -8,14 +8,17 @@
#include <lvgl/devices/indev.h> #include <lvgl/devices/indev.h>
#include <lvgl/devices/keyboard.h> #include <lvgl/devices/keyboard.h>
#include <lvgl/devices/pointer.h> #include <lvgl/devices/pointer.h>
#include <lvgl/devices/trackball.h>
#include <lvgl/widgets/sliderbox.h> #include <lvgl/widgets/sliderbox.h>
#include <lvgl/grid_navigation.h> #include <lvgl/grid_navigation.h>
#include <lvgl/insets.h> #include <lvgl/insets.h>
#include <lvgl/ppa.h>
#include <lvgl/theme.h> #include <lvgl/theme.h>
#include <lvgl/widgets/badge.h> #include <lvgl/widgets/badge.h>
#include <lvgl/widgets/card.h> #include <lvgl/widgets/card.h>
#include <lvgl/widgets/chip.h> #include <lvgl/widgets/chip.h>
#include <lvgl/widgets/focus_container.h>
#include <lvgl/widgets/icon_button.h> #include <lvgl/widgets/icon_button.h>
#include <lvgl/widgets/list.h> #include <lvgl/widgets/list.h>
#include <lvgl/widgets/page_indicator.h> #include <lvgl/widgets/page_indicator.h>
@ -52,6 +55,8 @@ const struct ModuleSymbol lvgl_module_symbols[] = {
DEFINE_MODULE_SYMBOL(lvgl_get_launcher_icon_font_height), DEFINE_MODULE_SYMBOL(lvgl_get_launcher_icon_font_height),
DEFINE_MODULE_SYMBOL(lvgl_get_statusbar_icon_font), DEFINE_MODULE_SYMBOL(lvgl_get_statusbar_icon_font),
DEFINE_MODULE_SYMBOL(lvgl_get_statusbar_icon_font_height), DEFINE_MODULE_SYMBOL(lvgl_get_statusbar_icon_font_height),
DEFINE_MODULE_SYMBOL(lvgl_set_text_font),
DEFINE_MODULE_SYMBOL(lvgl_set_icon_font),
// lvgl_display // lvgl_display
DEFINE_MODULE_SYMBOL(lvgl_display_add), DEFINE_MODULE_SYMBOL(lvgl_display_add),
DEFINE_MODULE_SYMBOL(lvgl_display_remove), DEFINE_MODULE_SYMBOL(lvgl_display_remove),
@ -60,6 +65,23 @@ const struct ModuleSymbol lvgl_module_symbols[] = {
// lvgl_keyboard // lvgl_keyboard
DEFINE_MODULE_SYMBOL(lvgl_keyboard_add), DEFINE_MODULE_SYMBOL(lvgl_keyboard_add),
DEFINE_MODULE_SYMBOL(lvgl_keyboard_remove), DEFINE_MODULE_SYMBOL(lvgl_keyboard_remove),
DEFINE_MODULE_SYMBOL(lvgl_keyboard_find_by_device),
DEFINE_MODULE_SYMBOL(lvgl_keyboard_enable),
DEFINE_MODULE_SYMBOL(lvgl_keyboard_disable),
DEFINE_MODULE_SYMBOL(lvgl_keyboard_set_active_group),
DEFINE_MODULE_SYMBOL(lvgl_keyboard_get_app_group),
DEFINE_MODULE_SYMBOL(lvgl_keyboard_add_textarea),
DEFINE_MODULE_SYMBOL(lvgl_hardware_keyboard_is_available),
DEFINE_MODULE_SYMBOL(lvgl_hardware_keyboard_add_custom),
DEFINE_MODULE_SYMBOL(lvgl_hardware_keyboard_remove_custom),
DEFINE_MODULE_SYMBOL(lvgl_software_keyboard_construct),
DEFINE_MODULE_SYMBOL(lvgl_software_keyboard_destruct),
DEFINE_MODULE_SYMBOL(lvgl_software_keyboard_show),
DEFINE_MODULE_SYMBOL(lvgl_software_keyboard_hide),
DEFINE_MODULE_SYMBOL(lvgl_software_keyboard_is_enabled),
DEFINE_MODULE_SYMBOL(lvgl_software_keyboard_get_last),
DEFINE_MODULE_SYMBOL(lvgl_software_keyboard_activate),
DEFINE_MODULE_SYMBOL(lvgl_software_keyboard_deactivate),
// lvgl_pointer // lvgl_pointer
DEFINE_MODULE_SYMBOL(lvgl_pointer_set_calibration), DEFINE_MODULE_SYMBOL(lvgl_pointer_set_calibration),
DEFINE_MODULE_SYMBOL(lvgl_pointer_get_calibration), DEFINE_MODULE_SYMBOL(lvgl_pointer_get_calibration),
@ -67,6 +89,19 @@ const struct ModuleSymbol lvgl_module_symbols[] = {
DEFINE_MODULE_SYMBOL(lvgl_pointer_add), DEFINE_MODULE_SYMBOL(lvgl_pointer_add),
DEFINE_MODULE_SYMBOL(lvgl_pointer_get_slot_index), DEFINE_MODULE_SYMBOL(lvgl_pointer_get_slot_index),
DEFINE_MODULE_SYMBOL(lvgl_pointer_remove), DEFINE_MODULE_SYMBOL(lvgl_pointer_remove),
// lvgl_trackball
DEFINE_MODULE_SYMBOL(lvgl_trackball_add),
DEFINE_MODULE_SYMBOL(lvgl_trackball_remove),
DEFINE_MODULE_SYMBOL(lvgl_trackball_get_settings),
DEFINE_MODULE_SYMBOL(lvgl_trackball_set_settings),
DEFINE_MODULE_SYMBOL(lvgl_trackball_settings_get_default),
DEFINE_MODULE_SYMBOL(lvgl_trackball_set_cursor_image),
// lvgl_ppa
DEFINE_MODULE_SYMBOL(lvgl_ppa_is_supported),
DEFINE_MODULE_SYMBOL(lvgl_ppa_supports_color_format),
DEFINE_MODULE_SYMBOL(lvgl_ppa_get_or_create),
DEFINE_MODULE_SYMBOL(lvgl_ppa_delete),
DEFINE_MODULE_SYMBOL(lvgl_ppa_rotate),
// lvgl_grid_navigation // lvgl_grid_navigation
DEFINE_MODULE_SYMBOL(lvgl_grid_navigation_add), DEFINE_MODULE_SYMBOL(lvgl_grid_navigation_add),
DEFINE_MODULE_SYMBOL(lvgl_grid_navigation_remove), DEFINE_MODULE_SYMBOL(lvgl_grid_navigation_remove),
@ -87,6 +122,9 @@ const struct ModuleSymbol lvgl_module_symbols[] = {
DEFINE_MODULE_SYMBOL(lvgl_theme_is_compact), DEFINE_MODULE_SYMBOL(lvgl_theme_is_compact),
// lvgl_badge // lvgl_badge
DEFINE_MODULE_SYMBOL(lvgl_badge_create), DEFINE_MODULE_SYMBOL(lvgl_badge_create),
// lvgl_focus_container
DEFINE_MODULE_SYMBOL(lvgl_focus_container_create),
// lvgl_page_indicator
DEFINE_MODULE_SYMBOL(lvgl_page_indicator_create), DEFINE_MODULE_SYMBOL(lvgl_page_indicator_create),
DEFINE_MODULE_SYMBOL(lvgl_page_indicator_set_page_count), DEFINE_MODULE_SYMBOL(lvgl_page_indicator_set_page_count),
DEFINE_MODULE_SYMBOL(lvgl_page_indicator_set_page), DEFINE_MODULE_SYMBOL(lvgl_page_indicator_set_page),

View File

@ -14,6 +14,7 @@ extern const lv_obj_class_t lvgl_icon_button_filled_class;
extern const lv_obj_class_t lvgl_icon_button_tonal_class; extern const lv_obj_class_t lvgl_icon_button_tonal_class;
extern const lv_obj_class_t lvgl_sliderbox_class; extern const lv_obj_class_t lvgl_sliderbox_class;
extern const lv_obj_class_t lvgl_card_class; extern const lv_obj_class_t lvgl_card_class;
extern const lv_obj_class_t lvgl_focus_container_class;
extern const lv_obj_class_t lvgl_chip_class; extern const lv_obj_class_t lvgl_chip_class;
extern const lv_obj_class_t lvgl_badge_class; extern const lv_obj_class_t lvgl_badge_class;
extern const lv_obj_class_t lvgl_page_indicator_class; extern const lv_obj_class_t lvgl_page_indicator_class;
@ -49,7 +50,7 @@ extern const lv_obj_class_t lvgl_list_button_class;
#define CORNER_LARGE (LV_DPX_CALC(theme->disp_dpi, 16)) #define CORNER_LARGE (LV_DPX_CALC(theme->disp_dpi, 16))
#define CORNER_EXTRA_LARGE (LV_DPX_CALC(theme->disp_dpi, 28)) #define CORNER_EXTRA_LARGE (LV_DPX_CALC(theme->disp_dpi, 28))
#define CORNER_FULL LV_RADIUS_CIRCLE #define CORNER_FULL LV_RADIUS_CIRCLE
#define CORNER_COMPACT 3 #define CORNER_COMPACT 4
#define SPACE_1 (LV_DPX_CALC(theme->disp_dpi, 4)) #define SPACE_1 (LV_DPX_CALC(theme->disp_dpi, 4))
#define SPACE_2 (LV_DPX_CALC(theme->disp_dpi, 8)) #define SPACE_2 (LV_DPX_CALC(theme->disp_dpi, 8))
#define SPACE_3 (LV_DPX_CALC(theme->disp_dpi, 12)) #define SPACE_3 (LV_DPX_CALC(theme->disp_dpi, 12))
@ -184,6 +185,7 @@ extern const lv_obj_class_t lvgl_list_button_class;
#define APP_BAR_CONTENT_COLOR COLOR_ON_SURFACE #define APP_BAR_CONTENT_COLOR COLOR_ON_SURFACE
#define APP_BAR_PRESSED_OPACITY (LV_OPA_20) #define APP_BAR_PRESSED_OPACITY (LV_OPA_20)
#define APP_BAR_PAD_HOR (theme->config.is_compact ? 0 : SPACE_1) #define APP_BAR_PAD_HOR (theme->config.is_compact ? 0 : SPACE_1)
#define APP_BAR_PAD_RIGHT (theme->config.is_compact ? APP_BAR_PAD_HOR + 2 : APP_BAR_PAD_HOR)
#define APP_BAR_GAP (theme->config.is_compact ? 2 : SPACE_1) #define APP_BAR_GAP (theme->config.is_compact ? 2 : SPACE_1)
#define APP_BAR_FOCUS_WIDTH FOCUS_INSET_WIDTH #define APP_BAR_FOCUS_WIDTH FOCUS_INSET_WIDTH
#define FOCUS_RING_COLOR FOCUS_COLOR #define FOCUS_RING_COLOR FOCUS_COLOR
@ -383,6 +385,8 @@ typedef struct {
lv_style_t icon_button_tonal; lv_style_t icon_button_tonal;
lv_style_t icon_button_tonal_pressed; lv_style_t icon_button_tonal_pressed;
lv_style_t app_bar; lv_style_t app_bar;
lv_style_t app_bar_nav_button;
lv_style_t app_bar_button_focus_ring;
lv_style_t app_bar_button; lv_style_t app_bar_button;
lv_style_t app_bar_button_pressed; lv_style_t app_bar_button_pressed;
lv_style_t app_bar_focus_ring; lv_style_t app_bar_focus_ring;
@ -907,9 +911,20 @@ static void style_init(my_theme_t * theme)
lv_style_set_radius(&theme->styles.app_bar, 0); lv_style_set_radius(&theme->styles.app_bar, 0);
lv_style_set_border_width(&theme->styles.app_bar, 0); 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_hor(&theme->styles.app_bar, APP_BAR_PAD_HOR);
lv_style_set_pad_right(&theme->styles.app_bar, APP_BAR_PAD_RIGHT);
lv_style_set_pad_ver(&theme->styles.app_bar, 0); lv_style_set_pad_ver(&theme->styles.app_bar, 0);
lv_style_set_pad_gap(&theme->styles.app_bar, APP_BAR_GAP); lv_style_set_pad_gap(&theme->styles.app_bar, APP_BAR_GAP);
style_init_reset(&theme->styles.app_bar_nav_button);
if(theme->config.is_compact) {
lv_style_set_margin_left(&theme->styles.app_bar_nav_button, 4);
}
style_init_reset(&theme->styles.app_bar_button_focus_ring);
if(theme->config.is_compact) {
lv_style_set_outline_pad(&theme->styles.app_bar_button_focus_ring, 0);
}
style_init_reset(&theme->styles.app_bar_button); style_init_reset(&theme->styles.app_bar_button);
lv_style_set_pad_all(&theme->styles.app_bar_button, 0); lv_style_set_pad_all(&theme->styles.app_bar_button, 0);
lv_style_set_text_color(&theme->styles.app_bar_button, APP_BAR_CONTENT_COLOR); lv_style_set_text_color(&theme->styles.app_bar_button, APP_BAR_CONTENT_COLOR);
@ -1723,6 +1738,21 @@ static void theme_apply(lv_theme_t * th, lv_obj_t * obj)
} }
#endif #endif
if(lv_obj_check_type(obj, &lvgl_icon_button_class)) { if(lv_obj_check_type(obj, &lvgl_icon_button_class)) {
/* toolbar > icon_button[0] */
if(lv_obj_check_type(parent, &lvgl_toolbar_class) && lss_is_child(parent, 0, obj)) {
lv_obj_add_style(obj, &theme->styles.icon_button, 0);
lv_obj_add_style(obj, &theme->styles.app_bar_button, 0);
lv_obj_add_style(obj, &theme->styles.app_bar_nav_button, 0);
lv_obj_add_style(obj, &theme->styles.transition_exit, 0);
lv_obj_add_style(obj, &theme->styles.app_bar_button_pressed, LV_STATE_PRESSED);
lv_obj_add_style(obj, &theme->styles.transition_enter, LV_STATE_PRESSED);
lv_obj_add_style(obj, &theme->styles.app_bar_focus_ring, LV_STATE_FOCUS_KEY);
lv_obj_add_style(obj, &theme->styles.app_bar_focus_ring, LV_STATE_EDITED);
lv_obj_add_style(obj, &theme->styles.app_bar_button_focus_ring, LV_STATE_FOCUS_KEY);
lv_obj_add_style(obj, &theme->styles.app_bar_button_focus_ring, LV_STATE_EDITED);
lv_obj_add_style(obj, &theme->styles.disabled, LV_STATE_DISABLED);
return;
}
/* toolbar > icon_button */ /* toolbar > icon_button */
if(lv_obj_check_type(parent, &lvgl_toolbar_class)) { if(lv_obj_check_type(parent, &lvgl_toolbar_class)) {
lv_obj_add_style(obj, &theme->styles.icon_button, 0); lv_obj_add_style(obj, &theme->styles.icon_button, 0);
@ -1732,6 +1762,8 @@ static void theme_apply(lv_theme_t * th, lv_obj_t * obj)
lv_obj_add_style(obj, &theme->styles.transition_enter, LV_STATE_PRESSED); lv_obj_add_style(obj, &theme->styles.transition_enter, LV_STATE_PRESSED);
lv_obj_add_style(obj, &theme->styles.app_bar_focus_ring, LV_STATE_FOCUS_KEY); lv_obj_add_style(obj, &theme->styles.app_bar_focus_ring, LV_STATE_FOCUS_KEY);
lv_obj_add_style(obj, &theme->styles.app_bar_focus_ring, LV_STATE_EDITED); lv_obj_add_style(obj, &theme->styles.app_bar_focus_ring, LV_STATE_EDITED);
lv_obj_add_style(obj, &theme->styles.app_bar_button_focus_ring, LV_STATE_FOCUS_KEY);
lv_obj_add_style(obj, &theme->styles.app_bar_button_focus_ring, LV_STATE_EDITED);
lv_obj_add_style(obj, &theme->styles.disabled, LV_STATE_DISABLED); lv_obj_add_style(obj, &theme->styles.disabled, LV_STATE_DISABLED);
return; return;
} }
@ -1925,6 +1957,11 @@ static void theme_apply(lv_theme_t * th, lv_obj_t * obj)
lv_obj_add_style(obj, &theme->styles.scrollbar, LV_PART_SCROLLBAR); lv_obj_add_style(obj, &theme->styles.scrollbar, LV_PART_SCROLLBAR);
return; return;
} }
if(lv_obj_check_type(obj, &lvgl_focus_container_class)) {
/* focus_container */
lv_obj_add_style(obj, &theme->styles.focus_ring, LV_STATE_FOCUS_KEY);
return;
}
if(lv_obj_check_type(obj, &lvgl_sliderbox_class)) { if(lv_obj_check_type(obj, &lvgl_sliderbox_class)) {
/* sliderbox */ /* sliderbox */
lv_obj_add_style(obj, &theme->styles.sliderbox, 0); lv_obj_add_style(obj, &theme->styles.sliderbox, 0);

View File

@ -14,6 +14,7 @@ extern const lv_obj_class_t lvgl_icon_button_filled_class;
extern const lv_obj_class_t lvgl_icon_button_tonal_class; extern const lv_obj_class_t lvgl_icon_button_tonal_class;
extern const lv_obj_class_t lvgl_sliderbox_class; extern const lv_obj_class_t lvgl_sliderbox_class;
extern const lv_obj_class_t lvgl_card_class; extern const lv_obj_class_t lvgl_card_class;
extern const lv_obj_class_t lvgl_focus_container_class;
extern const lv_obj_class_t lvgl_chip_class; extern const lv_obj_class_t lvgl_chip_class;
extern const lv_obj_class_t lvgl_badge_class; extern const lv_obj_class_t lvgl_badge_class;
extern const lv_obj_class_t lvgl_page_indicator_class; extern const lv_obj_class_t lvgl_page_indicator_class;
@ -29,7 +30,7 @@ extern const lv_obj_class_t lvgl_list_button_class;
#define CORNER_MEDIUM (LV_DPX_CALC(theme->disp_dpi, 8)) #define CORNER_MEDIUM (LV_DPX_CALC(theme->disp_dpi, 8))
#define CORNER_LARGE (LV_DPX_CALC(theme->disp_dpi, 12)) #define CORNER_LARGE (LV_DPX_CALC(theme->disp_dpi, 12))
#define CORNER_FULL LV_RADIUS_CIRCLE #define CORNER_FULL LV_RADIUS_CIRCLE
#define CORNER_COMPACT 2 #define CORNER_COMPACT 4
#define SPACE_1 (LV_DPX_CALC(theme->disp_dpi, 2)) #define SPACE_1 (LV_DPX_CALC(theme->disp_dpi, 2))
#define SPACE_2 (LV_DPX_CALC(theme->disp_dpi, 4)) #define SPACE_2 (LV_DPX_CALC(theme->disp_dpi, 4))
#define SPACE_3 (LV_DPX_CALC(theme->disp_dpi, 6)) #define SPACE_3 (LV_DPX_CALC(theme->disp_dpi, 6))
@ -118,6 +119,7 @@ extern const lv_obj_class_t lvgl_list_button_class;
#define ICON_BUTTON_TONAL_BORDER_COLOR COLOR_OUTLINE #define ICON_BUTTON_TONAL_BORDER_COLOR COLOR_OUTLINE
#define APP_BAR_TITLE_COLOR COLOR_ON_SURFACE #define APP_BAR_TITLE_COLOR COLOR_ON_SURFACE
#define APP_BAR_PAD_HOR (theme->config.is_compact ? 0 : SPACE_1) #define APP_BAR_PAD_HOR (theme->config.is_compact ? 0 : SPACE_1)
#define APP_BAR_PAD_RIGHT (theme->config.is_compact ? APP_BAR_PAD_HOR + 2 : APP_BAR_PAD_HOR)
#define APP_BAR_GAP (theme->config.is_compact ? 2 : SPACE_1) #define APP_BAR_GAP (theme->config.is_compact ? 2 : SPACE_1)
#define APP_BAR_FOCUS_WIDTH FOCUS_INSET_WIDTH #define APP_BAR_FOCUS_WIDTH FOCUS_INSET_WIDTH
#define FOCUS_RING_COLOR COLOR_ON_SURFACE #define FOCUS_RING_COLOR COLOR_ON_SURFACE
@ -240,6 +242,8 @@ typedef struct {
lv_style_t icon_button_filled_pressed; lv_style_t icon_button_filled_pressed;
lv_style_t icon_button_tonal; lv_style_t icon_button_tonal;
lv_style_t app_bar; lv_style_t app_bar;
lv_style_t app_bar_nav_button;
lv_style_t app_bar_button_focus_ring;
lv_style_t app_bar_button; lv_style_t app_bar_button;
lv_style_t app_bar_focus_ring; lv_style_t app_bar_focus_ring;
#if LV_USE_SWITCH #if LV_USE_SWITCH
@ -644,9 +648,20 @@ static void style_init(my_theme_t * theme)
lv_style_set_radius(&theme->styles.app_bar, 0); lv_style_set_radius(&theme->styles.app_bar, 0);
lv_style_set_border_width(&theme->styles.app_bar, 0); 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_hor(&theme->styles.app_bar, APP_BAR_PAD_HOR);
lv_style_set_pad_right(&theme->styles.app_bar, APP_BAR_PAD_RIGHT);
lv_style_set_pad_ver(&theme->styles.app_bar, 0); lv_style_set_pad_ver(&theme->styles.app_bar, 0);
lv_style_set_pad_gap(&theme->styles.app_bar, APP_BAR_GAP); lv_style_set_pad_gap(&theme->styles.app_bar, APP_BAR_GAP);
style_init_reset(&theme->styles.app_bar_nav_button);
if(theme->config.is_compact) {
lv_style_set_margin_left(&theme->styles.app_bar_nav_button, 4);
}
style_init_reset(&theme->styles.app_bar_button_focus_ring);
if(theme->config.is_compact) {
lv_style_set_outline_pad(&theme->styles.app_bar_button_focus_ring, 0);
}
style_init_reset(&theme->styles.app_bar_button); style_init_reset(&theme->styles.app_bar_button);
lv_style_set_pad_all(&theme->styles.app_bar_button, 0); lv_style_set_pad_all(&theme->styles.app_bar_button, 0);
@ -1334,6 +1349,19 @@ static void theme_apply(lv_theme_t * th, lv_obj_t * obj)
} }
#endif #endif
if(lv_obj_check_type(obj, &lvgl_icon_button_class)) { if(lv_obj_check_type(obj, &lvgl_icon_button_class)) {
/* toolbar > icon_button[0] */
if(lv_obj_check_type(parent, &lvgl_toolbar_class) && lss_is_child(parent, 0, obj)) {
lv_obj_add_style(obj, &theme->styles.icon_button, 0);
lv_obj_add_style(obj, &theme->styles.app_bar_button, 0);
lv_obj_add_style(obj, &theme->styles.app_bar_nav_button, 0);
lv_obj_add_style(obj, &theme->styles.inverted, LV_STATE_PRESSED);
lv_obj_add_style(obj, &theme->styles.app_bar_focus_ring, LV_STATE_FOCUS_KEY);
lv_obj_add_style(obj, &theme->styles.app_bar_focus_ring, LV_STATE_EDITED);
lv_obj_add_style(obj, &theme->styles.app_bar_button_focus_ring, LV_STATE_FOCUS_KEY);
lv_obj_add_style(obj, &theme->styles.app_bar_button_focus_ring, LV_STATE_EDITED);
lv_obj_add_style(obj, &theme->styles.disabled, LV_STATE_DISABLED);
return;
}
/* toolbar > icon_button */ /* toolbar > icon_button */
if(lv_obj_check_type(parent, &lvgl_toolbar_class)) { if(lv_obj_check_type(parent, &lvgl_toolbar_class)) {
lv_obj_add_style(obj, &theme->styles.icon_button, 0); lv_obj_add_style(obj, &theme->styles.icon_button, 0);
@ -1341,6 +1369,8 @@ static void theme_apply(lv_theme_t * th, lv_obj_t * obj)
lv_obj_add_style(obj, &theme->styles.inverted, LV_STATE_PRESSED); lv_obj_add_style(obj, &theme->styles.inverted, LV_STATE_PRESSED);
lv_obj_add_style(obj, &theme->styles.app_bar_focus_ring, LV_STATE_FOCUS_KEY); lv_obj_add_style(obj, &theme->styles.app_bar_focus_ring, LV_STATE_FOCUS_KEY);
lv_obj_add_style(obj, &theme->styles.app_bar_focus_ring, LV_STATE_EDITED); lv_obj_add_style(obj, &theme->styles.app_bar_focus_ring, LV_STATE_EDITED);
lv_obj_add_style(obj, &theme->styles.app_bar_button_focus_ring, LV_STATE_FOCUS_KEY);
lv_obj_add_style(obj, &theme->styles.app_bar_button_focus_ring, LV_STATE_EDITED);
lv_obj_add_style(obj, &theme->styles.disabled, LV_STATE_DISABLED); lv_obj_add_style(obj, &theme->styles.disabled, LV_STATE_DISABLED);
return; return;
} }
@ -1486,6 +1516,11 @@ static void theme_apply(lv_theme_t * th, lv_obj_t * obj)
lv_obj_add_style(obj, &theme->styles.scrollbar, LV_PART_SCROLLBAR); lv_obj_add_style(obj, &theme->styles.scrollbar, LV_PART_SCROLLBAR);
return; return;
} }
if(lv_obj_check_type(obj, &lvgl_focus_container_class)) {
/* focus_container */
lv_obj_add_style(obj, &theme->styles.focus_ring, LV_STATE_FOCUS_KEY);
return;
}
if(lv_obj_check_type(obj, &lvgl_sliderbox_class)) { if(lv_obj_check_type(obj, &lvgl_sliderbox_class)) {
/* sliderbox */ /* sliderbox */
lv_obj_add_style(obj, &theme->styles.sliderbox, 0); lv_obj_add_style(obj, &theme->styles.sliderbox, 0);

View File

@ -0,0 +1,27 @@
// SPDX-License-Identifier: Apache-2.0
#define LV_USE_PRIVATE_API 1 // For the lv_obj_class_t declaration
#include <lvgl/widgets/focus_container.h>
// Not static: the theme styles focus containers by this class
extern "C" const lv_obj_class_t lvgl_focus_container_class = {
.base_class = &lv_obj_class,
.constructor_cb = nullptr,
.destructor_cb = nullptr,
.event_cb = nullptr,
.user_data = nullptr,
.name = "focus_container",
.width_def = LV_SIZE_CONTENT,
.height_def = LV_SIZE_CONTENT,
.editable = LV_OBJ_CLASS_EDITABLE_INHERIT,
.group_def = LV_OBJ_CLASS_GROUP_DEF_TRUE,
.instance_size = 0,
.theme_inheritable = LV_OBJ_CLASS_THEME_INHERITABLE_FALSE
};
lv_obj_t* lvgl_focus_container_create(lv_obj_t* parent) {
lv_obj_t* obj = lv_obj_class_create_obj(&lvgl_focus_container_class, parent);
lv_obj_class_init_obj(obj);
lv_obj_set_scrollable(obj, false);
return obj;
}

View File

@ -3,6 +3,7 @@
#include <lvgl/widgets/sliderbox.h> #include <lvgl/widgets/sliderbox.h>
#include <lvgl/fonts.h> #include <lvgl/fonts.h>
#include <lvgl/grid_navigation.h>
typedef struct { typedef struct {
lv_obj_t obj; lv_obj_t obj;
@ -175,6 +176,9 @@ lv_obj_t* lvgl_sliderbox_create(lv_obj_t* parent, int32_t min, int32_t max, int3
sliderBox->plusButton = create_step_button(obj, LV_SYMBOL_PLUS, &on_plus_button_clicked, obj, button_size); sliderBox->plusButton = create_step_button(obj, LV_SYMBOL_PLUS, &on_plus_button_clicked, obj, button_size);
// The sliderbox is one stop for the keys: left and right move between the buttons and the slider, up and down leave it
lvgl_grid_navigation_add(obj);
lvgl_sliderbox_set_value(obj, value, LV_ANIM_OFF); lvgl_sliderbox_set_value(obj, value, LV_ANIM_OFF);
return obj; return obj;

View File

@ -30,6 +30,7 @@
@widget icon_button_tonal: lvgl_icon_button_tonal_class; @widget icon_button_tonal: lvgl_icon_button_tonal_class;
@widget sliderbox: lvgl_sliderbox_class; @widget sliderbox: lvgl_sliderbox_class;
@widget card: lvgl_card_class; @widget card: lvgl_card_class;
@widget focus_container: lvgl_focus_container_class;
@widget chip: lvgl_chip_class; @widget chip: lvgl_chip_class;
@widget badge: lvgl_badge_class; @widget badge: lvgl_badge_class;
@widget page_indicator: lvgl_page_indicator_class; @widget page_indicator: lvgl_page_indicator_class;
@ -74,7 +75,7 @@
@corner-large: 16dp; @corner-large: 16dp;
@corner-extra-large: 28dp; @corner-extra-large: 28dp;
@corner-full: circle; @corner-full: circle;
@corner-compact: 3; @corner-compact: 4;
// Spacing scale (4dp grid) // Spacing scale (4dp grid)
@space-1: 4dp; @space-1: 4dp;
@ -266,6 +267,8 @@
@app-bar-content-color: @color-on-surface; @app-bar-content-color: @color-on-surface;
@app-bar-pressed-opacity: 20%; @app-bar-pressed-opacity: 20%;
@app-bar-pad-hor: if($isCompact, 0, @space-1); @app-bar-pad-hor: if($isCompact, 0, @space-1);
// Keeps the action buttons off the screen's edge in the compact layout
@app-bar-pad-right: if($isCompact, @app-bar-pad-hor + 2, @app-bar-pad-hor);
@app-bar-gap: if($isCompact, 2, @space-1); @app-bar-gap: if($isCompact, 2, @space-1);
// The focus ring is drawn inside toolbar buttons, as there's little room around them // The focus ring is drawn inside toolbar buttons, as there's little room around them
@app-bar-focus-width: @focus-inset-width; @app-bar-focus-width: @focus-inset-width;
@ -728,10 +731,21 @@ app_bar {
radius: 0; radius: 0;
borderWidth: 0; borderWidth: 0;
padHor: @app-bar-pad-hor; padHor: @app-bar-pad-hor;
padRight: @app-bar-pad-right;
padVer: 0; padVer: 0;
padGap: @app-bar-gap; padGap: @app-bar-gap;
} }
// The toolbar's first button: the close (navigation) button
app_bar_nav_button {
@if ($isCompact) { marginLeft: 4; }
}
// Compact toolbar buttons are as big as their icon, so their focus ring goes around the outside instead of the inside
app_bar_button_focus_ring {
@if ($isCompact) { outlinePad: 0; }
}
app_bar_button { app_bar_button {
padAll: 0; padAll: 0;
textColor: @app-bar-content-color; textColor: @app-bar-content-color;
@ -1329,6 +1343,20 @@ apply(button) {
button_disabled: state.disabled; button_disabled: state.disabled;
} }
apply(toolbar > icon_button[0]) {
icon_button: default;
app_bar_button: default;
app_bar_nav_button: default;
transition_exit: default;
app_bar_button_pressed: state.pressed;
transition_enter: state.pressed;
app_bar_focus_ring: state.focusKey;
app_bar_focus_ring: state.edited;
app_bar_button_focus_ring: state.focusKey;
app_bar_button_focus_ring: state.edited;
disabled: state.disabled;
}
apply(toolbar > icon_button) { apply(toolbar > icon_button) {
icon_button: default; icon_button: default;
app_bar_button: default; app_bar_button: default;
@ -1337,6 +1365,8 @@ apply(toolbar > icon_button) {
transition_enter: state.pressed; transition_enter: state.pressed;
app_bar_focus_ring: state.focusKey; app_bar_focus_ring: state.focusKey;
app_bar_focus_ring: state.edited; app_bar_focus_ring: state.edited;
app_bar_button_focus_ring: state.focusKey;
app_bar_button_focus_ring: state.edited;
disabled: state.disabled; disabled: state.disabled;
} }
@ -1480,6 +1510,10 @@ apply(card) {
scrollbar: part.scrollbar; scrollbar: part.scrollbar;
} }
apply(focus_container) {
focus_ring: state.focusKey;
}
apply(sliderbox) { apply(sliderbox) {
sliderbox: default; sliderbox: default;
} }

View File

@ -28,6 +28,7 @@
@widget icon_button_tonal: lvgl_icon_button_tonal_class; @widget icon_button_tonal: lvgl_icon_button_tonal_class;
@widget sliderbox: lvgl_sliderbox_class; @widget sliderbox: lvgl_sliderbox_class;
@widget card: lvgl_card_class; @widget card: lvgl_card_class;
@widget focus_container: lvgl_focus_container_class;
@widget chip: lvgl_chip_class; @widget chip: lvgl_chip_class;
@widget badge: lvgl_badge_class; @widget badge: lvgl_badge_class;
@widget page_indicator: lvgl_page_indicator_class; @widget page_indicator: lvgl_page_indicator_class;
@ -49,7 +50,7 @@
@corner-medium: 8dp; @corner-medium: 8dp;
@corner-large: 12dp; @corner-large: 12dp;
@corner-full: circle; @corner-full: circle;
@corner-compact: 2; @corner-compact: 4;
// Spacing scale // Spacing scale
@space-1: 2dp; @space-1: 2dp;
@ -184,6 +185,8 @@
// Toolbar: top app bar with a divider, which shows the background like the statusbar does // Toolbar: top app bar with a divider, which shows the background like the statusbar does
@app-bar-title-color: @color-on-surface; @app-bar-title-color: @color-on-surface;
@app-bar-pad-hor: if($isCompact, 0, @space-1); @app-bar-pad-hor: if($isCompact, 0, @space-1);
// Keeps the action buttons off the screen's edge in the compact layout
@app-bar-pad-right: if($isCompact, @app-bar-pad-hor + 2, @app-bar-pad-hor);
@app-bar-gap: if($isCompact, 2, @space-1); @app-bar-gap: if($isCompact, 2, @space-1);
// The focus ring is drawn inside toolbar buttons, as there's little room around them // The focus ring is drawn inside toolbar buttons, as there's little room around them
@app-bar-focus-width: @focus-inset-width; @app-bar-focus-width: @focus-inset-width;
@ -503,10 +506,21 @@ app_bar {
radius: 0; radius: 0;
borderWidth: 0; borderWidth: 0;
padHor: @app-bar-pad-hor; padHor: @app-bar-pad-hor;
padRight: @app-bar-pad-right;
padVer: 0; padVer: 0;
padGap: @app-bar-gap; padGap: @app-bar-gap;
} }
// The toolbar's first button: the close (navigation) button
app_bar_nav_button {
@if ($isCompact) { marginLeft: 4; }
}
// Compact toolbar buttons are as big as their icon, so their focus ring goes around the outside instead of the inside
app_bar_button_focus_ring {
@if ($isCompact) { outlinePad: 0; }
}
app_bar_button { app_bar_button {
padAll: 0; padAll: 0;
} }
@ -987,12 +1001,26 @@ apply(tabview > *[0] > button) {
edit_ring: state.edited; edit_ring: state.edited;
} }
apply(toolbar > icon_button[0]) {
icon_button: default;
app_bar_button: default;
app_bar_nav_button: default;
inverted: state.pressed;
app_bar_focus_ring: state.focusKey;
app_bar_focus_ring: state.edited;
app_bar_button_focus_ring: state.focusKey;
app_bar_button_focus_ring: state.edited;
disabled: state.disabled;
}
apply(toolbar > icon_button) { apply(toolbar > icon_button) {
icon_button: default; icon_button: default;
app_bar_button: default; app_bar_button: default;
inverted: state.pressed; inverted: state.pressed;
app_bar_focus_ring: state.focusKey; app_bar_focus_ring: state.focusKey;
app_bar_focus_ring: state.edited; app_bar_focus_ring: state.edited;
app_bar_button_focus_ring: state.focusKey;
app_bar_button_focus_ring: state.edited;
disabled: state.disabled; disabled: state.disabled;
} }
@ -1110,6 +1138,10 @@ apply(card) {
scrollbar: part.scrollbar; scrollbar: part.scrollbar;
} }
apply(focus_container) {
focus_ring: state.focusKey;
}
apply(sliderbox) { apply(sliderbox) {
sliderbox: default; sliderbox: default;
} }

View File

@ -145,6 +145,43 @@ enum WindowState window_manager_get_state(WindowId id);
*/ */
enum WindowState window_manager_await_state_change(WindowId id, TickType_t timeout); enum WindowState window_manager_await_state_change(WindowId id, TickType_t timeout);
/**
* Windows live in layers. Only the active layer receives input: its widgets form the group that
* keypad indevs are attached to, and a shown overlay covers the app layer for pointer input.
*/
enum WindowLayer {
/** The chrome created by the screen-init callback and the app windows. */
WINDOW_LAYER_APP,
/** A single overlay on top of the app layer. */
WINDOW_LAYER_OVERLAY,
};
/**
* Shows an overlay on top of the app layer and makes it the active layer. A shown overlay is
* replaced. The overlay is a full-size backdrop: clicking the backdrop itself (outside of the
* widgets created by @a create_widgets) or pressing ESC on a focused overlay widget hides it.
* App windows that are created or rebuilt while the overlay is shown stay in the app layer.
* When a pointer device exists, the widget that is focused first doesn't show as selected.
* @param[in] create_widgets called once to populate the backdrop, with the LVGL lock held
* @param[in] destroy_widgets may be NULL, called once when the overlay is hidden, with the LVGL
* lock held
* @param[in] user_data passed to both callbacks
* @warning Only uses the LVGL lock, so it may be called from LVGL event callbacks.
* @retval ERROR_INVALID_STATE the window manager isn't started
* @retval ERROR_NONE on success
*/
error_t window_manager_overlay_show(WindowCreateWidgetsFn create_widgets, WindowDestroyWidgetsFn destroy_widgets, void* user_data);
/**
* Hides the overlay, if any, and makes the app layer the active layer.
* @warning Only uses the LVGL lock, so it may be called from LVGL event callbacks, including
* the events of the overlay's own widgets.
*/
void window_manager_overlay_hide(void);
/** @return the layer that currently receives input */
enum WindowLayer window_manager_get_active_layer(void);
#ifdef __cplusplus #ifdef __cplusplus
} }
#endif #endif

View File

@ -12,6 +12,9 @@ static const ModuleSymbol SYMBOLS[] = {
DEFINE_MODULE_SYMBOL(window_manager_remove), DEFINE_MODULE_SYMBOL(window_manager_remove),
DEFINE_MODULE_SYMBOL(window_manager_get_state), DEFINE_MODULE_SYMBOL(window_manager_get_state),
DEFINE_MODULE_SYMBOL(window_manager_await_state_change), DEFINE_MODULE_SYMBOL(window_manager_await_state_change),
DEFINE_MODULE_SYMBOL(window_manager_overlay_show),
DEFINE_MODULE_SYMBOL(window_manager_overlay_hide),
DEFINE_MODULE_SYMBOL(window_manager_get_active_layer),
MODULE_SYMBOL_TERMINATOR, MODULE_SYMBOL_TERMINATOR,
}; };

View File

@ -3,6 +3,9 @@
#include <app/instance.h> #include <app/instance.h>
#include <lvgl/devices/indev.h>
#include <lvgl/devices/keyboard.h>
#include <lvgl/grid_navigation.h>
#include <lvgl/lvgl.h> #include <lvgl/lvgl.h>
#include <tactility/check.h> #include <tactility/check.h>
@ -88,6 +91,54 @@ WindowManagerState& state() {
return instance; return instance;
} }
/** Guarded by the LVGL lock instead of WindowManagerState's mutexes, so the overlay can be shown
* and hidden from LVGL event callbacks. */
struct OverlayState {
lv_obj_t* widget = nullptr;
lv_group_t* group = nullptr;
WindowDestroyWidgetsFn destroy_widgets = nullptr;
void* user_data = nullptr;
};
OverlayState overlay;
void delete_overlay_widget(void* widget) {
lv_obj_delete(static_cast<lv_obj_t*>(widget));
}
// A pending deletion must not run for a widget that was deleted along with the root widget
void on_overlay_widget_deleted(lv_event_t* event) {
lv_async_call_cancel(delete_overlay_widget, lv_event_get_current_target_obj(event));
}
// Call while holding the LVGL lock
void hide_overlay_locked() {
if (overlay.widget == nullptr) {
return;
}
if (overlay.destroy_widgets != nullptr) {
overlay.destroy_widgets(overlay.user_data);
}
lvgl_keyboard_set_active_group(nullptr);
lv_group_delete(overlay.group);
// Deleted asynchronously, as this may run inside an event of one of the overlay's widgets
lv_obj_add_flag(overlay.widget, LV_OBJ_FLAG_HIDDEN);
lv_async_call(delete_overlay_widget, overlay.widget);
overlay = {};
}
void on_overlay_backdrop_clicked(lv_event_t* event) {
if (lv_event_get_target_obj(event) == lv_event_get_current_target_obj(event)) {
hide_overlay_locked();
}
}
void on_overlay_key(lv_event_t* event) {
if (lv_event_get_key(event) == LV_KEY_ESC) {
hide_overlay_locked();
}
}
lv_obj_t* build_window_widget(lv_obj_t* content, WindowCreateWidgetsFn create_widgets, void* user_data) { lv_obj_t* build_window_widget(lv_obj_t* content, WindowCreateWidgetsFn create_widgets, void* user_data) {
if (content == nullptr) { if (content == nullptr) {
return nullptr; return nullptr;
@ -103,7 +154,11 @@ lv_obj_t* build_window_widget(lv_obj_t* content, WindowCreateWidgetsFn create_wi
// theme's scrollbar styling as a thin line hugging this widget's edges. // theme's scrollbar styling as a thin line hugging this widget's edges.
lv_obj_set_scrollable(widget, false); lv_obj_set_scrollable(widget, false);
if (create_widgets != nullptr) { if (create_widgets != nullptr) {
// Windows belong to the app layer, also while the overlay is the active layer
lv_group_t* default_group = lv_group_get_default();
lv_group_set_default(lvgl_keyboard_get_app_group());
create_widgets(widget, user_data); create_widgets(widget, user_data);
lv_group_set_default(default_group);
} }
lvgl_unlock(); lvgl_unlock();
return widget; return widget;
@ -308,6 +363,10 @@ error_t window_manager_stop(void) {
give_and_release(waiter); give_and_release(waiter);
} }
lvgl_lock();
hide_overlay_locked();
lvgl_unlock();
// Deleting the real widget cascades to everything under it - chrome and top_widget alike. // Deleting the real widget cascades to everything under it - chrome and top_widget alike.
delete_widget(widget, top_destroy_widgets, top_user_data); delete_widget(widget, top_destroy_widgets, top_user_data);
@ -512,4 +571,75 @@ WindowState window_manager_await_state_change(WindowId id, TickType_t timeout) {
return window_manager_get_state(id); return window_manager_get_state(id);
} }
error_t window_manager_overlay_show(WindowCreateWidgetsFn create_widgets, WindowDestroyWidgetsFn destroy_widgets, void* user_data) {
auto& s = state();
lvgl_lock();
mutex_lock(&s.mutex);
lv_obj_t* root = s.started ? s.real_root_widget : nullptr;
mutex_unlock(&s.mutex);
if (root == nullptr) {
lvgl_unlock();
return ERROR_INVALID_STATE;
}
hide_overlay_locked();
lv_group_t* group = lv_group_create();
if (group == nullptr) {
lvgl_unlock();
return ERROR_OUT_OF_MEMORY;
}
// Widgets created from here on join the overlay's group
lvgl_keyboard_set_active_group(group);
// Created as the last child of the root widget, so it covers everything else
lv_obj_t* widget = lv_obj_create(root);
lv_obj_set_size(widget, LV_PCT(100), LV_PCT(100));
lv_obj_set_style_pad_all(widget, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(widget, 0, LV_STATE_DEFAULT);
lv_obj_set_style_radius(widget, 0, LV_STATE_DEFAULT);
// Monochrome displays can't dim the app layer, so the overlay draws over it as-is
const bool monochrome = lv_display_get_color_format(lv_obj_get_display(root)) == LV_COLOR_FORMAT_I1;
lv_obj_set_style_bg_color(widget, lv_color_black(), LV_STATE_DEFAULT);
lv_obj_set_style_bg_opa(widget, monochrome ? LV_OPA_TRANSP : LV_OPA_50, LV_STATE_DEFAULT);
lv_obj_set_scrollable(widget, false);
lv_obj_add_event_cb(widget, on_overlay_backdrop_clicked, LV_EVENT_CLICKED, nullptr);
lv_obj_add_event_cb(widget, on_overlay_widget_deleted, LV_EVENT_DELETE, nullptr);
overlay = OverlayState { widget, group, destroy_widgets, user_data };
if (create_widgets != nullptr) {
create_widgets(widget, user_data);
}
// The first widget in the new group is focused automatically. With a pointer device, that shouldn't show as selected.
if (lvgl_indev_exists(LV_INDEV_TYPE_POINTER)) {
lvgl_focus_hide_key_selection(group);
}
const uint32_t object_count = lv_group_get_obj_count(group);
for (uint32_t i = 0; i < object_count; i++) {
lv_obj_add_event_cb(lv_group_get_obj_by_index(group, i), on_overlay_key, LV_EVENT_KEY, nullptr);
}
lvgl_unlock();
return ERROR_NONE;
}
void window_manager_overlay_hide(void) {
lvgl_lock();
hide_overlay_locked();
lvgl_unlock();
}
WindowLayer window_manager_get_active_layer(void) {
lvgl_lock();
WindowLayer layer = overlay.widget != nullptr ? WINDOW_LAYER_OVERLAY : WINDOW_LAYER_APP;
lvgl_unlock();
return layer;
}
} // extern "C" } // extern "C"

View File

@ -26,13 +26,18 @@ void statusbar_icon_set_image(int8_t id, const std::string& image);
/** Update the visibility for an icon on the statusbar. Does not need to be called with LVGL lock. */ /** Update the visibility for an icon on the statusbar. Does not need to be called with LVGL lock. */
void statusbar_icon_set_visibility(int8_t id, bool visible); void statusbar_icon_set_visibility(int8_t id, bool visible);
/**
* Sets the app that starts when the icon is clicked. Does not need to be called with LVGL lock.
* @param[in] id the icon id
* @param[in] appId the app id, or an empty string to make the icon not clickable
*/
void statusbar_icon_set_app(int8_t id, const std::string& appId);
int statusbar_get_height(); int statusbar_get_height();
/**
* Switches between the split layout (a bar on top) and the side layout (a vertical strip on the left). Circular displays always use the split layout.
* The caller places the statusbar in its parent. Needs to be called with LVGL lock.
*/
void statusbar_set_side(lv_obj_t* statusbar, bool side);
/**
* @return the container for the action buttons of apps in the side layout, or nullptr on circular displays. It is hidden in the split layout.
* Needs to be called with LVGL lock.
*/
lv_obj_t* statusbar_get_action_container(lv_obj_t* statusbar);
} // namespace } // namespace

View File

@ -0,0 +1,61 @@
#pragma once
#include <cstdint>
namespace tt::settings::launcher {
/** How the statusbar and the app's navigation bar are placed on screen */
enum class SystemBarsLayout {
/** The statusbar on top and the navigation bar at the bottom */
Split,
/** A single strip on the left side of the screen */
Side
};
enum class SystemBarsMode {
/** Side for wide landscape screens, Split otherwise */
Auto,
/** The layouts that the user picked for portrait and landscape */
Custom
};
/** The system bars layouts that the device allows in an orientation */
enum class SystemBarsCapability {
/** The layout follows the settings */
Any,
Split,
Side
};
struct SystemBarsCapabilities {
/** Used for portrait (including square) screens */
SystemBarsCapability portrait;
SystemBarsCapability landscape;
};
struct LauncherSettings {
SystemBarsMode systemBarsMode;
/** Used for portrait (including square) screens in SystemBarsMode::Custom */
SystemBarsLayout portraitLayout;
/** Used for landscape screens in SystemBarsMode::Custom */
SystemBarsLayout landscapeLayout;
};
bool load(LauncherSettings& settings);
LauncherSettings loadOrGetDefault();
LauncherSettings getDefault();
bool save(const LauncherSettings& settings);
/** @return the system bars layouts that this device's configuration allows */
SystemBarsCapabilities getSystemBarsCapabilities();
/**
* @return the system bars layout for a screen of the given size in its current orientation.
* A capability other than Any for that orientation decides the layout, instead of the settings.
*/
SystemBarsLayout resolveSystemBarsLayout(const LauncherSettings& settings, const SystemBarsCapabilities& capabilities, int32_t width, int32_t height);
} // namespace tt::settings::launcher

View File

@ -59,6 +59,32 @@ menu "Tactility App"
help help
The minimum time to show the splash screen in milliseconds. The minimum time to show the splash screen in milliseconds.
When set to 0, startup will continue to desktop as soon as boot operations are finished. When set to 0, startup will continue to desktop as soon as boot operations are finished.
choice TT_SYSTEM_BARS_PORTRAIT
prompt "System bars layouts in portrait"
default TT_SYSTEM_BARS_PORTRAIT_ANY
help
The system bars layouts that portrait orientations (including square ones) can use.
"Any" lets the user's Appearance settings decide.
config TT_SYSTEM_BARS_PORTRAIT_ANY
bool "Any"
config TT_SYSTEM_BARS_PORTRAIT_SPLIT
bool "Split"
config TT_SYSTEM_BARS_PORTRAIT_SIDE
bool "Side"
endchoice
choice TT_SYSTEM_BARS_LANDSCAPE
prompt "System bars layouts in landscape"
default TT_SYSTEM_BARS_LANDSCAPE_ANY
help
The system bars layouts that landscape orientations can use.
"Any" lets the user's Appearance settings decide.
config TT_SYSTEM_BARS_LANDSCAPE_ANY
bool "Any"
config TT_SYSTEM_BARS_LANDSCAPE_SPLIT
bool "Split"
config TT_SYSTEM_BARS_LANDSCAPE_SIDE
bool "Side"
endchoice
config TT_TOUCH_CALIBRATION_SUPPORTED config TT_TOUCH_CALIBRATION_SUPPORTED
bool "Set true when a touch screen calibration app should be included" bool "Set true when a touch screen calibration app should be included"
default n default n

View File

@ -69,7 +69,7 @@ public:
/** /**
* Creates the grid and populates it, with a bar below it: buttons on the left (see addBarButton()), * Creates the grid and populates it, with a bar below it: buttons on the left (see addBarButton()),
* a page indicator in the center and page buttons on the right. The page indicator and buttons are * followed by the page buttons, and a page indicator on the right. The page indicator and buttons are
* hidden when there's only one page, and the page buttons wrap around. * hidden when there's only one page, and the page buttons wrap around.
* @param[in] parent the container for the grid and the bar * @param[in] parent the container for the grid and the bar
*/ */
@ -82,12 +82,22 @@ public:
void setPageButtons(lv_obj_t* previous, lv_obj_t* next); void setPageButtons(lv_obj_t* previous, lv_obj_t* next);
/** /**
* Adds an icon button to the left side of the bottom bar. Call right after createWidgetsWithBottomBar(). * Adds an icon button to the bottom bar, before the page buttons. Call right after createWidgetsWithBottomBar().
* @param[in] icon a shared icon (see lvgl/icons/shared.h) * @param[in] icon a shared icon (see lvgl/icons/shared.h)
* @return the button * @return the button
*/ */
lv_obj_t* addBarButton(const char* icon, lv_event_cb_t onClicked, void* userData); lv_obj_t* addBarButton(const char* icon, lv_event_cb_t onClicked, void* userData);
/**
* Moves the bottom bar's buttons into a column in the given container, e.g. a side strip, and the page indicator below the grid.
* Call after createWidgetsWithBottomBar(), and again whenever the container changes.
* @param[in] sideContainer the container for the buttons, or nullptr to show the bar below the grid
*/
void setBarSideContainer(lv_obj_t* sideContainer);
/** Deletes the widgets that aren't part of the parent passed to createWidgetsWithBottomBar(). Call when the parent's widgets are deleted. */
void destroyWidgets();
/** /**
* Rebuilds the grid asynchronously, so it is safe to call from a tile's own event. * Rebuilds the grid asynchronously, so it is safe to call from a tile's own event.
* @param[in] keepSelection when true, the selected item stays selected and its page is shown * @param[in] keepSelection when true, the selected item stays selected and its page is shown
@ -115,8 +125,16 @@ private:
lv_obj_t* pageIndicator = nullptr; lv_obj_t* pageIndicator = nullptr;
lv_obj_t* bottomBar = nullptr; lv_obj_t* bottomBar = nullptr;
lv_obj_t* barSpacer = nullptr; lv_obj_t* barSpacer = nullptr;
/** The parent passed to createWidgetsWithBottomBar() */
lv_obj_t* barParent = nullptr;
/** The container that holds the bar's buttons instead of the bar below the grid, or nullptr */
lv_obj_t* barSideContainer = nullptr;
/** The buttons added with addBarButton(), in the order they were added */
std::vector<lv_obj_t*> barButtons;
void createGrid(lv_obj_t* parent); void createGrid(lv_obj_t* parent);
void arrangeBar();
static void onSideBarKey(lv_event_t* e);
void finishCreate(lv_obj_t* parent); void finishCreate(lv_obj_t* parent);
void goToPreviousPage(); void goToPreviousPage();
void goToNextPage(); void goToNextPage();

View File

@ -0,0 +1,59 @@
#pragma once
#include <Tactility/settings/LauncherSettings.h>
#include <lvgl.h>
#include <cstdint>
#include <functional>
namespace tt::lvgl {
using SystemBarsListenerId = uint32_t;
/**
* Takes control of the layout of the screen's statusbar and app container, and applies the layout of the launcher settings.
* The layout follows display rotations by itself.
* @warning Caller must hold the LVGL lock.
* @param[in] container the flex container that holds the statusbar and the app container
*/
void systemBarsAttach(lv_obj_t* container, lv_obj_t* statusbar, lv_obj_t* appContainer);
/**
* Releases the objects passed to systemBarsAttach(), before they're deleted.
* @warning Caller must hold the LVGL lock.
*/
void systemBarsDetach();
/**
* Applies the launcher settings and the current display size again, e.g. after the settings changed.
* Notifies the listeners when the layout changed.
* @warning Caller must hold the LVGL lock.
*/
void systemBarsRefresh();
/**
* @warning Caller must hold the LVGL lock.
* @return the layout that is shown
*/
settings::launcher::SystemBarsLayout systemBarsGetLayout();
/**
* @warning Caller must hold the LVGL lock.
* @return the container in the side strip for an app's action buttons, or nullptr when the side layout isn't shown
*/
lv_obj_t* systemBarsGetActionContainer();
/**
* Adds a listener that is called when the layout changed. It's called with the LVGL lock held.
* @warning Caller must hold the LVGL lock.
*/
SystemBarsListenerId systemBarsAddListener(std::function<void()> listener);
/**
* Removes a listener. It doesn't take any lock besides the LVGL lock, so a window's destroy_widgets callback can call it.
* @warning Caller must hold the LVGL lock.
*/
void systemBarsRemoveListener(SystemBarsListenerId id);
}

View File

@ -0,0 +1,11 @@
#pragma once
namespace tt::lvgl {
/**
* Shows the system menu with quick toggles (Wi-Fi, GPS, Bluetooth) in the window manager's overlay layer.
* @warning Caller must hold the LVGL lock.
*/
void systemMenuShow();
}

View File

@ -4,6 +4,7 @@
#include <lvgl/fonts.h> #include <lvgl/fonts.h>
#include <lvgl/grid_navigation.h> #include <lvgl/grid_navigation.h>
#include <lvgl/insets.h> #include <lvgl/insets.h>
#include <lvgl/theme.h>
#include <lvgl/widgets/badge.h> #include <lvgl/widgets/badge.h>
#include <lvgl/widgets/icon_button.h> #include <lvgl/widgets/icon_button.h>
#include <lvgl/widgets/page_indicator.h> #include <lvgl/widgets/page_indicator.h>
@ -56,41 +57,32 @@ const TileGridItem* getTileItem(lv_event_t* e) {
} }
// The width of the visible buttons on one side of the bar's spacer, including the gaps between them // The page indicator gets the space that the buttons leave.
int32_t getSideWidth(lv_obj_t* bar, bool rightSide) { // It's the only child that is neither clickable nor the spacer (which grows).
int32_t width = 0;
uint32_t visible = 0;
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(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_is_hidden(child) && !lv_obj_is_ignore_layout(child)) {
width += lv_obj_get_width(child);
visible++;
}
}
return visible > 1 ? width + static_cast<int32_t>(visible - 1) * lv_obj_get_style_pad_column(bar, LV_PART_MAIN) : width;
}
// The page indicator is centered on the bar and gets the space that the buttons on the wider side leave on both sides
void updatePageIndicatorWidth(void* data) { void updatePageIndicatorWidth(void* data) {
auto* bottom_bar = static_cast<lv_obj_t*>(data); auto* bottom_bar = static_cast<lv_obj_t*>(data);
lv_obj_t* indicator = nullptr; lv_obj_t* indicator = nullptr;
int32_t buttons_width = 0;
int32_t gap_count = -1;
const uint32_t count = lv_obj_get_child_count(bottom_bar); const uint32_t count = lv_obj_get_child_count(bottom_bar);
for (uint32_t i = 0; i < count && indicator == nullptr; i++) { for (uint32_t i = 0; i < count; i++) {
lv_obj_t* child = lv_obj_get_child(bottom_bar, static_cast<int32_t>(i)); lv_obj_t* child = lv_obj_get_child(bottom_bar, static_cast<int32_t>(i));
if (lv_obj_is_ignore_layout(child)) { if (lv_obj_get_style_flex_grow(child, LV_PART_MAIN) > 0) {
gap_count++;
} else if (!lv_obj_is_clickable(child)) {
indicator = child; indicator = child;
gap_count++;
} else if (!lv_obj_is_hidden(child)) {
buttons_width += lv_obj_get_width(child);
gap_count++;
} }
} }
if (indicator == nullptr) { if (indicator == nullptr) {
return; return;
} }
const int32_t side_width = std::max(getSideWidth(bottom_bar, false), getSideWidth(bottom_bar, true)); const int32_t gaps = gap_count * lv_obj_get_style_pad_column(bottom_bar, LV_PART_MAIN);
const int32_t gaps = 2 * lv_obj_get_style_pad_column(bottom_bar, LV_PART_MAIN); const int32_t margins = lv_obj_get_style_margin_left(indicator, LV_PART_MAIN) + lv_obj_get_style_margin_right(indicator, LV_PART_MAIN);
const int32_t available = lv_obj_get_content_width(bottom_bar) - 2 * side_width - gaps; const int32_t available = lv_obj_get_content_width(bottom_bar) - buttons_width - gaps - margins;
const int32_t max_width = std::max<int32_t>(0, available); const int32_t max_width = std::max<int32_t>(0, available);
// Setting a style marks the layout as dirty, which would resize the bar and call this again // Setting a style marks the layout as dirty, which would resize the bar and call this again
if (lv_obj_get_style_max_width(indicator, LV_PART_MAIN) != max_width) { if (lv_obj_get_style_max_width(indicator, LV_PART_MAIN) != max_width) {
@ -103,6 +95,45 @@ void onBottomBarSizeChanged(lv_event_t* event) {
lv_async_call(updatePageIndicatorWidth, lv_event_get_target_obj(event)); lv_async_call(updatePageIndicatorWidth, lv_event_get_target_obj(event));
} }
// Puts the bar before or after the grid in their input group, without moving the focus
void setGroupOrder(lv_obj_t* grid, lv_obj_t* bar, bool barFirst) {
lv_group_t* group = lv_obj_get_group(bar);
if (group == nullptr || lv_obj_get_group(grid) != group) {
return;
}
int32_t grid_index = -1;
int32_t bar_index = -1;
const uint32_t count = lv_group_get_obj_count(group);
for (uint32_t i = 0; i < count; i++) {
lv_obj_t* obj = lv_group_get_obj_by_index(group, i);
if (obj == grid) {
grid_index = static_cast<int32_t>(i);
} else if (obj == bar) {
bar_index = static_cast<int32_t>(i);
}
}
if ((bar_index < grid_index) == barFirst) {
return;
}
// Swapping also swaps the focus
lv_obj_t* focused = lv_group_get_focused(group);
lv_group_swap_obj(grid, bar);
if (focused != nullptr && lv_group_get_focused(group) != focused) {
lv_group_focus_obj(focused);
}
}
lv_obj_t* findFirstBarButton(lv_obj_t* bar) {
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(bar, static_cast<int32_t>(i));
if (!lv_obj_is_hidden(child) && lv_obj_is_clickable(child)) {
return child;
}
}
return nullptr;
}
void onBottomBarDeleted(lv_event_t* event) { void onBottomBarDeleted(lv_event_t* event) {
lv_async_call_cancel(updatePageIndicatorWidth, lv_event_get_target_obj(event)); lv_async_call_cancel(updatePageIndicatorWidth, lv_event_get_target_obj(event));
} }
@ -346,15 +377,18 @@ void TileGrid::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 // Keys enter the bar on the previous page button, or on the first visible button when there's only one page
void TileGrid::onBottomBarFocused(lv_event_t* e) { void TileGrid::onBottomBarFocused(lv_event_t* e) {
const auto* self = static_cast<TileGrid*>(lv_event_get_user_data(e)); const auto* self = static_cast<TileGrid*>(lv_event_get_user_data(e));
lv_obj_t* current = lvgl_grid_navigation_get_focused(self->bottomBar); lv_obj_t* current = lvgl_grid_navigation_get_focused(self->bottomBar);
lv_obj_t* target = lv_obj_is_hidden(self->prevButton) ? lv_obj_get_child(self->bottomBar, 0) : self->prevButton; lv_obj_t* target = lv_obj_is_hidden(self->prevButton) ? findFirstBarButton(self->bottomBar) : self->prevButton;
if (current == nullptr || target == current || target == self->barSpacer) { if (current == nullptr || target == nullptr || target == current) {
return; return;
} }
const bool showSelection = lv_obj_has_state(current, LV_STATE_FOCUS_KEY); // Only keys show the selection, the bar's previously focused child can be stale
lv_indev_t* indev = lv_indev_active();
const lv_indev_type_t type = indev != nullptr ? lv_indev_get_type(indev) : LV_INDEV_TYPE_NONE;
const bool showSelection = type == LV_INDEV_TYPE_KEYPAD || type == LV_INDEV_TYPE_ENCODER;
lv_gridnav_set_focused(self->bottomBar, target, LV_ANIM_OFF); lv_gridnav_set_focused(self->bottomBar, target, LV_ANIM_OFF);
if (!showSelection) { if (!showSelection) {
lv_obj_remove_state(target, LV_STATE_FOCUS_KEY); lv_obj_remove_state(target, LV_STATE_FOCUS_KEY);
@ -362,6 +396,9 @@ void TileGrid::onBottomBarFocused(lv_event_t* e) {
} }
void TileGrid::createWidgetsWithBottomBar(lv_obj_t* parent) { void TileGrid::createWidgetsWithBottomBar(lv_obj_t* parent) {
barParent = parent;
barSideContainer = nullptr;
barButtons.clear();
bottomBar = lv_obj_create(parent); bottomBar = lv_obj_create(parent);
lv_obj_set_size(bottomBar, LV_PCT(100), LV_SIZE_CONTENT); 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_pad_all(bottomBar, 0, LV_STATE_DEFAULT);
@ -376,7 +413,10 @@ void TileGrid::createWidgetsWithBottomBar(lv_obj_t* parent) {
createGrid(parent); createGrid(parent);
lv_obj_move_foreground(bottomBar); lv_obj_move_foreground(bottomBar);
// The bar buttons go before the spacer, which pushes the page buttons to the right pageIndicator = lvgl_page_indicator_create(bottomBar);
// Keeps the page indicator away from the screen edge
lv_obj_set_style_margin_hor(pageIndicator, lv_obj_get_style_pad_column(bottomBar, LV_PART_MAIN), LV_STATE_DEFAULT);
// The spacer pushes the buttons and the page indicator to opposite sides
barSpacer = lv_obj_create(bottomBar); barSpacer = lv_obj_create(bottomBar);
lv_obj_set_size(barSpacer, 0, 0); lv_obj_set_size(barSpacer, 0, 0);
lv_obj_set_flex_grow(barSpacer, 1); lv_obj_set_flex_grow(barSpacer, 1);
@ -387,9 +427,7 @@ void TileGrid::createWidgetsWithBottomBar(lv_obj_t* parent) {
nextButton = lvgl_icon_button_create(bottomBar); nextButton = lvgl_icon_button_create(bottomBar);
lv_label_set_text(lv_label_create(nextButton), ">"); lv_label_set_text(lv_label_create(nextButton), ">");
lv_obj_add_event_cb(nextButton, onNextPressed, LV_EVENT_SHORT_CLICKED, this); lv_obj_add_event_cb(nextButton, onNextPressed, LV_EVENT_SHORT_CLICKED, this);
pageIndicator = lvgl_page_indicator_create(bottomBar); arrangeBar();
lv_obj_set_ignore_layout(pageIndicator, true);
lv_obj_align(pageIndicator, LV_ALIGN_CENTER, 0, 0);
// The arrow keys move between all buttons of the bar as one row // The arrow keys move between all buttons of the bar as one row
lvgl_grid_navigation_add(bottomBar); lvgl_grid_navigation_add(bottomBar);
lv_obj_add_event_cb(bottomBar, onBottomBarFocused, LV_EVENT_FOCUSED, this); lv_obj_add_event_cb(bottomBar, onBottomBarFocused, LV_EVENT_FOCUSED, this);
@ -404,6 +442,9 @@ void TileGrid::createWidgets(lv_obj_t* parent) {
pageIndicator = nullptr; pageIndicator = nullptr;
bottomBar = nullptr; bottomBar = nullptr;
barSpacer = nullptr; barSpacer = nullptr;
barParent = nullptr;
barSideContainer = nullptr;
barButtons.clear();
createGrid(parent); createGrid(parent);
finishCreate(parent); finishCreate(parent);
} }
@ -443,12 +484,101 @@ lv_obj_t* TileGrid::findTile(const std::string& id) const {
lv_obj_t* TileGrid::addBarButton(const char* icon, lv_event_cb_t onClicked, void* userData) { lv_obj_t* TileGrid::addBarButton(const char* icon, lv_event_cb_t onClicked, void* userData) {
auto* button = lvgl_icon_button_create(bottomBar); auto* button = lvgl_icon_button_create(bottomBar);
lv_obj_move_to_index(button, lv_obj_get_index(barSpacer));
auto* label = lv_label_create(button); auto* label = lv_label_create(button);
lv_obj_set_style_text_font(label, lvgl_get_shared_icon_default_font(), LV_STATE_DEFAULT); lv_obj_set_style_text_font(label, lvgl_get_shared_icon_default_font(), LV_STATE_DEFAULT);
lv_label_set_text(label, icon); lv_label_set_text(label, icon);
lv_obj_add_event_cb(button, onClicked, LV_EVENT_SHORT_CLICKED, userData); lv_obj_add_event_cb(button, onClicked, LV_EVENT_SHORT_CLICKED, userData);
barButtons.push_back(button);
arrangeBar();
return button; return button;
} }
void TileGrid::arrangeBar() {
// The order of the bar's children, from the start of the row or column
std::vector<lv_obj_t*> order;
if (barSideContainer != nullptr) {
// The page indicator is below the grid and the spacer isn't used. The bar's own buttons are at the bottom.
order.insert(order.end(), { barSpacer, nextButton, prevButton });
order.insert(order.end(), barButtons.rbegin(), barButtons.rend());
} else {
order.insert(order.end(), barButtons.begin(), barButtons.end());
order.insert(order.end(), { prevButton, nextButton, barSpacer, pageIndicator });
}
for (size_t i = 0; i < order.size(); i++) {
lv_obj_move_to_index(order[i], static_cast<int32_t>(i));
}
}
void TileGrid::setBarSideContainer(lv_obj_t* sideContainer) {
if (bottomBar == nullptr || sideContainer == barSideContainer) {
return;
}
barSideContainer = sideContainer;
if (sideContainer != nullptr) {
lv_obj_set_parent(bottomBar, sideContainer);
lv_obj_set_size(bottomBar, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
lv_obj_set_flex_flow(bottomBar, LV_FLEX_FLOW_COLUMN);
// The strip has its own background
lv_obj_set_style_bg_opa(bottomBar, LV_OPA_TRANSP, LV_STATE_DEFAULT);
// The buttons are close together, like the statusbar's icons above them
lv_obj_set_style_pad_row(bottomBar, 2, LV_STATE_DEFAULT);
lv_obj_set_hidden(barSpacer, true);
// The page indicator is centered below the grid
lv_obj_set_parent(pageIndicator, barParent);
lv_obj_move_to_index(pageIndicator, lv_obj_get_index(grid) + 1);
lv_obj_set_width(pageIndicator, LV_PCT(100));
lv_obj_remove_local_style_prop(pageIndicator, LV_STYLE_MAX_WIDTH, LV_PART_MAIN);
// Keeps the dots off the screen's bottom edge
lv_obj_set_style_margin_bottom(pageIndicator, lvgl_theme_is_compact() ? 1 : 2, LV_STATE_DEFAULT);
} else {
lv_obj_set_parent(bottomBar, barParent);
lv_obj_move_to_index(bottomBar, lv_obj_get_index(grid) + 1);
lv_obj_set_size(bottomBar, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(bottomBar, LV_FLEX_FLOW_ROW);
lv_obj_remove_local_style_prop(bottomBar, LV_STYLE_BG_OPA, LV_PART_MAIN);
lv_obj_remove_local_style_prop(bottomBar, LV_STYLE_PAD_ROW, LV_PART_MAIN);
lv_obj_set_hidden(barSpacer, false);
lv_obj_set_parent(pageIndicator, bottomBar);
lv_obj_set_width(pageIndicator, LV_SIZE_CONTENT);
lv_obj_remove_local_style_prop(pageIndicator, LV_STYLE_MARGIN_BOTTOM, LV_PART_MAIN);
}
// The keys move through the bar where it's shown: below the statusbar's icons in the side strip, or below the grid
setGroupOrder(grid, bottomBar, sideContainer != nullptr);
if (sideContainer != nullptr) {
// Runs before grid navigation, which would move the focus along the group instead of sideways
lv_obj_add_event_cb(bottomBar, onSideBarKey, static_cast<lv_event_code_t>(LV_EVENT_KEY | LV_EVENT_PREPROCESS), this);
} else {
lv_obj_remove_event_cb(bottomBar, onSideBarKey);
}
arrangeBar();
}
// In the side strip on the left, the right key moves the focus to the grid, and the left key stays at the screen's edge
void TileGrid::onSideBarKey(lv_event_t* e) {
auto* self = static_cast<TileGrid*>(lv_event_get_user_data(e));
const uint32_t key = lv_event_get_key(e);
if (key == LV_KEY_RIGHT) {
lv_group_focus_obj(self->grid);
lv_event_stop_processing(e);
} else if (key == LV_KEY_LEFT) {
lv_event_stop_processing(e);
}
}
void TileGrid::destroyWidgets() {
// The window only deletes its own widgets, and the bar can be in the side strip
if (bottomBar != nullptr && barSideContainer != nullptr) {
lv_obj_delete(bottomBar);
}
// The other bar widgets are deleted with the bar or the window, so no pointer may outlive them
bottomBar = nullptr;
barSideContainer = nullptr;
barParent = nullptr;
pageIndicator = nullptr;
prevButton = nullptr;
nextButton = nullptr;
barSpacer = nullptr;
barButtons.clear();
}
} }

View File

@ -4,7 +4,9 @@
#include <Tactility/lvgl/Fonts.h> #include <Tactility/lvgl/Fonts.h>
#include <Tactility/lvgl/Lvgl.h> #include <Tactility/lvgl/Lvgl.h>
#include <Tactility/lvgl/Theme.h> #include <Tactility/lvgl/Theme.h>
#include <Tactility/lvgl/SystemBars.h>
#include <Tactility/settings/AppearanceSettings.h> #include <Tactility/settings/AppearanceSettings.h>
#include <Tactility/settings/LauncherSettings.h>
#include <app/event.h> #include <app/event.h>
#include <app/manager.h> #include <app/manager.h>
@ -21,6 +23,7 @@
#include <lvgl.h> #include <lvgl.h>
#include <lvgl/fonts.h> #include <lvgl/fonts.h>
#include <lvgl/grid_navigation.h>
#include <lvgl/lvgl.h> #include <lvgl/lvgl.h>
#include <lvgl/theme.h> #include <lvgl/theme.h>
#include <lvgl/widgets/card.h> #include <lvgl/widgets/card.h>
@ -147,6 +150,15 @@ struct Context {
lv_obj_t* surfaceTintChips[settings::appearance::SURFACE_TINT_LEVEL_COUNT] = {}; lv_obj_t* surfaceTintChips[settings::appearance::SURFACE_TINT_LEVEL_COUNT] = {};
/** The display can only show the mono theme */ /** The display can only show the mono theme */
bool isMonoDisplay = false; bool isMonoDisplay = false;
// System bars, which are applied right away instead of with the Apply button
lv_obj_t* systemBarsAutoChip = nullptr;
lv_obj_t* systemBarsCustomChip = nullptr;
lv_obj_t* portraitRow = nullptr;
lv_obj_t* portraitSplitChip = nullptr;
lv_obj_t* portraitSideChip = nullptr;
lv_obj_t* landscapeRow = nullptr;
lv_obj_t* landscapeSplitChip = nullptr;
lv_obj_t* landscapeSideChip = nullptr;
}; };
lvgl::FontConfiguration toFontConfiguration(const settings::appearance::AppearanceSettings& settings) { lvgl::FontConfiguration toFontConfiguration(const settings::appearance::AppearanceSettings& settings) {
@ -481,6 +493,8 @@ lv_obj_t* createLabeledRow(lv_obj_t* parent, const char* text) {
auto* label = lv_label_create(row); auto* label = lv_label_create(row);
lv_label_set_text(label, text); lv_label_set_text(label, text);
lv_obj_set_flex_grow(label, 1); lv_obj_set_flex_grow(label, 1);
// The keys move between the rows, and left and right between the row's items
lvgl_grid_navigation_add(row);
return row; return row;
} }
@ -544,6 +558,83 @@ void createThemeCard(lv_obj_t* parent, Context* ctx) {
lv_obj_add_event_cb(animations_switch, onAnimationsChanged, LV_EVENT_VALUE_CHANGED, ctx); lv_obj_add_event_cb(animations_switch, onAnimationsChanged, LV_EVENT_VALUE_CHANGED, ctx);
} }
void updateSystemBarsWidgets(Context* ctx, const settings::launcher::LauncherSettings& settings) {
using settings::launcher::SystemBarsLayout;
const bool custom = settings.systemBarsMode == settings::launcher::SystemBarsMode::Custom;
setChecked(ctx->systemBarsAutoChip, !custom);
setChecked(ctx->systemBarsCustomChip, custom);
// The layouts per orientation are only used with the Custom mode. Orientations with a fixed layout have no row.
if (ctx->portraitRow != nullptr) {
setHidden(ctx->portraitRow, !custom);
setChecked(ctx->portraitSplitChip, settings.portraitLayout == SystemBarsLayout::Split);
setChecked(ctx->portraitSideChip, settings.portraitLayout == SystemBarsLayout::Side);
}
if (ctx->landscapeRow != nullptr) {
setHidden(ctx->landscapeRow, !custom);
setChecked(ctx->landscapeSplitChip, settings.landscapeLayout == SystemBarsLayout::Split);
setChecked(ctx->landscapeSideChip, settings.landscapeLayout == SystemBarsLayout::Side);
}
}
// Saves the system bars setting of the pressed chip and shows the system bars with it
void onSystemBarsChipPressed(lv_event_t* event) {
using settings::launcher::SystemBarsLayout;
using settings::launcher::SystemBarsMode;
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
auto* chip = lv_event_get_target_obj(event);
auto settings = settings::launcher::loadOrGetDefault();
if (chip == ctx->systemBarsAutoChip || chip == ctx->systemBarsCustomChip) {
settings.systemBarsMode = chip == ctx->systemBarsCustomChip ? SystemBarsMode::Custom : SystemBarsMode::Auto;
} else if (chip == ctx->portraitSplitChip || chip == ctx->portraitSideChip) {
settings.portraitLayout = chip == ctx->portraitSideChip ? SystemBarsLayout::Side : SystemBarsLayout::Split;
} else {
settings.landscapeLayout = chip == ctx->landscapeSideChip ? SystemBarsLayout::Side : SystemBarsLayout::Split;
}
if (!settings::launcher::save(settings)) {
LOG_E(TAG, "Failed to save system bars settings");
}
updateSystemBarsWidgets(ctx, settings);
lvgl::systemBarsRefresh();
}
void createSystemBarsCard(lv_obj_t* parent, Context* ctx) {
using settings::launcher::SystemBarsCapability;
// The device's configuration can fix the layout per orientation, which leaves nothing to choose for it
const auto capabilities = settings::launcher::getSystemBarsCapabilities();
const bool portrait_choosable = capabilities.portrait == SystemBarsCapability::Any;
const bool landscape_choosable = capabilities.landscape == SystemBarsCapability::Any;
// Stale pointers from a previous build of the window must not match new widgets
ctx->portraitRow = nullptr;
ctx->portraitSplitChip = nullptr;
ctx->portraitSideChip = nullptr;
ctx->landscapeRow = nullptr;
ctx->landscapeSplitChip = nullptr;
ctx->landscapeSideChip = nullptr;
if (!portrait_choosable && !landscape_choosable) {
return;
}
auto* card = createSection(parent, "System Bars");
auto* mode_row = createLabeledRow(card, "Layout");
ctx->systemBarsAutoChip = createChip(mode_row, "Auto", onSystemBarsChipPressed, ctx);
ctx->systemBarsCustomChip = createChip(mode_row, "Custom", onSystemBarsChipPressed, ctx);
if (portrait_choosable) {
ctx->portraitRow = createLabeledRow(card, "Portrait");
ctx->portraitSplitChip = createChip(ctx->portraitRow, "Split", onSystemBarsChipPressed, ctx);
ctx->portraitSideChip = createChip(ctx->portraitRow, "Side", onSystemBarsChipPressed, ctx);
}
if (landscape_choosable) {
ctx->landscapeRow = createLabeledRow(card, "Landscape");
ctx->landscapeSplitChip = createChip(ctx->landscapeRow, "Split", onSystemBarsChipPressed, ctx);
ctx->landscapeSideChip = createChip(ctx->landscapeRow, "Side", onSystemBarsChipPressed, ctx);
}
updateSystemBarsWidgets(ctx, settings::launcher::loadOrGetDefault());
}
void createColorsCard(lv_obj_t* parent, Context* ctx) { void createColorsCard(lv_obj_t* parent, Context* ctx) {
ctx->colorsTitle = lv_label_create(parent); ctx->colorsTitle = lv_label_create(parent);
lv_label_set_text(ctx->colorsTitle, "Theme Colors"); lv_label_set_text(ctx->colorsTitle, "Theme Colors");
@ -632,6 +723,7 @@ void createWidgets(lv_obj_t* parent, void* userData) {
if (!ctx->isMonoDisplay) { if (!ctx->isMonoDisplay) {
createColorsCard(content, ctx); createColorsCard(content, ctx);
} }
createSystemBarsCard(content, ctx);
auto* fonts_card = createSection(content, "Fonts"); auto* fonts_card = createSection(content, "Fonts");
auto* font_size_row = createRow(fonts_card); auto* font_size_row = createRow(fonts_card);

View File

@ -156,7 +156,7 @@ extern const ::AppManifest manifest = {
.category = APP_CATEGORY_SETTINGS, .category = APP_CATEGORY_SETTINGS,
.location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast<void*>(appMain) }, .location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast<void*>(appMain) },
.flags = 0, .flags = 0,
.stack = { .depth = 3072, .desired_memory_capability = 0 }, .stack = { .depth = 4096, .desired_memory_capability = 0 },
}; };
} // namespace } // namespace

View File

@ -52,8 +52,7 @@ struct Context {
/** Row and column sizes of the results grid, which must outlive the grid layout */ /** Row and column sizes of the results grid, which must outlive the grid layout */
std::vector<int32_t> gridRows; std::vector<int32_t> gridRows;
std::vector<int32_t> gridColumns; std::vector<int32_t> gridColumns;
// Widgets: the refresh button and chips only exist when there are I2C interfaces // Widgets: the chips only exist when there are I2C interfaces. Pressing a chip scans its bus.
lv_obj_t* refreshButton = nullptr;
lv_obj_t* chipsRow = nullptr; lv_obj_t* chipsRow = nullptr;
lv_obj_t* scanListWidget = nullptr; lv_obj_t* scanListWidget = nullptr;
}; };
@ -170,7 +169,6 @@ void updateViews(Context* ctx) {
// The scan reads the port once, so the bus can't change and the scan can't restart while it runs // 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; const bool scanning = ctx->scanState == ScanStateScanning;
lv_obj_set_state(ctx->refreshButton, LV_STATE_DISABLED, scanning);
if (ctx->chipsRow != nullptr) { if (ctx->chipsRow != nullptr) {
const uint32_t chip_count = lv_obj_get_child_count(ctx->chipsRow); const uint32_t chip_count = lv_obj_get_child_count(ctx->chipsRow);
for (uint32_t i = 0; i < chip_count; i++) { for (uint32_t i = 0; i < chip_count; i++) {
@ -359,12 +357,6 @@ void onChipPressed(lv_event_t* event) {
selectBus(ctx, static_cast<int32_t>(reinterpret_cast<intptr_t>(lv_obj_get_user_data(chip)))); selectBus(ctx, static_cast<int32_t>(reinterpret_cast<intptr_t>(lv_obj_get_user_data(chip))));
} }
void onPressRefresh(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
startScanning(ctx);
updateViews(ctx);
}
// endregion Callbacks // endregion Callbacks
void createWidgets(lv_obj_t* parent, void* userData) { void createWidgets(lv_obj_t* parent, void* userData) {
@ -394,29 +386,25 @@ void createWidgets(lv_obj_t* parent, void* userData) {
return; return;
} }
ctx->refreshButton = lvgl_toolbar_add_image_button_action(toolbar, LV_SYMBOL_REFRESH, onPressRefresh, ctx); // Also shown for a single bus, as pressing a chip scans its bus again. 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);
if (port_names.size() > 1) { auto* chips_row = lv_obj_create(main_wrapper);
// Centered while the chips fit, and scrollable when they don't lv_obj_set_size(chips_row, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
lv_obj_set_flex_align(main_wrapper, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START); lv_obj_set_style_max_width(chips_row, LV_PCT(100), LV_STATE_DEFAULT);
auto* chips_row = lv_obj_create(main_wrapper); lv_obj_set_flex_flow(chips_row, LV_FLEX_FLOW_ROW);
lv_obj_set_size(chips_row, LV_SIZE_CONTENT, LV_SIZE_CONTENT); lv_obj_set_scroll_dir(chips_row, LV_DIR_HOR);
lv_obj_set_style_max_width(chips_row, LV_PCT(100), LV_STATE_DEFAULT); lv_obj_set_style_border_width(chips_row, 0, LV_STATE_DEFAULT);
lv_obj_set_flex_flow(chips_row, LV_FLEX_FLOW_ROW); // The chips' margins leave room for their focus rings and space them apart
lv_obj_set_scroll_dir(chips_row, LV_DIR_HOR); lv_obj_set_style_pad_all(chips_row, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(chips_row, 0, LV_STATE_DEFAULT); lv_obj_set_style_pad_column(chips_row, 0, LV_STATE_DEFAULT);
// The chips' margins leave room for their focus rings and space them apart for (size_t i = 0; i < port_names.size(); i++) {
lv_obj_set_style_pad_all(chips_row, 0, LV_STATE_DEFAULT); auto* chip = lvgl_chip_create(chips_row);
lv_obj_set_style_pad_column(chips_row, 0, LV_STATE_DEFAULT); lv_label_set_text(lv_label_create(chip), port_names[i].c_str());
for (size_t i = 0; i < port_names.size(); i++) { lv_obj_set_user_data(chip, reinterpret_cast<void*>(static_cast<intptr_t>(i)));
auto* chip = lvgl_chip_create(chips_row); lv_obj_add_event_cb(chip, onChipPressed, LV_EVENT_SHORT_CLICKED, ctx);
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;
} }
lvgl_grid_navigation_add(chips_row);
ctx->chipsRow = chips_row;
auto* scan_list = lvgl_card_create(main_wrapper); auto* scan_list = lvgl_card_create(main_wrapper);
lv_obj_set_size(scan_list, LV_PCT(100), LV_SIZE_CONTENT); lv_obj_set_size(scan_list, LV_PCT(100), LV_SIZE_CONTENT);

View File

@ -1,5 +1,4 @@
#include <app/event.h> #include <app/event.h>
#include <app/manager.h>
#include <app/manifest.h> #include <app/manifest.h>
#include <app/scheduler.h> #include <app/scheduler.h>
#include <app/start.h> #include <app/start.h>
@ -8,19 +7,17 @@
#include <vector> #include <vector>
#include <lvgl.h> #include <lvgl.h>
#include <lvgl/icons/shared.h>
#include <lvgl/theme.h> #include <lvgl/theme.h>
#include <lvgl_window_manager/window_manager.h> #include <lvgl_window_manager/window_manager.h>
#include <tactility/check.h> #include <tactility/check.h>
#include <tactility/device.h>
#include <tactility/drivers/power_supply.h>
#include <tactility/log.h> #include <tactility/log.h>
#include <Tactility/app/TileGrid.h> #include <Tactility/app/TileGrid.h>
#include <Tactility/app/launcher/LauncherMode.h> #include <Tactility/app/launcher/LauncherMode.h>
#include <Tactility/app/setup/Setup.h> #include <Tactility/app/setup/Setup.h>
#include <Tactility/lvgl/SystemBars.h>
#include <Tactility/settings/BootSettings.h> #include <Tactility/settings/BootSettings.h>
#include <Tactility/Tactility.h> #include <Tactility/Tactility.h>
@ -51,6 +48,7 @@ Mode getOtherMode(Mode mode) {
struct Context { struct Context {
Mode mode = Mode::Apps; Mode mode = Mode::Apps;
lv_obj_t* modeButtonIcon = nullptr; lv_obj_t* modeButtonIcon = nullptr;
lvgl::SystemBarsListenerId systemBarsListener = 0;
TileGrid grid { TileGrid::Callbacks { TileGrid grid { TileGrid::Callbacks {
.collect = collectItems, .collect = collectItems,
.onClicked = onAppClicked, .onClicked = onAppClicked,
@ -110,28 +108,6 @@ void onModeButtonPressed(lv_event_t* e) {
lv_label_set_text(ctx->modeButtonIcon, getLauncherMode(getOtherMode(ctx->mode)).buttonIcon); 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)));
}
bool isAppRegistered(const char* appId) {
::AppManifest manifest;
return app_manager_find_manifest(appId, &manifest) == ERROR_NONE;
}
bool supportsPowerOff() {
bool supported = false;
device_for_each_of_type(&POWER_SUPPLY_TYPE, &supported, [](Device* device, void* context) {
if (device_is_ready(device) && power_supply_supports_power_off(device)) {
*static_cast<bool*>(context) = true;
return false; // stop iterating
} else {
return true; // continue iterating
}
});
return supported;
}
void createWidgets(lv_obj_t* parent, void* userData) { void createWidgets(lv_obj_t* parent, void* userData) {
auto* ctx = static_cast<Context*>(userData); auto* ctx = static_cast<Context*>(userData);
@ -143,9 +119,19 @@ void createWidgets(lv_obj_t* parent, void* userData) {
ctx->grid.createWidgetsWithBottomBar(parent); ctx->grid.createWidgetsWithBottomBar(parent);
lv_obj_t* modeButton = ctx->grid.addBarButton(getLauncherMode(getOtherMode(ctx->mode)).buttonIcon, onModeButtonPressed, ctx); lv_obj_t* modeButton = ctx->grid.addBarButton(getLauncherMode(getOtherMode(ctx->mode)).buttonIcon, onModeButtonPressed, ctx);
ctx->modeButtonIcon = lv_obj_get_child(modeButton, 0); 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")); // In the side layout, the bar's buttons are in the system bars strip
} ctx->grid.setBarSideContainer(lvgl::systemBarsGetActionContainer());
ctx->systemBarsListener = lvgl::systemBarsAddListener([ctx] {
ctx->grid.setBarSideContainer(lvgl::systemBarsGetActionContainer());
});
}
void destroyWidgets(void* userData) {
auto* ctx = static_cast<Context*>(userData);
lvgl::systemBarsRemoveListener(ctx->systemBarsListener);
ctx->systemBarsListener = 0;
ctx->grid.destroyWidgets();
} }
void runAutoStart() { void runAutoStart() {
@ -186,7 +172,7 @@ int32_t appMain(int argc, char* argv[]) {
check(app_event_subscribe(&sub, &event_group) == ERROR_NONE); check(app_event_subscribe(&sub, &event_group) == ERROR_NONE);
Context ctx; Context ctx;
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx); WindowId window = window_manager_create_ext(appInstanceId, createWidgets, destroyWidgets, &ctx);
runAutoStart(); runAutoStart();

View File

@ -5,6 +5,7 @@
#include <app/manager.h> #include <app/manager.h>
#include <app/manifest.h> #include <app/manifest.h>
#include <app/scheduler.h> #include <app/scheduler.h>
#include <app/start.h>
#include <lvgl_window_manager/window_manager.h> #include <lvgl_window_manager/window_manager.h>
@ -138,6 +139,19 @@ void onQuickChargeChanged(lv_event_t* event) {
} }
} }
bool isPowerOffAppRegistered() {
::AppManifest manifest;
return app_manager_find_manifest("tactility.poweroff", &manifest) == ERROR_NONE;
}
void onPowerOffPressed(lv_event_t* /*event*/) {
AppStartContext context;
uint32_t instance_id = 0;
if (app_start_context_from_id("tactility.poweroff", &context) == ERROR_NONE) {
app_start_with_context(&context, &instance_id);
}
}
void createWidgets(lv_obj_t* parent, void* userData) { void createWidgets(lv_obj_t* parent, void* userData) {
auto* ctx = static_cast<Context*>(userData); auto* ctx = static_cast<Context*>(userData);
@ -221,6 +235,26 @@ void createWidgets(lv_obj_t* parent, void* userData) {
entry.quickChargeSwitch = qc_switch; entry.quickChargeSwitch = qc_switch;
} }
if (power_supply_supports_power_off(device) && isPowerOffAppRegistered()) {
lv_obj_t* power_off_container = lv_obj_create(card);
lv_obj_set_width(power_off_container, LV_PCT(100));
lv_obj_set_height(power_off_container, LV_SIZE_CONTENT);
lv_obj_set_flex_flow(power_off_container, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(power_off_container, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_scrollable(power_off_container, false);
lv_obj_set_style_pad_all(power_off_container, 0, 0);
lv_obj_set_style_pad_gap(power_off_container, 0, 0);
lvgl::obj_set_style_bg_invisible(power_off_container);
lv_obj_t* label = lv_label_create(power_off_container);
lv_label_set_text(label, "Device power");
lv_obj_set_flex_grow(label, 1);
lv_obj_t* power_off_button = lv_button_create(power_off_container);
lv_label_set_text(lv_label_create(power_off_button), "Power off");
lv_obj_add_event_cb(power_off_button, onPowerOffPressed, LV_EVENT_SHORT_CLICKED, nullptr);
}
PowerSupplyPropertyValue value; PowerSupplyPropertyValue value;
for (auto property : DISPLAYED_PROPERTIES) { for (auto property : DISPLAYED_PROPERTIES) {
if (power_supply_get_property(device, property, &value) == ERROR_NONE) { if (power_supply_get_property(device, property, &value) == ERROR_NONE) {

View File

@ -120,10 +120,10 @@ int main(int argc, char* argv[]) {
return 0; return 0;
} }
#ifdef PLATFORM_ESP #ifdef ESP_PLATFORM
constexpr auto memory_sufficient = memory_external_total() > 0; const bool memory_sufficient = memory_external_total() > 0;
#else #else
constexpr auto memory_sufficient = true; const bool memory_sufficient = true;
#endif #endif
// The canvas holds a full frame, which needs external RAM on larger displays. // The canvas holds a full frame, which needs external RAM on larger displays.

View File

@ -10,6 +10,7 @@
#include <Tactility/TactilityConfig.h> #include <Tactility/TactilityConfig.h>
#include <Tactility/lvgl/KeyboardDeviceListener.h> #include <Tactility/lvgl/KeyboardDeviceListener.h>
#include <Tactility/lvgl/Statusbar.h> #include <Tactility/lvgl/Statusbar.h>
#include <Tactility/lvgl/SystemBars.h>
#include <Tactility/lvgl/TrackballInit.h> #include <Tactility/lvgl/TrackballInit.h>
#include <Tactility/lvgl/UsbHidInput.h> #include <Tactility/lvgl/UsbHidInput.h>
#include <Tactility/service/ServiceManifest.h> #include <Tactility/service/ServiceManifest.h>
@ -113,6 +114,9 @@ static lv_obj_t* windowManagerScreenInit(lv_obj_t* root) {
// focused textarea shows it (see lvgl_keyboard_add_textarea()/textarea_show_keyboard()). // focused textarea shows it (see lvgl_keyboard_add_textarea()/textarea_show_keyboard()).
lvgl_software_keyboard_construct(&softwareKeyboard, root); lvgl_software_keyboard_construct(&softwareKeyboard, root);
// Places the statusbar on top, or in a strip on the side
systemBarsAttach(vertical_container, statusbar, app_container);
return app_container; return app_container;
} }
@ -211,6 +215,10 @@ static void onLvglStopped() {
check(service::removeService(service::memorychecker::manifest.id)); check(service::removeService(service::memorychecker::manifest.id));
check(service::removeService(service::statusbar::manifest.id)); check(service::removeService(service::statusbar::manifest.id));
lvgl_lock();
systemBarsDetach();
lvgl_unlock();
if (softwareKeyboard.object != nullptr) { if (softwareKeyboard.object != nullptr) {
// lv_obj_delete() walks/mutates the object graph (event lists, group membership, // lv_obj_delete() walks/mutates the object graph (event lists, group membership,
// parent/child links). Without the LVGL lock this can race the LVGL port task's own // parent/child links). Without the LVGL lock this can race the LVGL port task's own

View File

@ -5,6 +5,7 @@
#include <Tactility/Timer.h> #include <Tactility/Timer.h>
#include <Tactility/lvgl/Statusbar.h> #include <Tactility/lvgl/Statusbar.h>
#include <Tactility/lvgl/Style.h> #include <Tactility/lvgl/Style.h>
#include <Tactility/lvgl/SystemMenu.h>
#include <Tactility/settings/Time.h> #include <Tactility/settings/Time.h>
#include <tactility/check.h> #include <tactility/check.h>
@ -12,14 +13,13 @@
#include <tactility/system_event.h> #include <tactility/system_event.h>
#include <tactility/time.h> #include <tactility/time.h>
#include <app/start.h>
#include <lvgl/devices/indev.h>
#include <lvgl/fonts.h> #include <lvgl/fonts.h>
#include <lvgl/grid_navigation.h>
#include <lvgl/insets.h> #include <lvgl/insets.h>
#include <lvgl/lvgl.h> #include <lvgl/lvgl.h>
#include <lvgl/theme.h> #include <lvgl/theme.h>
#include <lvgl/icons/shared.h>
#include <lvgl/widgets/focus_container.h>
#include <lvgl/widgets/icon_button.h>
#include <algorithm> #include <algorithm>
#include <memory> #include <memory>
@ -36,9 +36,10 @@ static void onUpdateTime();
struct StatusbarIcon { struct StatusbarIcon {
std::string image; std::string image;
std::string appId;
bool visible = false; bool visible = false;
bool claimed = false; bool claimed = false;
// When the icon was added: the side strip drops the newest icons that don't fit
uint32_t added_order = 0;
}; };
struct StatusbarData { struct StatusbarData {
@ -49,6 +50,7 @@ struct StatusbarData {
uint8_t time_hours = 0; uint8_t time_hours = 0;
uint8_t time_minutes = 0; uint8_t time_minutes = 0;
bool time_set = false; bool time_set = false;
uint32_t next_added_order = 0;
}; };
static StatusbarData statusbar_data; static StatusbarData statusbar_data;
@ -59,6 +61,15 @@ typedef struct {
lv_obj_t* icons[STATUSBAR_ICON_LIMIT]; lv_obj_t* icons[STATUSBAR_ICON_LIMIT];
lv_obj_t* battery_icon; lv_obj_t* battery_icon;
PubSub<void*>::SubscriptionHandle pubsub_subscription; PubSub<void*>::SubscriptionHandle pubsub_subscription;
// The parent of the icons, which opens the system menu
lv_obj_t* icon_row;
// Pushes the action container to the bottom in the side layout. Null on circular displays.
lv_obj_t* spacer;
// Holds the action buttons of apps in the side layout. Null on circular displays.
lv_obj_t* action_container;
// The horizontal padding of the split layout
int32_t split_pad_hor;
bool side;
} Statusbar; } Statusbar;
static void statusbar_constructor(const lv_obj_class_t* class_p, lv_obj_t* obj); static void statusbar_constructor(const lv_obj_class_t* class_p, lv_obj_t* obj);
@ -67,6 +78,8 @@ static void statusbar_event(const lv_obj_class_t* class_p, lv_event_t* event);
static void update_time(Statusbar* statusbar); static void update_time(Statusbar* statusbar);
static void update_main(Statusbar* statusbar); static void update_main(Statusbar* statusbar);
static void update_icons(Statusbar* statusbar);
static void onSideSizeChanged(lv_event_t* event);
static TickType_t getNextUpdateTime() { static TickType_t getNextUpdateTime() {
time_t now = ::time(nullptr); time_t now = ::time(nullptr);
@ -161,21 +174,49 @@ static void update_icon(lv_obj_t* image, const StatusbarIcon* icon) {
} else { } else {
lv_obj_set_hidden(image, true); lv_obj_set_hidden(image, true);
} }
lv_obj_set_clickable(image, !icon->appId.empty());
} }
static void onIconClicked(lv_event_t* event) { static void onIconRowClicked(lv_event_t* /*event*/) {
const auto index = reinterpret_cast<intptr_t>(lv_event_get_user_data(event)); systemMenuShow();
statusbar_data.mutex.lock(); }
const std::string app_id = statusbar_data.icons[index].appId;
statusbar_data.mutex.unlock(); static bool is_inside(lv_obj_t* obj, lv_obj_t* ancestor) {
if (app_id.empty()) { for (lv_obj_t* current = obj; current != nullptr; current = lv_obj_get_parent(current)) {
if (current == ancestor) {
return true;
}
}
return false;
}
// In the side strip on the left, the right key moves the focus to the first widget outside of the strip
static void onIconRowKey(lv_event_t* event) {
auto* statusbar = static_cast<Statusbar*>(lv_event_get_user_data(event));
if (!statusbar->side) {
return; return;
} }
AppStartContext context; if (lv_event_get_key(event) != LV_KEY_RIGHT) {
uint32_t instance_id = 0; return;
if (app_start_context_from_id(app_id.c_str(), &context) == ERROR_NONE) { }
app_start_with_context(&context, &instance_id); lv_group_t* group = lv_obj_get_group(statusbar->icon_row);
if (group == nullptr) {
return;
}
const uint32_t count = lv_group_get_obj_count(group);
for (uint32_t i = 0; i < count; i++) {
lv_group_focus_next(group);
if (!is_inside(lv_group_get_focused(group), &statusbar->obj)) {
return;
}
}
}
static void onStatusbarGesture(lv_event_t* /*event*/) {
lv_indev_t* indev = lv_indev_active();
if (indev != nullptr && lv_indev_get_gesture_dir(indev) == LV_DIR_BOTTOM) {
// The release must not click the pressed widget
lv_indev_wait_release(indev);
systemMenuShow();
} }
} }
@ -198,7 +239,7 @@ lv_obj_t* statusbar_create(lv_obj_t* parent) {
DisplayShape shape; DisplayShape shape;
lvgl_display_get_shape(display, &shape); lvgl_display_get_shape(display, &shape);
// The parent of the icons, which grid navigation moves through // The parent of the icons, which opens the system menu
lv_obj_t* icon_row; lv_obj_t* icon_row;
if (shape.shape == DISPLAY_SHAPE_CIRCLE) { if (shape.shape == DISPLAY_SHAPE_CIRCLE) {
// Two centered rows, as the top of a circle is narrow: the time, and the icons below it // Two centered rows, as the top of a circle is narrow: the time, and the icons below it
@ -211,9 +252,7 @@ lv_obj_t* statusbar_create(lv_obj_t* parent) {
statusbar->time = lv_label_create(obj); statusbar->time = lv_label_create(obj);
update_time(statusbar); update_time(statusbar);
icon_row = lv_obj_create(obj); icon_row = lvgl_focus_container_create(obj);
lv_obj_remove_style_all(icon_row);
lv_obj_set_size(icon_row, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
lv_obj_set_style_pad_ver(icon_row, static_cast<int32_t>(icon_size / 10), LV_STATE_DEFAULT); lv_obj_set_style_pad_ver(icon_row, static_cast<int32_t>(icon_size / 10), LV_STATE_DEFAULT);
lv_obj_set_style_pad_column(icon_row, icon_padding, LV_STATE_DEFAULT); lv_obj_set_style_pad_column(icon_row, icon_padding, LV_STATE_DEFAULT);
lv_obj_set_flex_flow(icon_row, LV_FLEX_FLOW_ROW); lv_obj_set_flex_flow(icon_row, LV_FLEX_FLOW_ROW);
@ -240,14 +279,39 @@ lv_obj_t* statusbar_create(lv_obj_t* parent) {
lv_obj_set_flex_grow(left_spacer, 1); lv_obj_set_flex_grow(left_spacer, 1);
lv_obj_set_style_bg_opa(left_spacer, LV_OPA_0, LV_STATE_DEFAULT); lv_obj_set_style_bg_opa(left_spacer, LV_OPA_0, LV_STATE_DEFAULT);
lv_obj_set_style_border_opa(left_spacer, LV_OPA_0, LV_STATE_DEFAULT); lv_obj_set_style_border_opa(left_spacer, LV_OPA_0, LV_STATE_DEFAULT);
statusbar->spacer = left_spacer;
icon_row = obj; icon_row = lvgl_focus_container_create(obj);
lv_obj_set_style_pad_column(icon_row, icon_padding, LV_STATE_DEFAULT);
lv_obj_set_style_pad_row(icon_row, icon_padding, LV_STATE_DEFAULT);
lv_obj_set_style_margin_right(icon_row, 2, LV_STATE_DEFAULT);
lv_obj_set_flex_flow(icon_row, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(icon_row, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
auto* action_container = lv_obj_create(obj);
lv_obj_remove_style_all(action_container);
lv_obj_set_size(action_container, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
lv_obj_set_flex_flow(action_container, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(action_container, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_scrollable(action_container, false);
lv_obj_set_hidden(action_container, true);
statusbar->action_container = action_container;
statusbar->split_pad_hor = lv_obj_get_style_pad_left(obj, LV_PART_MAIN);
}
statusbar->icon_row = icon_row;
lv_obj_add_flag(icon_row, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_event_cb(icon_row, onIconRowClicked, LV_EVENT_SHORT_CLICKED, nullptr);
lv_obj_add_event_cb(icon_row, onIconRowKey, LV_EVENT_KEY, statusbar);
// Swiping down from anywhere on the statusbar opens the system menu
lv_obj_set_gesture_bubble(obj, false);
lv_obj_add_event_cb(obj, onStatusbarGesture, LV_EVENT_GESTURE, nullptr);
if (statusbar->action_container != nullptr) {
lv_obj_add_event_cb(obj, onSideSizeChanged, LV_EVENT_SIZE_CHANGED, statusbar);
lv_obj_add_event_cb(statusbar->action_container, onSideSizeChanged, LV_EVENT_SIZE_CHANGED, statusbar);
} }
// 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 = !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); statusbar_data.mutex.lock(MAX_TICKS);
for (int i = 0; i < STATUSBAR_ICON_LIMIT; ++i) { for (int i = 0; i < STATUSBAR_ICON_LIMIT; ++i) {
@ -255,12 +319,6 @@ lv_obj_t* statusbar_create(lv_obj_t* parent) {
lv_obj_set_size(image, icon_size, icon_size); // regular padding doesn't work lv_obj_set_size(image, icon_size, icon_size); // regular padding doesn't work
lv_obj_set_style_text_font(image, lvgl_get_statusbar_icon_font(), LV_STATE_DEFAULT); lv_obj_set_style_text_font(image, lvgl_get_statusbar_icon_font(), LV_STATE_DEFAULT);
lv_obj_set_style_pad_all(image, 0, LV_STATE_DEFAULT); lv_obj_set_style_pad_all(image, 0, LV_STATE_DEFAULT);
if (own_focus_ring) {
lv_obj_set_style_outline_color(image, focus_color, LV_STATE_FOCUS_KEY);
lv_obj_set_style_outline_width(image, focus_width, LV_STATE_FOCUS_KEY);
lv_obj_set_style_outline_pad(image, -focus_width, LV_STATE_FOCUS_KEY);
}
lv_obj_add_event_cb(image, onIconClicked, LV_EVENT_SHORT_CLICKED, reinterpret_cast<void*>(static_cast<intptr_t>(i)));
statusbar->icons[i] = image; statusbar->icons[i] = image;
update_icon(image, &(statusbar_data.icons[i])); update_icon(image, &(statusbar_data.icons[i]));
@ -275,12 +333,8 @@ lv_obj_t* statusbar_create(lv_obj_t* parent) {
}); });
statusbar_data.mutex.unlock(); statusbar_data.mutex.unlock();
// Without a touchscreen, the icons are selected with the keys // Only the icon row is focused, it joins the group by itself
if (lvgl_indev_exists(LV_INDEV_TYPE_KEYPAD)) { if (lv_obj_get_group(obj) != nullptr) {
lvgl_grid_navigation_add(icon_row);
}
// Only the icon row is focused when it isn't the statusbar itself
if (icon_row != obj && lv_obj_get_group(obj) != nullptr) {
lv_group_remove_obj(obj); lv_group_remove_obj(obj);
} }
@ -297,17 +351,61 @@ static void update_time(Statusbar* statusbar) {
} }
} }
static void update_main(Statusbar* statusbar) { // In the side strip, the time and the action buttons always show. The icons get the height that's left, and the newest ones are hidden when they don't fit.
update_time(statusbar); // Call while holding statusbar_data.mutex, after update_icon() set the icons' visibility.
static void apply_side_icon_limit(Statusbar* statusbar) {
if (!statusbar->side) {
return;
}
lv_obj_t* obj = &statusbar->obj;
const int32_t gap = lv_obj_get_style_pad_row(obj, LV_PART_MAIN);
// The time, the icons, the spacer and the actions are separated by 3 gaps, and the spacer is at least 1 pixel high
const int32_t taken = lv_obj_get_height(statusbar->time) + lv_obj_get_style_margin_bottom(statusbar->time, LV_PART_MAIN) +
lv_obj_get_height(statusbar->action_container) + 3 * gap + 1;
const int32_t available = lv_obj_get_content_height(obj) - taken;
const int32_t icon_size = static_cast<int32_t>(lvgl_get_statusbar_icon_font_height());
const int32_t icon_gap = lv_obj_get_style_pad_row(statusbar->icon_row, LV_PART_MAIN);
const int32_t fitting = available >= icon_size ? (available + icon_gap) / (icon_size + icon_gap) : 0;
// The shown icons, oldest first
int shown[STATUSBAR_ICON_LIMIT];
int shown_count = 0;
for (int i = 0; i < STATUSBAR_ICON_LIMIT; ++i) {
if (!lv_obj_is_hidden(statusbar->icons[i])) {
shown[shown_count++] = i;
}
}
std::sort(shown, shown + shown_count, [](int a, int b) {
return statusbar_data.icons[a].added_order < statusbar_data.icons[b].added_order;
});
for (int i = static_cast<int>(fitting); i < shown_count; ++i) {
lv_obj_set_hidden(statusbar->icons[shown[i]], true);
}
}
static void update_icons(Statusbar* statusbar) {
if (statusbar_data.mutex.lock(200 / portTICK_PERIOD_MS)) { if (statusbar_data.mutex.lock(200 / portTICK_PERIOD_MS)) {
for (int i = 0; i < STATUSBAR_ICON_LIMIT; ++i) { for (int i = 0; i < STATUSBAR_ICON_LIMIT; ++i) {
update_icon(statusbar->icons[i], &(statusbar_data.icons[i])); update_icon(statusbar->icons[i], &(statusbar_data.icons[i]));
} }
apply_side_icon_limit(statusbar);
statusbar_data.mutex.unlock(); statusbar_data.mutex.unlock();
} }
} }
// The room for the icons changes with the strip's height and the actions that apps add
static void onSideSizeChanged(lv_event_t* event) {
auto* statusbar = static_cast<Statusbar*>(lv_event_get_user_data(event));
if (statusbar->side) {
update_icons(statusbar);
}
}
static void update_main(Statusbar* statusbar) {
update_time(statusbar);
update_icons(statusbar);
}
static void statusbar_event(const lv_obj_class_t* class_p, lv_event_t* event) { static void statusbar_event(const lv_obj_class_t* class_p, lv_event_t* event) {
// Call the ancestor's event handler // Call the ancestor's event handler
lv_result_t result = lv_obj_event_base(&statusbar_class, event); lv_result_t result = lv_obj_event_base(&statusbar_class, event);
@ -331,6 +429,7 @@ int8_t statusbar_icon_add(const std::string& image, bool visible) {
statusbar_data.icons[i].claimed = true; statusbar_data.icons[i].claimed = true;
statusbar_data.icons[i].visible = visible; statusbar_data.icons[i].visible = visible;
statusbar_data.icons[i].image = image; statusbar_data.icons[i].image = image;
statusbar_data.icons[i].added_order = statusbar_data.next_added_order++;
result = i; result = i;
LOG_D(TAG, "id %d: added", (int)i); LOG_D(TAG, "id %d: added", (int)i);
break; break;
@ -353,7 +452,6 @@ void statusbar_icon_remove(int8_t id) {
icon->claimed = false; icon->claimed = false;
icon->visible = false; icon->visible = false;
icon->image = ""; icon->image = "";
icon->appId = "";
statusbar_data.mutex.unlock(); statusbar_data.mutex.unlock();
statusbar_data.pubsub->publish(nullptr); statusbar_data.pubsub->publish(nullptr);
} }
@ -384,20 +482,121 @@ void statusbar_icon_set_visibility(int8_t id, bool visible) {
statusbar_data.pubsub->publish(nullptr); statusbar_data.pubsub->publish(nullptr);
} }
void statusbar_icon_set_app(int8_t id, const std::string& appId) { // The colour that is visible behind the object: its own background or the first visible one of its parents
check(id >= 0 && id < STATUSBAR_ICON_LIMIT); static lv_color_t get_visible_bg_color(lv_obj_t* obj) {
statusbar_data.mutex.lock(); for (lv_obj_t* current = obj; current != nullptr; current = lv_obj_get_parent(current)) {
StatusbarIcon* icon = &statusbar_data.icons[id]; if (lv_obj_get_style_bg_opa(current, LV_PART_MAIN) >= LV_OPA_50) {
check(icon->claimed); return lv_obj_get_style_bg_color(current, LV_PART_MAIN);
icon->appId = appId; }
statusbar_data.mutex.unlock(); }
statusbar_data.pubsub->publish(nullptr); return lv_obj_get_style_bg_color(obj, LV_PART_MAIN);
}
// The width of an app's action button in the side strip, measured with a temporary button like the ones apps add
static int32_t get_action_button_width(lv_obj_t* actionContainer) {
auto* button = lvgl_icon_button_create(actionContainer);
// It must not take the focus
if (lv_obj_get_group(button) != nullptr) {
lv_group_remove_obj(button);
}
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, LVGL_ICON_SHARED_SETTINGS);
lv_obj_update_layout(button);
const int32_t width = lv_obj_get_width(button);
lv_obj_delete(button);
return width;
}
// The width of the widest time that the label can show, as the font's digits can differ in width
static int32_t get_widest_time_width(const lv_font_t* font, int32_t letterSpace) {
char widest_digit = '0';
int32_t widest_digit_width = 0;
for (char digit = '0'; digit <= '9'; digit++) {
const char text[2] = { digit, '\0' };
lv_point_t size;
lv_text_get_size(&size, text, font, 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE);
if (size.x > widest_digit_width) {
widest_digit_width = size.x;
widest_digit = digit;
}
}
const char text[6] = { widest_digit, widest_digit, ':', widest_digit, widest_digit, '\0' };
lv_point_t size;
lv_text_get_size(&size, text, font, letterSpace, 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE);
return size.x;
}
void statusbar_set_side(lv_obj_t* obj, bool side) {
auto* statusbar = reinterpret_cast<Statusbar*>(obj);
// Circular displays only have room for the statusbar at the top
if (statusbar->action_container == nullptr || statusbar->side == side) {
return;
}
statusbar->side = side;
lv_obj_remove_local_style_prop(obj, LV_STYLE_BG_COLOR, LV_PART_MAIN);
lv_obj_remove_local_style_prop(obj, LV_STYLE_BG_OPA, LV_PART_MAIN);
if (side) {
lv_obj_set_flex_flow(obj, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_hor(obj, 2, LV_STATE_DEFAULT);
// A fixed width, so the strip doesn't change when an app adds or removes action buttons
lv_obj_set_hidden(statusbar->action_container, false);
const int32_t time_width = get_widest_time_width(lvgl_get_text_font(FONT_SIZE_SMALL), lv_obj_get_style_text_letter_space(statusbar->time, LV_PART_MAIN));
const int32_t content_width = std::max(get_action_button_width(statusbar->action_container), time_width);
lv_obj_set_size(obj, content_width + 2 * 2, LV_PCT(100));
// Keeps the action buttons away from the screen's bottom edge. The compact layout has no room to spare.
const bool compact = lvgl_theme_is_compact();
lv_obj_set_style_pad_top(obj, compact ? 0 : 4, LV_STATE_DEFAULT);
lv_obj_set_style_pad_bottom(obj, compact ? 0 : 8, LV_STATE_DEFAULT);
// Slightly darker than the colour behind it, so the strip stands out from the app next to it
const lv_color_t background = get_visible_bg_color(obj);
lv_obj_set_style_bg_color(obj, lv_color_darken(background, LV_OPA_20), LV_STATE_DEFAULT);
lv_obj_set_style_bg_opa(obj, LV_OPA_COVER, LV_STATE_DEFAULT);
lv_obj_set_style_margin_left(statusbar->time, 0, LV_STATE_DEFAULT);
// The small font fits the time in the narrow strip
lv_obj_set_style_text_font(statusbar->time, lvgl_get_text_font(FONT_SIZE_SMALL), LV_STATE_DEFAULT);
lv_obj_set_style_margin_bottom(statusbar->time, 10, LV_STATE_DEFAULT);
// As wide as the strip, so the column of items is centered on the strip's full width
lv_obj_set_width(statusbar->time, LV_PCT(100));
lv_obj_set_style_text_align(statusbar->time, LV_TEXT_ALIGN_CENTER, LV_STATE_DEFAULT);
// The time stays on one line, also when it's wider than expected
lv_label_set_long_mode(statusbar->time, LV_LABEL_LONG_MODE_CLIP);
lv_obj_set_style_margin_right(statusbar->icon_row, 0, LV_STATE_DEFAULT);
// The icon that's last in the top bar is closest to the screen's corner there, so it goes on top
lv_obj_set_flex_flow(statusbar->icon_row, LV_FLEX_FLOW_COLUMN_REVERSE);
// The time, the icons, the spacer and then the actions
lv_obj_move_to_index(statusbar->icon_row, 1);
lv_obj_set_hidden(statusbar->action_container, false);
} else {
lv_obj_set_size(obj, LV_PCT(100), statusbar_get_height());
lv_obj_set_flex_flow(obj, LV_FLEX_FLOW_ROW);
lv_obj_set_style_pad_hor(obj, statusbar->split_pad_hor, LV_STATE_DEFAULT);
lv_obj_set_style_pad_ver(obj, 0, LV_STATE_DEFAULT);
lv_obj_set_style_margin_left(statusbar->time, 4, LV_STATE_DEFAULT);
lv_obj_remove_local_style_prop(statusbar->time, LV_STYLE_TEXT_FONT, LV_PART_MAIN);
lv_obj_remove_local_style_prop(statusbar->time, LV_STYLE_MARGIN_BOTTOM, LV_PART_MAIN);
lv_obj_set_width(statusbar->time, LV_SIZE_CONTENT);
lv_obj_remove_local_style_prop(statusbar->time, LV_STYLE_TEXT_ALIGN, LV_PART_MAIN);
lv_label_set_long_mode(statusbar->time, LV_LABEL_LONG_MODE_WRAP);
lv_obj_set_style_margin_right(statusbar->icon_row, 2, LV_STATE_DEFAULT);
lv_obj_set_flex_flow(statusbar->icon_row, LV_FLEX_FLOW_ROW);
// The time, the spacer and then the icons
lv_obj_move_to_index(statusbar->icon_row, 2);
lv_obj_set_hidden(statusbar->action_container, true);
}
// The split layout shows all icons, the side strip only the ones that fit
update_icons(statusbar);
}
lv_obj_t* statusbar_get_action_container(lv_obj_t* obj) {
return reinterpret_cast<Statusbar*>(obj)->action_container;
} }
int statusbar_get_height() { int statusbar_get_height() {
const auto icon_size = lvgl_get_statusbar_icon_font_height(); const auto icon_size = lvgl_get_statusbar_icon_font_height();
const auto vertical_padding = static_cast<uint32_t>((static_cast<float>(icon_size) * 0.1f)); const auto vertical_padding = static_cast<uint32_t>((static_cast<float>(icon_size) * 0.1f));
return icon_size + (2 * vertical_padding); return icon_size + (2 * vertical_padding) + 4;
} }
} // namespace } // namespace

View File

@ -0,0 +1,194 @@
#include <Tactility/lvgl/SystemBars.h>
#include <Tactility/lvgl/Statusbar.h>
#include <Tactility/lvgl/SystemMenu.h>
#include <lvgl_window_manager/window_manager.h>
#include <lvgl/theme.h>
#include <algorithm>
#include <utility>
#include <vector>
namespace tt::lvgl {
using settings::launcher::SystemBarsLayout;
namespace {
struct Listener {
SystemBarsListenerId id;
std::function<void()> callback;
};
// All state is guarded by the LVGL lock
struct SystemBarsState {
lv_obj_t* container = nullptr;
lv_obj_t* statusbar = nullptr;
lv_obj_t* appContainer = nullptr;
lv_display_t* display = nullptr;
SystemBarsLayout layout = SystemBarsLayout::Split;
bool applied = false;
// Where the current press of a pointer started, for swipes from the top edge
lv_point_t pressPoint = { 0, 0 };
SystemBarsListenerId nextListenerId = 1;
std::vector<Listener> listeners;
};
SystemBarsState state;
void onResolutionChanged(lv_event_t* /*event*/) {
systemBarsRefresh();
}
// The target of a pointer device's own events is the device
lv_indev_t* getEventIndev(lv_event_t* event) {
return static_cast<lv_indev_t*>(lv_event_get_current_target(event));
}
void onPointerPressed(lv_event_t* event) {
lv_indev_get_point(getEventIndev(event), &state.pressPoint);
}
// In the side layout, the top edge has no statusbar to swipe down from, so a swipe down that starts there opens the system menu
void onPointerReleased(lv_event_t* event) {
if (state.layout != SystemBarsLayout::Side || window_manager_get_active_layer() != WINDOW_LAYER_APP) {
return;
}
lv_indev_t* indev = getEventIndev(event);
lv_point_t point;
lv_indev_get_point(indev, &point);
const int32_t edge = statusbar_get_height();
const int32_t dy = point.y - state.pressPoint.y;
const int32_t dx = point.x - state.pressPoint.x;
if (state.pressPoint.y <= edge && dy >= edge * 2 && dy > 2 * LV_ABS(dx)) {
// The release must not click the widget below the pointer
lv_indev_reset(indev, nullptr);
systemMenuShow();
}
}
void forEachPointer(void (*action)(lv_indev_t* indev)) {
for (lv_indev_t* indev = lv_indev_get_next(nullptr); indev != nullptr; indev = lv_indev_get_next(indev)) {
if (lv_indev_get_type(indev) == LV_INDEV_TYPE_POINTER) {
action(indev);
}
}
}
void removePointerCallbacks(lv_indev_t* indev) {
lv_indev_remove_event_cb_with_user_data(indev, onPointerPressed, nullptr);
lv_indev_remove_event_cb_with_user_data(indev, onPointerReleased, nullptr);
}
void addPointerCallbacks(lv_indev_t* indev) {
removePointerCallbacks(indev);
lv_indev_add_event_cb(indev, onPointerPressed, LV_EVENT_PRESSED, nullptr);
lv_indev_add_event_cb(indev, onPointerReleased, LV_EVENT_RELEASED, nullptr);
}
// The monochrome theme has no tints, so a line separates the strip from the app
void applySeparator(bool side) {
lv_obj_remove_local_style_prop(state.statusbar, LV_STYLE_BORDER_SIDE, LV_PART_MAIN);
lv_obj_remove_local_style_prop(state.statusbar, LV_STYLE_BORDER_WIDTH, LV_PART_MAIN);
lv_obj_remove_local_style_prop(state.statusbar, LV_STYLE_BORDER_COLOR, LV_PART_MAIN);
lv_obj_remove_local_style_prop(state.statusbar, LV_STYLE_BORDER_OPA, LV_PART_MAIN);
if (side && lvgl_theme_is_mono()) {
lv_obj_set_style_border_side(state.statusbar, LV_BORDER_SIDE_RIGHT, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(state.statusbar, 1, LV_STATE_DEFAULT);
lv_obj_set_style_border_color(state.statusbar, lv_obj_get_style_text_color(state.statusbar, LV_PART_MAIN), LV_STATE_DEFAULT);
lv_obj_set_style_border_opa(state.statusbar, LV_OPA_COVER, LV_STATE_DEFAULT);
}
}
// The statusbar is the container's first child: on top in the split layout, and on the left in the side layout
void applyLayout(SystemBarsLayout layout) {
const bool side = layout == SystemBarsLayout::Side;
statusbar_set_side(state.statusbar, side);
applySeparator(side);
if (side) {
lv_obj_set_flex_flow(state.container, LV_FLEX_FLOW_ROW);
lv_obj_set_size(state.appContainer, LV_SIZE_CONTENT, LV_PCT(100));
} else {
lv_obj_set_flex_flow(state.container, LV_FLEX_FLOW_COLUMN);
lv_obj_set_size(state.appContainer, LV_PCT(100), LV_SIZE_CONTENT);
}
lv_obj_set_flex_grow(state.appContainer, 1);
}
}
void systemBarsAttach(lv_obj_t* container, lv_obj_t* statusbar, lv_obj_t* appContainer) {
systemBarsDetach();
state.container = container;
state.statusbar = statusbar;
state.appContainer = appContainer;
state.display = lv_obj_get_display(container);
state.applied = false;
lv_display_add_event_cb(state.display, onResolutionChanged, LV_EVENT_RESOLUTION_CHANGED, nullptr);
systemBarsRefresh();
}
void systemBarsDetach() {
if (state.display != nullptr) {
lv_display_remove_event_cb_with_user_data(state.display, onResolutionChanged, nullptr);
forEachPointer(removePointerCallbacks);
}
state.container = nullptr;
state.statusbar = nullptr;
state.appContainer = nullptr;
state.display = nullptr;
state.layout = SystemBarsLayout::Split;
}
void systemBarsRefresh() {
if (state.container == nullptr) {
return;
}
// Also picks up pointer devices that were added since the last refresh
forEachPointer(addPointerCallbacks);
const auto settings = settings::launcher::loadOrGetDefault();
const auto width = lv_display_get_horizontal_resolution(state.display);
const auto height = lv_display_get_vertical_resolution(state.display);
auto layout = settings::launcher::resolveSystemBarsLayout(settings, settings::launcher::getSystemBarsCapabilities(), width, height);
// Circular displays only have room for the statusbar at the top
if (statusbar_get_action_container(state.statusbar) == nullptr) {
layout = SystemBarsLayout::Split;
}
if (state.applied && layout == state.layout) {
return;
}
applyLayout(layout);
state.layout = layout;
state.applied = true;
// A copy, as a listener may remove itself
const auto listeners = state.listeners;
for (const auto& listener : listeners) {
listener.callback();
}
}
SystemBarsLayout systemBarsGetLayout() {
return state.layout;
}
lv_obj_t* systemBarsGetActionContainer() {
if (state.statusbar == nullptr || state.layout != SystemBarsLayout::Side) {
return nullptr;
}
return statusbar_get_action_container(state.statusbar);
}
SystemBarsListenerId systemBarsAddListener(std::function<void()> listener) {
const auto id = state.nextListenerId++;
state.listeners.push_back({ id, std::move(listener) });
return id;
}
void systemBarsRemoveListener(SystemBarsListenerId id) {
std::erase_if(state.listeners, [id](const Listener& listener) { return listener.id == id; });
}
}

View File

@ -0,0 +1,284 @@
#include <Tactility/lvgl/SystemMenu.h>
#include <Tactility/TactilityPrivate.h>
#include <Tactility/bluetooth/Bluetooth.h>
#include <app/manager.h>
#include <app/start.h>
#include <gps/gps.h>
#include <lvgl/devices/indev.h>
#include <lvgl/fonts.h>
#include <lvgl/grid_navigation.h>
#include <lvgl/icons/shared.h>
#include <lvgl/lvgl.h>
#include <lvgl/theme.h>
#include <lvgl/widgets/card.h>
#include <lvgl/widgets/icon_button.h>
#include <lvgl_window_manager/window_manager.h>
#include <tactility/concurrent/dispatcher.h>
#include <tactility/device.h>
#include <tactility/drivers/bluetooth.h>
#include <tactility/drivers/power_supply.h>
#include <tactility/drivers/wifi.h>
#include <tactility/log.h>
#include <cstdint>
#ifdef ESP_PLATFORM
#include <sdkconfig.h>
#endif
namespace tt::lvgl {
constexpr auto* TAG = "SystemMenu";
constexpr auto* POWER_OFF_APP_ID = "tactility.poweroff";
static void* toContext(bool enabled) {
return reinterpret_cast<void*>(static_cast<intptr_t>(enabled));
}
static bool fromContext(void* context) {
return reinterpret_cast<intptr_t>(context) != 0;
}
static bool hasWifi() {
return device_exists_of_type(&WIFI_TYPE);
}
static bool hasGps() {
return device_exists_of_type(&GPS_TYPE);
}
static bool hasBluetooth() {
#if defined(CONFIG_BT_NIMBLE_ENABLED)
return device_exists_of_type(&BLUETOOTH_TYPE);
#else
return false;
#endif
}
static bool isWifiEnabled() {
Device* device = nullptr;
if (device_get_first_by_type(&WIFI_TYPE, &device) != ERROR_NONE) {
return false;
}
WifiRadioState state = WIFI_RADIO_STATE_OFF;
wifi_get_radio_state(device, &state);
device_put(device);
return state == WIFI_RADIO_STATE_ON || state == WIFI_RADIO_STATE_ON_PENDING;
}
static void setWifiEnabled(void* context) {
Device* device = nullptr;
if (device_get_first_by_type(&WIFI_TYPE, &device) != ERROR_NONE) {
return;
}
error_t result = fromContext(context) ? wifi_set_radio_on(device) : wifi_set_radio_off(device);
if (result != ERROR_NONE) {
LOG_E(TAG, "Failed to set Wi-Fi radio state (%s)", error_to_string(result));
}
device_put(device);
}
static bool isGpsEnabled() {
bool enabled = false;
device_for_each_of_type(&GPS_TYPE, &enabled, [](Device* device, void* context) {
if (device_is_ready(device)) {
*static_cast<bool*>(context) = true;
return false;
}
return true;
});
return enabled;
}
static void setGpsEnabled(void* context) {
device_for_each_of_type(&GPS_TYPE, context, [](Device* device, void* context) {
const bool enabled = fromContext(context);
if (enabled && !device_is_ready(device)) {
device_start(device);
} else if (!enabled && device_is_ready(device)) {
device_stop(device);
}
return true;
});
}
#if defined(CONFIG_BT_NIMBLE_ENABLED)
static bool isBluetoothEnabled() {
Device* device = nullptr;
if (device_get_first_by_type(&BLUETOOTH_TYPE, &device) != ERROR_NONE) {
return false;
}
bool enabled = bluetooth::isRadioOnOrPending(device);
device_put(device);
return enabled;
}
static void setBluetoothEnabled(void* context) {
Device* device = nullptr;
if (device_get_first_by_type(&BLUETOOTH_TYPE, &device) != ERROR_NONE) {
return;
}
const bool enabled = fromContext(context);
if (enabled != bluetooth::isRadioOnOrPending(device)) {
if (enabled) {
bluetooth::start(device);
} else {
bluetooth::stop(device);
}
}
device_put(device);
}
#endif
// Changing the radio and device states can block, so it happens on the main task
struct ToggleOption {
const char* enabledIcon;
const char* disabledIcon;
bool (*isEnabled)();
DispatcherCallback setEnabled;
};
static const char* getToggleIcon(const ToggleOption* option, bool enabled) {
return enabled ? option->enabledIcon : option->disabledIcon;
}
static void onToggleChanged(lv_event_t* event) {
auto* toggle = lv_event_get_target_obj(event);
const auto* option = static_cast<const ToggleOption*>(lv_event_get_user_data(event));
const bool enabled = lv_obj_has_state(toggle, LV_STATE_CHECKED);
lv_label_set_text(lv_obj_get_child(toggle, 0), getToggleIcon(option, enabled));
dispatcher_dispatch_timed(getMainDispatcherHandle(), toContext(enabled), option->setEnabled, portMAX_DELAY);
}
static void onCloseClicked(lv_event_t* /*event*/) {
window_manager_overlay_hide();
}
static void onBackdropGesture(lv_event_t* /*event*/) {
lv_indev_t* indev = lv_indev_active();
if (indev != nullptr && lv_indev_get_gesture_dir(indev) == LV_DIR_TOP) {
// The release must not click the pressed widget, e.g. a toggle
lv_indev_wait_release(indev);
window_manager_overlay_hide();
}
}
static bool supportsPowerOff() {
::AppManifest manifest;
if (app_manager_find_manifest(POWER_OFF_APP_ID, &manifest) != ERROR_NONE) {
return false;
}
bool supported = false;
device_for_each_of_type(&POWER_SUPPLY_TYPE, &supported, [](Device* device, void* context) {
if (device_is_ready(device) && power_supply_supports_power_off(device)) {
*static_cast<bool*>(context) = true;
return false;
}
return true;
});
return supported;
}
static void onPowerOffClicked(lv_event_t* /*event*/) {
// The app's window is created in the app layer, which the overlay would cover
window_manager_overlay_hide();
AppStartContext context;
uint32_t instance_id = 0;
if (app_start_context_from_id(POWER_OFF_APP_ID, &context) == ERROR_NONE) {
app_start_with_context(&context, &instance_id);
}
}
// A square icon button, sized for the menu's tiles
static lv_obj_t* createTile(lv_obj_t* parent, const char* icon) {
const auto icon_height = static_cast<int32_t>(lvgl_get_shared_icon_default_font_height());
auto* tile = lvgl_icon_button_create(parent);
lv_obj_set_size(tile, icon_height * 2, icon_height * 2);
auto* icon_label = lv_label_create(tile);
lv_obj_set_style_text_font(icon_label, lvgl_get_shared_icon_default_font(), LV_STATE_DEFAULT);
lv_label_set_text(icon_label, icon);
lv_obj_center(icon_label);
return tile;
}
// A tile that the theme shows as on while it's checked
static void createOption(lv_obj_t* parent, const ToggleOption* option) {
const bool enabled = option->isEnabled();
auto* toggle = createTile(parent, getToggleIcon(option, enabled));
lv_obj_add_flag(toggle, LV_OBJ_FLAG_CHECKABLE);
lv_obj_set_state(toggle, LV_STATE_CHECKED, enabled);
lv_obj_add_event_cb(toggle, onToggleChanged, LV_EVENT_VALUE_CHANGED, const_cast<ToggleOption*>(option));
}
static void createWidgets(lv_obj_t* parent, void* /*userData*/) {
// Swiping up anywhere closes the menu
lv_obj_set_gesture_bubble(parent, false);
lv_obj_add_event_cb(parent, onBackdropGesture, LV_EVENT_GESTURE, nullptr);
// The card fits its content, up to most of the screen
auto* card = lvgl_card_create(parent);
lv_obj_set_size(card, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
lv_obj_set_style_max_height(card, LV_PCT(90), LV_STATE_DEFAULT);
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_center(card);
auto* grid = lv_obj_create(card);
lv_obj_remove_style_all(grid);
lv_obj_set_size(grid, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
// A percentage can't limit the width, as the card's width depends on the grid. The tiles wrap at the limit.
const int32_t max_card_width = lv_display_get_horizontal_resolution(lv_obj_get_display(parent)) * 9 / 10;
const int32_t card_padding = lv_obj_get_style_pad_left(card, LV_PART_MAIN) + lv_obj_get_style_pad_right(card, LV_PART_MAIN);
lv_obj_set_style_max_width(grid, max_card_width - card_padding, LV_STATE_DEFAULT);
lv_obj_set_flex_flow(grid, LV_FLEX_FLOW_ROW_WRAP);
lv_obj_set_flex_align(grid, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
const int32_t gap = lvgl_theme_is_compact() ? 8 : 16;
lv_obj_set_style_pad_column(grid, gap, LV_STATE_DEFAULT);
lv_obj_set_style_pad_row(grid, gap, LV_STATE_DEFAULT);
static constexpr ToggleOption wifi_option = { LVGL_ICON_SHARED_WIFI, LVGL_ICON_SHARED_WIFI_OFF, isWifiEnabled, setWifiEnabled };
if (hasWifi()) {
createOption(grid, &wifi_option);
}
static constexpr ToggleOption gps_option = { LVGL_ICON_SHARED_LOCATION_ON, LVGL_ICON_SHARED_LOCATION_OFF, isGpsEnabled, setGpsEnabled };
if (hasGps()) {
createOption(grid, &gps_option);
}
#if defined(CONFIG_BT_NIMBLE_ENABLED)
static constexpr ToggleOption bluetooth_option = { LVGL_ICON_SHARED_BLUETOOTH, LVGL_ICON_SHARED_BLUETOOTH_DISABLED, isBluetoothEnabled, setBluetoothEnabled };
if (hasBluetooth()) {
createOption(grid, &bluetooth_option);
}
#endif
if (supportsPowerOff()) {
auto* power_tile = createTile(grid, LVGL_ICON_SHARED_POWER_SETTINGS_NEW);
lv_obj_add_event_cb(power_tile, onPowerOffClicked, LV_EVENT_SHORT_CLICKED, nullptr);
}
// The arrow keys move between the tiles. Toggles that are focused directly would be switched by them.
lvgl_grid_navigation_add(grid);
// Without a pointer device, tapping outside of the card or swiping up isn't possible
if (!lvgl_indev_exists(LV_INDEV_TYPE_POINTER)) {
auto* close_button = createTile(card, LVGL_ICON_SHARED_CLOSE);
lv_obj_add_event_cb(close_button, onCloseClicked, LV_EVENT_SHORT_CLICKED, nullptr);
}
}
void systemMenuShow() {
if (!hasWifi() && !hasGps() && !hasBluetooth() && !supportsPowerOff()) {
return;
}
window_manager_overlay_show(createWidgets, nullptr, nullptr);
}
}

View File

@ -55,7 +55,6 @@ bool MemoryCheckerService::onStart(ServiceContext& service) {
lock.lock(); lock.lock();
statusbarIconId = lvgl::statusbar_icon_add(LVGL_ICON_STATUSBAR_MEMORY, false); statusbarIconId = lvgl::statusbar_icon_add(LVGL_ICON_STATUSBAR_MEMORY, false);
lvgl::statusbar_icon_set_app(statusbarIconId, "tactility.systeminfo");
lvgl::statusbar_icon_set_visibility(statusbarIconId, false); lvgl::statusbar_icon_set_visibility(statusbarIconId, false);
timer.setCallbackPriority(Thread::Priority::Lower); timer.setCallbackPriority(Thread::Priority::Lower);

View File

@ -80,12 +80,13 @@ static const char* getWifiStatusIcon() {
check(false, "not implemented"); check(false, "not implemented");
} }
// Only shown while the radio is on
static const char* getBluetoothStatusIcon(tt::bluetooth::RadioState state, bool scanning, bool connected) { static const char* getBluetoothStatusIcon(tt::bluetooth::RadioState state, bool scanning, bool connected) {
switch (state) { switch (state) {
using enum tt::bluetooth::RadioState; using enum tt::bluetooth::RadioState;
case Off: case Off:
case OffPending: case OffPending:
return LVGL_ICON_STATUSBAR_BLUETOOTH_DISABLED; return nullptr;
case OnPending: case OnPending:
case On: case On:
if (connected) return LVGL_ICON_STATUSBAR_BLUETOOTH_CONNECTED; if (connected) return LVGL_ICON_STATUSBAR_BLUETOOTH_CONNECTED;
@ -149,8 +150,6 @@ class StatusbarService final : public Service {
const char* bt_last_icon = nullptr; const char* bt_last_icon = nullptr;
int8_t wifi_icon_id; int8_t wifi_icon_id;
const char* wifi_last_icon = nullptr; const char* wifi_last_icon = nullptr;
int8_t sdcard_icon_id;
const char* sdcard_last_icon = nullptr;
int8_t power_icon_id; int8_t power_icon_id;
const char* power_last_icon = nullptr; const char* power_last_icon = nullptr;
int8_t usb_icon_id; int8_t usb_icon_id;
@ -165,12 +164,8 @@ class StatusbarService final : public Service {
} }
void updateGpsIcon() { void updateGpsIcon() {
const char* icon = nullptr; // Only shown while a GPS device is on
if (device_has_active_by_type(&GPS_TYPE)) { const char* icon = device_has_active_by_type(&GPS_TYPE) ? LVGL_ICON_STATUSBAR_LOCATION_ON : nullptr;
icon = LVGL_ICON_STATUSBAR_LOCATION_ON;
} else if (device_exists_of_type(&GPS_TYPE)) {
icon = LVGL_ICON_STATUSBAR_LOCATION_OFF;
}
if (gps_last_icon != icon) { if (gps_last_icon != icon) {
if (icon != nullptr) { if (icon != nullptr) {
lvgl::statusbar_icon_set_image(gps_icon_id, icon); lvgl::statusbar_icon_set_image(gps_icon_id, icon);
@ -290,42 +285,12 @@ class StatusbarService final : public Service {
} }
} }
void updateSdCardIcon() {
struct SdCardState { bool found; bool mounted; };
SdCardState state = { false, false };
file_system_for_each(&state, [](FileSystem* fs, void* context) {
auto* s = static_cast<SdCardState*>(context);
char path[64];
if (file_system_get_path(fs, path, sizeof(path)) != ERROR_NONE) return true;
if (strncmp(path, "/sdcard", 7) != 0) return true;
s->found = true;
s->mounted = file_system_is_mounted(fs);
return false;
});
if (state.found) {
auto* desired_icon = getSdCardStatusIcon(state.mounted);
if (sdcard_last_icon != desired_icon) {
lvgl::statusbar_icon_set_image(sdcard_icon_id, desired_icon);
lvgl::statusbar_icon_set_visibility(sdcard_icon_id, true);
sdcard_last_icon = desired_icon;
}
} else {
if (sdcard_last_icon != nullptr) {
lvgl::statusbar_icon_set_visibility(sdcard_icon_id, false);
sdcard_last_icon = nullptr;
}
}
}
void update() { void update() {
if (lvgl_is_running()) { if (lvgl_is_running()) {
if (lvgl_try_lock(200)) { if (lvgl_try_lock(200)) {
updateGpsIcon(); updateGpsIcon();
updateBluetoothIcon(); updateBluetoothIcon();
updateWifiIcon(); updateWifiIcon();
updateSdCardIcon();
updatePowerStatusIcon(); updatePowerStatusIcon();
updateUsbIcon(); updateUsbIcon();
lvgl_unlock(); lvgl_unlock();
@ -339,21 +304,13 @@ public:
gps_icon_id = lvgl::statusbar_icon_add(); gps_icon_id = lvgl::statusbar_icon_add();
bt_icon_id = lvgl::statusbar_icon_add(); bt_icon_id = lvgl::statusbar_icon_add();
usb_icon_id = lvgl::statusbar_icon_add(); usb_icon_id = lvgl::statusbar_icon_add();
sdcard_icon_id = lvgl::statusbar_icon_add();
wifi_icon_id = lvgl::statusbar_icon_add(); wifi_icon_id = lvgl::statusbar_icon_add();
power_icon_id = lvgl::statusbar_icon_add(); power_icon_id = lvgl::statusbar_icon_add();
lvgl::statusbar_icon_set_app(gps_icon_id, "tactility.gpssettings");
lvgl::statusbar_icon_set_app(bt_icon_id, "tactility.btmanage");
lvgl::statusbar_icon_set_app(usb_icon_id, "tactility.usbsettings");
lvgl::statusbar_icon_set_app(sdcard_icon_id, "tactility.files");
lvgl::statusbar_icon_set_app(wifi_icon_id, "tactility.wifimanage");
lvgl::statusbar_icon_set_app(power_icon_id, "tactility.power");
} }
~StatusbarService() override { ~StatusbarService() override {
lvgl::statusbar_icon_remove(power_icon_id); lvgl::statusbar_icon_remove(power_icon_id);
lvgl::statusbar_icon_remove(wifi_icon_id); lvgl::statusbar_icon_remove(wifi_icon_id);
lvgl::statusbar_icon_remove(sdcard_icon_id);
lvgl::statusbar_icon_remove(usb_icon_id); lvgl::statusbar_icon_remove(usb_icon_id);
lvgl::statusbar_icon_remove(bt_icon_id); lvgl::statusbar_icon_remove(bt_icon_id);
lvgl::statusbar_icon_remove(gps_icon_id); lvgl::statusbar_icon_remove(gps_icon_id);

View File

@ -206,7 +206,6 @@ bool WebServerService::onStart(ServiceContext& service) {
// Create statusbar icon (hidden initially, shown when server actually starts) // Create statusbar icon (hidden initially, shown when server actually starts)
statusbarIconId = lvgl::statusbar_icon_add(); statusbarIconId = lvgl::statusbar_icon_add();
lvgl::statusbar_icon_set_app(statusbarIconId, "tactility.webserversettings");
lvgl::statusbar_icon_set_visibility(statusbarIconId, false); lvgl::statusbar_icon_set_visibility(statusbarIconId, false);
// Load and cache settings once at boot // Load and cache settings once at boot

View File

@ -0,0 +1,138 @@
#include <Tactility/settings/LauncherSettings.h>
#include <Tactility/file/File.h>
#include <Tactility/file/PropertiesFile.h>
#include <tactility/paths.h>
#ifdef ESP_PLATFORM
#include <sdkconfig.h>
#endif
#include <map>
#include <string>
namespace tt::settings::launcher {
constexpr auto* SETTINGS_KEY_SYSTEM_BARS = "systemBars";
constexpr auto* SYSTEM_BARS_AUTO = "auto";
constexpr auto* SYSTEM_BARS_CUSTOM = "custom";
constexpr auto* SETTINGS_KEY_PORTRAIT_LAYOUT = "portraitLayout";
constexpr auto* SETTINGS_KEY_LANDSCAPE_LAYOUT = "landscapeLayout";
constexpr auto* LAYOUT_SPLIT = "split";
constexpr auto* LAYOUT_SIDE = "side";
static const char* toString(SystemBarsLayout layout) {
return layout == SystemBarsLayout::Side ? LAYOUT_SIDE : LAYOUT_SPLIT;
}
static SystemBarsLayout toLayout(const std::map<std::string, std::string>& map, const char* key, SystemBarsLayout defaultValue) {
auto entry = map.find(key);
if (entry == map.end()) {
return defaultValue;
}
if (entry->second == LAYOUT_SIDE) {
return SystemBarsLayout::Side;
}
if (entry->second == LAYOUT_SPLIT) {
return SystemBarsLayout::Split;
}
return defaultValue;
}
static bool getSettingsFilePath(std::string& outPath) {
char root[128];
if (paths_get_data_path(root, sizeof(root)) != ERROR_NONE) {
return false;
}
outPath = std::string(root) + "/settings/launcher.properties";
return true;
}
bool load(LauncherSettings& settings) {
std::string settings_path;
if (!getSettingsFilePath(settings_path) || !file::isFile(settings_path)) {
return false;
}
std::map<std::string, std::string> map;
if (!file::loadPropertiesFile(settings_path, map)) {
return false;
}
settings = getDefault();
auto system_bars = map.find(SETTINGS_KEY_SYSTEM_BARS);
if (system_bars != map.end() && system_bars->second == SYSTEM_BARS_CUSTOM) {
settings.systemBarsMode = SystemBarsMode::Custom;
}
settings.portraitLayout = toLayout(map, SETTINGS_KEY_PORTRAIT_LAYOUT, settings.portraitLayout);
settings.landscapeLayout = toLayout(map, SETTINGS_KEY_LANDSCAPE_LAYOUT, settings.landscapeLayout);
return true;
}
LauncherSettings getDefault() {
return LauncherSettings {
.systemBarsMode = SystemBarsMode::Auto,
.portraitLayout = SystemBarsLayout::Split,
.landscapeLayout = SystemBarsLayout::Side
};
}
LauncherSettings loadOrGetDefault() {
LauncherSettings settings;
if (!load(settings)) {
settings = getDefault();
}
return settings;
}
bool save(const LauncherSettings& settings) {
std::map<std::string, std::string> map;
map[SETTINGS_KEY_SYSTEM_BARS] = settings.systemBarsMode == SystemBarsMode::Custom ? SYSTEM_BARS_CUSTOM : SYSTEM_BARS_AUTO;
map[SETTINGS_KEY_PORTRAIT_LAYOUT] = toString(settings.portraitLayout);
map[SETTINGS_KEY_LANDSCAPE_LAYOUT] = toString(settings.landscapeLayout);
std::string settings_path;
if (!getSettingsFilePath(settings_path)) {
return false;
}
if (!file::findOrCreateParentDirectory(settings_path, 0755)) {
return false;
}
return file::savePropertiesFile(settings_path, map);
}
SystemBarsCapabilities getSystemBarsCapabilities() {
SystemBarsCapabilities capabilities = { SystemBarsCapability::Any, SystemBarsCapability::Any };
#if defined(CONFIG_TT_SYSTEM_BARS_PORTRAIT_SPLIT)
capabilities.portrait = SystemBarsCapability::Split;
#elif defined(CONFIG_TT_SYSTEM_BARS_PORTRAIT_SIDE)
capabilities.portrait = SystemBarsCapability::Side;
#endif
#if defined(CONFIG_TT_SYSTEM_BARS_LANDSCAPE_SPLIT)
capabilities.landscape = SystemBarsCapability::Split;
#elif defined(CONFIG_TT_SYSTEM_BARS_LANDSCAPE_SIDE)
capabilities.landscape = SystemBarsCapability::Side;
#endif
return capabilities;
}
SystemBarsLayout resolveSystemBarsLayout(const LauncherSettings& settings, const SystemBarsCapabilities& capabilities, int32_t width, int32_t height) {
const bool landscape = width > height;
const auto capability = landscape ? capabilities.landscape : capabilities.portrait;
if (capability == SystemBarsCapability::Split) {
return SystemBarsLayout::Split;
}
if (capability == SystemBarsCapability::Side) {
return SystemBarsLayout::Side;
}
if (settings.systemBarsMode == SystemBarsMode::Custom) {
return landscape ? settings.landscapeLayout : settings.portraitLayout;
}
// Only wide landscape screens: an aspect ratio of 1.5 or more
return (landscape && (width * 2 >= height * 3)) ? SystemBarsLayout::Side : SystemBarsLayout::Split;
}
} // namespace tt::settings::launcher

View File

@ -0,0 +1,49 @@
#include "doctest.h"
#include <Tactility/settings/LauncherSettings.h>
using namespace tt::settings::launcher;
static constexpr SystemBarsCapabilities ANY = { SystemBarsCapability::Any, SystemBarsCapability::Any };
TEST_CASE("auto system bars use the side layout only for wide landscape screens") {
const LauncherSettings settings = getDefault();
CHECK_EQ(resolveSystemBarsLayout(settings, ANY, 320, 240), SystemBarsLayout::Split);
CHECK_EQ(resolveSystemBarsLayout(settings, ANY, 480, 222), SystemBarsLayout::Side);
CHECK_EQ(resolveSystemBarsLayout(settings, ANY, 240, 320), SystemBarsLayout::Split);
CHECK_EQ(resolveSystemBarsLayout(settings, ANY, 222, 480), SystemBarsLayout::Split);
CHECK_EQ(resolveSystemBarsLayout(settings, ANY, 300, 200), SystemBarsLayout::Side);
CHECK_EQ(resolveSystemBarsLayout(settings, ANY, 240, 240), SystemBarsLayout::Split);
}
TEST_CASE("custom system bars use the layout of the screen's orientation") {
LauncherSettings settings = getDefault();
settings.systemBarsMode = SystemBarsMode::Custom;
settings.portraitLayout = SystemBarsLayout::Side;
settings.landscapeLayout = SystemBarsLayout::Split;
CHECK_EQ(resolveSystemBarsLayout(settings, ANY, 240, 320), SystemBarsLayout::Side);
CHECK_EQ(resolveSystemBarsLayout(settings, ANY, 480, 222), SystemBarsLayout::Split);
// A square screen counts as portrait
CHECK_EQ(resolveSystemBarsLayout(settings, ANY, 240, 240), SystemBarsLayout::Side);
}
TEST_CASE("a fixed capability decides the layout instead of the settings") {
const SystemBarsCapabilities fixed = { SystemBarsCapability::Side, SystemBarsCapability::Split };
LauncherSettings settings = getDefault();
CHECK_EQ(resolveSystemBarsLayout(settings, fixed, 240, 320), SystemBarsLayout::Side);
CHECK_EQ(resolveSystemBarsLayout(settings, fixed, 480, 222), SystemBarsLayout::Split);
settings.systemBarsMode = SystemBarsMode::Custom;
settings.portraitLayout = SystemBarsLayout::Split;
settings.landscapeLayout = SystemBarsLayout::Side;
CHECK_EQ(resolveSystemBarsLayout(settings, fixed, 240, 320), SystemBarsLayout::Side);
CHECK_EQ(resolveSystemBarsLayout(settings, fixed, 480, 222), SystemBarsLayout::Split);
}
TEST_CASE("an orientation with the Any capability follows the settings while the other is fixed") {
const SystemBarsCapabilities landscape_fixed = { SystemBarsCapability::Any, SystemBarsCapability::Split };
LauncherSettings settings = getDefault();
settings.systemBarsMode = SystemBarsMode::Custom;
settings.portraitLayout = SystemBarsLayout::Side;
settings.landscapeLayout = SystemBarsLayout::Side;
CHECK_EQ(resolveSystemBarsLayout(settings, landscape_fixed, 240, 320), SystemBarsLayout::Side);
CHECK_EQ(resolveSystemBarsLayout(settings, landscape_fixed, 480, 222), SystemBarsLayout::Split);
}

View File

@ -101,6 +101,12 @@ def get_user_data_location(device_properties: dict):
exit_with_error(f"storage.userDataLocation must be 'SD' or 'Internal', but was: '{user_data_location}'") exit_with_error(f"storage.userDataLocation must be 'SD' or 'Internal', but was: '{user_data_location}'")
return user_data_location return user_data_location
def get_system_bars_capability(device_properties: dict, key: str):
capability = get_property_or_default(device_properties, key, "any").lower()
if capability not in ("any", "split", "side"):
exit_with_error(f"{key} must be 'any', 'split' or 'side', but was: '{capability}'")
return capability
def write_partition_table(output_file, device_properties: dict, is_dev: bool): def write_partition_table(output_file, device_properties: dict, is_dev: bool):
flash_size = get_property_or_exit(device_properties, "hardware.flashSize") flash_size = get_property_or_exit(device_properties, "hardware.flashSize")
if not flash_size.endswith("MB"): if not flash_size.endswith("MB"):
@ -150,6 +156,11 @@ def write_tactility_variables(output_file, device_properties: dict, device_id: s
output_file.write("CONFIG_TT_USER_DATA_LOCATION_SD=y\n") output_file.write("CONFIG_TT_USER_DATA_LOCATION_SD=y\n")
else: else:
output_file.write("CONFIG_TT_USER_DATA_LOCATION_INTERNAL=y\n") output_file.write("CONFIG_TT_USER_DATA_LOCATION_INTERNAL=y\n")
# System bars layouts per orientation
portrait = get_system_bars_capability(device_properties, "ui.systemBarsPortrait")
landscape = get_system_bars_capability(device_properties, "ui.systemBarsLandscape")
output_file.write(f"CONFIG_TT_SYSTEM_BARS_PORTRAIT_{portrait.upper()}=y\n")
output_file.write(f"CONFIG_TT_SYSTEM_BARS_LANDSCAPE_{landscape.upper()}=y\n")
def write_core_variables(output_file, device_properties: dict): def write_core_variables(output_file, device_properties: dict):
idf_target = get_property_or_exit(device_properties, "hardware.target").lower() idf_target = get_property_or_exit(device_properties, "hardware.target").lower()