Compare commits

..

2 Commits

Author SHA1 Message Date
Ken Van Hoeylandt
d64eac64f0
Notes revamped, icon fixes, Tab5 fixes (#682) 2026-10-09 08:49:25 +02:00
Ken Van Hoeylandt
7413888cdf
Refactor files app (#681)
+ re-use app grid as tile grid
2026-10-09 07:31:46 +02:00
24 changed files with 676 additions and 430 deletions

View File

@ -21,25 +21,38 @@ check e668
circle ef4a
close e5cd
cloud f15c
content_copy e14d
content_cut e14e
content_paste e14f
create_new_folder e2cc
delete e92e
deployed_code f720
description e873
devices e326
display_settings eb97
download f090
draft e66d
edit f097
edit_note e745
eject e8fb
electric_bolt ec1c
folder e2c7
folder_open e2c8
forum e8af
gamepad e30f
hard_disk f3da
help e8fd
hub e9f4
image e3f4
info e88e
keyboard_alt f028
keyboard_arrow_up e316
language ea07
lightbulb e90f
lightstrip fff75
link e250
lists e9b9
location_off e0c7
location_on f1db
logo_dev ead6
mail e159
@ -54,11 +67,16 @@ network_wifi_2_bar ebd6
network_wifi_3_bar ebe1
note_add e89c
palette e40a
phone_android f2db
play_arrow e037
power_settings_new f8c7
refresh e5d5
save e161
save_as eb60
sd_card e623
sd_card_alert f057
search ef7a
select_window_2 f4c8
settings e8b8
signal_wifi_0_bar f0b0
signal_wifi_4_bar f065

View File

@ -48,8 +48,8 @@ void tab5_create_devices_v2(Device* i2c0) {
.ldo_voltage_mv = 2500,
.dsi_bus_id = 0,
.num_data_lanes = 2,
.lane_bit_rate_mbps = 965, // ST7123 lane bitrate per M5Stack BSP
.dpi_clock_freq_mhz = 70,
.lane_bit_rate_mbps = 1040, // Deviates from M5Stack BSP to fix flickering
.dpi_clock_freq_mhz = 80,
.hsync_pulse_width = 2,
.hsync_back_porch = 40,
.hsync_front_porch = 40,

View File

@ -49,8 +49,8 @@ void tab5_create_devices_v3(Device* i2c0) {
.ldo_voltage_mv = 2500,
.dsi_bus_id = 0,
.num_data_lanes = 2,
.lane_bit_rate_mbps = 965, // ST7121 lane bitrate per M5Stack BSP (same as ST7123)
.dpi_clock_freq_mhz = 70,
.lane_bit_rate_mbps = 1040, // Deviates from M5Stack BSP to fix flickering
.dpi_clock_freq_mhz = 80,
.hsync_pulse_width = 2,
.hsync_back_porch = 40,
.hsync_front_porch = 40,

View File

@ -182,6 +182,22 @@ shared_symbol_code_point_names = [
"camera",
"lightstrip",
"phone_android", # Display settings: orientation
# Files app
"draft",
"description",
"link",
"sd_card",
"content_copy",
"content_cut",
"content_paste",
"edit",
"play_arrow",
"eject",
"create_new_folder",
# Notes app
"save",
"save_as",
"folder_open",
]
# Get more from https://fonts.google.com/icons?icon.set=Material+Symbols&icon.style=Rounded

View File

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

View File

@ -55,3 +55,17 @@
#define LVGL_ICON_SHARED_CAMERA "\xEE\x8E\xAF"
#define LVGL_ICON_SHARED_LIGHTSTRIP "\xF3\xBF\xBD\xB5"
#define LVGL_ICON_SHARED_PHONE_ANDROID "\xEF\x8B\x9B"
#define LVGL_ICON_SHARED_DRAFT "\xEE\x99\xAD"
#define LVGL_ICON_SHARED_DESCRIPTION "\xEE\xA1\xB3"
#define LVGL_ICON_SHARED_LINK "\xEE\x89\x90"
#define LVGL_ICON_SHARED_SD_CARD "\xEE\x98\xA3"
#define LVGL_ICON_SHARED_CONTENT_COPY "\xEE\x85\x8D"
#define LVGL_ICON_SHARED_CONTENT_CUT "\xEE\x85\x8E"
#define LVGL_ICON_SHARED_CONTENT_PASTE "\xEE\x85\x8F"
#define LVGL_ICON_SHARED_EDIT "\xEF\x82\x97"
#define LVGL_ICON_SHARED_PLAY_ARROW "\xEE\x80\xB7"
#define LVGL_ICON_SHARED_EJECT "\xEE\xA3\xBB"
#define LVGL_ICON_SHARED_CREATE_NEW_FOLDER "\xEE\x8B\x8C"
#define LVGL_ICON_SHARED_SAVE "\xEE\x85\xA1"
#define LVGL_ICON_SHARED_SAVE_AS "\xEE\xAD\xA0"
#define LVGL_ICON_SHARED_FOLDER_OPEN "\xEE\x8B\x88"

View File

@ -57,6 +57,20 @@ const char* const lvgl_icon_shared_names[] = {
"camera",
"lightstrip",
"phone_android",
"draft",
"description",
"link",
"sd_card",
"content_copy",
"content_cut",
"content_paste",
"edit",
"play_arrow",
"eject",
"create_new_folder",
"save",
"save_as",
"folder_open",
};
const size_t lvgl_icon_shared_name_count = sizeof(lvgl_icon_shared_names) / sizeof(lvgl_icon_shared_names[0]);

View File

@ -377,6 +377,7 @@ typedef struct {
lv_style_t text_button_pressed;
#endif
lv_style_t icon_button;
lv_style_t icon_button_selected;
lv_style_t icon_button_filled;
lv_style_t icon_button_filled_pressed;
lv_style_t icon_button_tonal;
@ -874,6 +875,11 @@ static void style_init(my_theme_t * theme)
lv_style_set_radius(&theme->styles.icon_button, BUTTON_SHAPE);
lv_style_set_pad_all(&theme->styles.icon_button, BUTTON_PAD_VER);
style_init_reset(&theme->styles.icon_button_selected);
lv_style_set_bg_color(&theme->styles.icon_button_selected, LIST_ITEM_FOCUSED_COLOR);
lv_style_set_bg_opa(&theme->styles.icon_button_selected, LV_OPA_COVER);
lv_style_set_text_color(&theme->styles.icon_button_selected, LIST_ITEM_FOCUSED_TEXT_COLOR);
style_init_reset(&theme->styles.icon_button_filled);
lv_style_set_bg_color(&theme->styles.icon_button_filled, ICON_BUTTON_FILLED_COLOR);
lv_style_set_bg_opa(&theme->styles.icon_button_filled, LV_OPA_COVER);
@ -1734,6 +1740,7 @@ static void theme_apply(lv_theme_t * th, lv_obj_t * obj)
lv_obj_add_style(obj, &theme->styles.transition_exit, 0);
lv_obj_add_style(obj, &theme->styles.state_pressed, LV_STATE_PRESSED);
lv_obj_add_style(obj, &theme->styles.transition_enter, LV_STATE_PRESSED);
lv_obj_add_style(obj, &theme->styles.icon_button_selected, LV_STATE_CHECKED);
lv_obj_add_style(obj, &theme->styles.focus_ring_inset, LV_STATE_FOCUS_KEY);
lv_obj_add_style(obj, &theme->styles.edit_ring_inset, LV_STATE_EDITED);
lv_obj_add_style(obj, &theme->styles.disabled, LV_STATE_DISABLED);

View File

@ -1347,6 +1347,7 @@ static void theme_apply(lv_theme_t * th, lv_obj_t * obj)
/* icon_button */
lv_obj_add_style(obj, &theme->styles.icon_button, 0);
lv_obj_add_style(obj, &theme->styles.inverted, LV_STATE_PRESSED);
lv_obj_add_style(obj, &theme->styles.inverted, LV_STATE_CHECKED);
lv_obj_add_style(obj, &theme->styles.focus_ring_inset, LV_STATE_FOCUS_KEY);
lv_obj_add_style(obj, &theme->styles.edit_ring_inset, LV_STATE_EDITED);
lv_obj_add_style(obj, &theme->styles.disabled, LV_STATE_DISABLED);

View File

@ -690,6 +690,13 @@ icon_button {
padAll: @button-pad-ver;
}
// A selected tile, e.g. the file that the Files app shows actions for
icon_button_selected {
bgColor: @list-item-focused-color;
bgOpa: cover;
textColor: @list-item-focused-text-color;
}
icon_button_filled {
bgColor: @icon-button-filled-color;
bgOpa: cover;
@ -1348,6 +1355,7 @@ apply(icon_button) {
transition_exit: default;
state_pressed: state.pressed;
transition_enter: state.pressed;
icon_button_selected: state.checked;
focus_ring_inset: state.focusKey;
edit_ring_inset: state.edited;
disabled: state.disabled;

View File

@ -1003,6 +1003,7 @@ apply(toolbar) {
apply(icon_button) {
icon_button: default;
inverted: state.pressed;
inverted: state.checked;
focus_ring_inset: state.focusKey;
edit_ring_inset: state.edited;
disabled: state.disabled;

View File

@ -1,43 +1,47 @@
#pragma once
#include <app/manifest.h>
#include <lvgl.h>
#include <cstdint>
#include <string>
#include <vector>
namespace tt::app {
struct AppGridItem {
/** Owned copy, so a tile never references the app ledger directly */
::AppManifest manifest;
/** Shared icon font glyph */
struct TileGridItem {
/** Identifies the item across rebuilds, e.g. to keep it selected */
std::string id;
std::string name;
/** Shared icon font glyph, or nullptr to resolve it with Callbacks::resolveIcon when the tile is shown */
const char* icon;
/** Rendered with an accent colour when not selected */
/** Marked with a badge */
bool highlighted;
};
/**
* Paged, non-scrolling grid of app tiles (icon above name) with "<" and ">" page buttons.
* Paged, non-scrolling grid of tiles (icon above name) with "<" and ">" page buttons.
* Must outlive the widgets it creates.
*/
class AppGrid final {
class TileGrid final {
public:
struct Callbacks {
/** Returns the items in display order. Called on every rebuild. */
std::vector<AppGridItem> (*collect)(void* userData);
void (*onClicked)(const ::AppManifest& manifest, void* userData);
std::vector<TileGridItem> (*collect)(void* userData);
void (*onClicked)(const TileGridItem& item, void* userData);
/** Optional */
void (*onLongPressed)(const ::AppManifest& manifest, void* userData);
void (*onLongPressed)(const TileGridItem& item, void* userData);
/** Optional */
void (*onKey)(const ::AppManifest& manifest, uint32_t key, void* userData);
void (*onKey)(const TileGridItem& item, uint32_t key, void* userData);
/** Optional: returns the icon of an item without one. Only called for the items on the shown page. */
const char* (*resolveIcon)(const TileGridItem& item, void* userData);
/** Optional: called after the user went to another page */
void (*onPageChanged)(void* userData);
void* userData;
};
explicit AppGrid(const Callbacks& callbacks) : callbacks(callbacks) {}
explicit TileGrid(const Callbacks& callbacks) : callbacks(callbacks) {}
enum class IconColor {
/** The theme's icon colour */
@ -57,6 +61,11 @@ public:
/** Swiping left or right over the grid goes to the next or previous page. Call before creating the widgets. */
void setSwipeNavigation(bool enabled) { swipeNavigation = enabled; }
/**
* Creates the grid and populates it. Call setPageButtons() right after it to add page buttons.
* @param[in] parent the container for the grid, with a flex column layout
*/
void createWidgets(lv_obj_t* parent);
/**
* Creates the grid and populates it, with a bar below it: buttons on the left (see addBarButton()),
@ -66,6 +75,12 @@ public:
*/
void createWidgetsWithBottomBar(lv_obj_t* parent);
/**
* Makes the buttons go to the previous and next page. They are hidden when there's only one page, and wrap around.
* Call right after createWidgets().
*/
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().
* @param[in] icon a shared icon (see lvgl/icons/shared.h)
@ -75,14 +90,17 @@ public:
/**
* Rebuilds the grid asynchronously, so it is safe to call from a tile's own event.
* @param[in] keepSelection when true, the selected app stays selected and its page is shown
* @param[in] keepSelection when true, the selected item stays selected and its page is shown
*/
void requestRepopulate(bool keepSelection);
/** @return the tile of the item on the shown page, or nullptr */
lv_obj_t* findTile(const std::string& id) const;
private:
Callbacks callbacks;
std::vector<AppGridItem> items;
std::vector<TileGridItem> items;
uint32_t page = 0;
uint32_t pageCount = 1;
IconColor iconColor = IconColor::Default;
@ -99,6 +117,7 @@ private:
lv_obj_t* barSpacer = nullptr;
void createGrid(lv_obj_t* parent);
void finishCreate(lv_obj_t* parent);
void goToPreviousPage();
void goToNextPage();
void populate();

View File

@ -61,8 +61,6 @@ public:
});
}
bool getDirent(uint32_t index, dirent& dirent);
void setSelectedChildEntry(const std::string& newFile) {
selected_child_entry = newFile;
action = ActionNone;

View File

@ -2,6 +2,8 @@
#include "./State.h"
#include <Tactility/app/TileGrid.h>
#include <app/stream.h>
#include <tactility/concurrent/task_event_group.h>
@ -19,43 +21,51 @@ class View final {
AppStream inputDialogStream {};
uint8_t inputDialogBuffer[256] {};
size_t current_start_index = 0;
size_t last_loaded_index = 0;
const size_t MAX_BATCH = 50;
TileGrid grid;
lv_obj_t* dir_entry_list = nullptr;
lv_obj_t* action_list = nullptr;
lv_obj_t* overlay = nullptr;
lv_obj_t* navigate_up_button = nullptr;
lv_obj_t* new_file_button = nullptr;
lv_obj_t* new_folder_button = nullptr;
lv_obj_t* paste_button = nullptr;
lv_obj_t* overflow_button = nullptr;
std::string installAppPath = { 0 };
uint32_t installDialogId = 0;
void showActions();
void showActionsForDirectory();
void showActionsForFile();
void showActionsForMountPoint();
static std::vector<TileGridItem> collectItems(void* userData);
static const char* resolveIcon(const TileGridItem& item, void* userData);
static void onTileClicked(const TileGridItem& item, void* userData);
static void onTileLongPressed(const TileGridItem& item, void* userData);
static void onTileKey(const TileGridItem& item, uint32_t key, void* userData);
static void onPageChanged(void* userData);
lv_obj_t* addOverlayButton(const char* icon, lv_event_cb_t callback);
void showOverlay(lv_obj_t* anchorTile);
void hideOverlay();
void showActionsForDirectory(lv_obj_t* tile);
void showActionsForFile(lv_obj_t* tile);
void showActionsForMountPoint(lv_obj_t* tile);
void addCommonFileActions();
void viewFile(const std::string&path, const std::string&filename);
void runFile(const std::string& file_path);
void createDirEntryWidget(lv_obj_t* parent, dirent& dir_entry);
void onNavigate();
public:
View(const std::shared_ptr<State>& state, TaskEventGroup* eventGroup) : state(state), eventGroup(eventGroup) {}
View(const std::shared_ptr<State>& state, TaskEventGroup* eventGroup);
void init(uint32_t appInstanceId, lv_obj_t* parent);
void update(size_t start_index = 0);
/**
* Shows the current entries of the state.
* @param[in] firstPage true after navigating to another directory, false to stay on the current page
*/
void update(bool firstPage);
void onBackPressed();
void onNavigateUpPressed();
void onDirEntryPressed(uint32_t index);
void onDirEntryLongPressed(int32_t index);
void onDirEntryKeyPressed(int32_t index, uint32_t key);
void onOverflowPressed();
void onDirEntryPressed(const std::string& name);
void onDirEntryLongPressed(const std::string& name);
void onDirEntryKeyPressed(const std::string& name, uint32_t key);
void onRenamePressed();
void onDeletePressed();
void onNewFilePressed();
@ -65,13 +75,12 @@ public:
void onPastePressed();
void onEjectPressed();
void onRunPressed();
void onDirEntryListScrollBegin();
void onOverlayKey(uint32_t key);
void onResult(uint32_t launchId, int32_t result);
void deinit();
private:
bool resolveDirentFromListIndex(int32_t list_index, dirent& out_entry);
bool findDirent(const std::string& name, dirent& out_entry);
void doPaste(const std::string& src, bool is_cut, const std::string& dst);
};

View File

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

View File

@ -1,4 +1,4 @@
#include <Tactility/app/AppGrid.h>
#include <Tactility/app/TileGrid.h>
#include <lvgl/devices/indev.h>
#include <lvgl/fonts.h>
@ -51,8 +51,8 @@ PageLayout computePageLayout(lv_obj_t* grid) {
};
}
const AppGridItem* getTileItem(lv_event_t* e) {
return static_cast<const AppGridItem*>(lv_obj_get_user_data(lv_event_get_target_obj(e)));
const TileGridItem* getTileItem(lv_event_t* e) {
return static_cast<const TileGridItem*>(lv_obj_get_user_data(lv_event_get_target_obj(e)));
}
@ -109,27 +109,27 @@ void onBottomBarDeleted(lv_event_t* event) {
} // namespace
void AppGrid::onDeferredNextPage(void* userData) {
static_cast<AppGrid*>(userData)->goToNextPage();
void TileGrid::onDeferredNextPage(void* userData) {
static_cast<TileGrid*>(userData)->goToNextPage();
}
void AppGrid::onDeferredPreviousPage(void* userData) {
static_cast<AppGrid*>(userData)->goToPreviousPage();
void TileGrid::onDeferredPreviousPage(void* userData) {
static_cast<TileGrid*>(userData)->goToPreviousPage();
}
void AppGrid::onDeferredRepopulate(void* userData) {
static_cast<AppGrid*>(userData)->populate();
void TileGrid::onDeferredRepopulate(void* userData) {
static_cast<TileGrid*>(userData)->populate();
}
// A layout pass can resize the grid temporarily (e.g. to its content height, before the parent's flex layout grows it again)
void AppGrid::onDeferredResize(void* userData) {
auto* self = static_cast<AppGrid*>(userData);
void TileGrid::onDeferredResize(void* userData) {
auto* self = static_cast<TileGrid*>(userData);
if (lv_obj_get_width(self->grid) != self->populatedWidth || lv_obj_get_height(self->grid) != self->populatedHeight) {
self->populate();
}
}
void AppGrid::onGridDeleted(lv_event_t* e) {
void TileGrid::onGridDeleted(lv_event_t* e) {
// Cancels a still-pending repopulate scheduled right before the grid itself was deleted (e.g. the app closing)
lv_async_call_cancel(onDeferredRepopulate, lv_event_get_user_data(e));
lv_async_call_cancel(onDeferredResize, lv_event_get_user_data(e));
@ -138,36 +138,42 @@ void AppGrid::onGridDeleted(lv_event_t* e) {
}
// Deferred: page size depends on the grid size, which is final only after the layout pass that triggered this.
void AppGrid::onGridSizeChanged(lv_event_t* e) {
void TileGrid::onGridSizeChanged(lv_event_t* e) {
// One pending check is enough, also when the size changes several times before it runs
lv_async_call_cancel(onDeferredResize, lv_event_get_user_data(e));
lv_async_call(onDeferredResize, lv_event_get_user_data(e));
}
// Changing the page doesn't keep the selected app selected: that would return to the selected app's page
void AppGrid::goToPreviousPage() {
void TileGrid::goToPreviousPage() {
page = page > 0 ? page - 1 : pageCount - 1;
keepSelectionOnRepopulate = false;
populate();
if (callbacks.onPageChanged != nullptr) {
callbacks.onPageChanged(callbacks.userData);
}
}
void AppGrid::goToNextPage() {
void TileGrid::goToNextPage() {
page = page + 1 < pageCount ? page + 1 : 0;
keepSelectionOnRepopulate = false;
populate();
if (callbacks.onPageChanged != nullptr) {
callbacks.onPageChanged(callbacks.userData);
}
}
void AppGrid::onPrevPressed(lv_event_t* e) {
static_cast<AppGrid*>(lv_event_get_user_data(e))->goToPreviousPage();
void TileGrid::onPrevPressed(lv_event_t* e) {
static_cast<TileGrid*>(lv_event_get_user_data(e))->goToPreviousPage();
}
void AppGrid::onNextPressed(lv_event_t* e) {
static_cast<AppGrid*>(lv_event_get_user_data(e))->goToNextPage();
void TileGrid::onNextPressed(lv_event_t* e) {
static_cast<TileGrid*>(lv_event_get_user_data(e))->goToNextPage();
}
// Swipes that start on a tile reach the grid too, as tiles pass their gestures on to the grid
void AppGrid::onGridGesture(lv_event_t* e) {
auto* self = static_cast<AppGrid*>(lv_event_get_user_data(e));
void TileGrid::onGridGesture(lv_event_t* e) {
auto* self = static_cast<TileGrid*>(lv_event_get_user_data(e));
lv_indev_t* indev = lv_indev_active();
if (indev == nullptr || self->pageCount <= 1) {
return;
@ -182,38 +188,38 @@ void AppGrid::onGridGesture(lv_event_t* e) {
lv_async_call(direction == LV_DIR_LEFT ? onDeferredNextPage : onDeferredPreviousPage, self);
}
void AppGrid::onTileClicked(lv_event_t* e) {
const auto* self = static_cast<AppGrid*>(lv_event_get_user_data(e));
self->callbacks.onClicked(getTileItem(e)->manifest, self->callbacks.userData);
void TileGrid::onTileClicked(lv_event_t* e) {
const auto* self = static_cast<TileGrid*>(lv_event_get_user_data(e));
self->callbacks.onClicked(*getTileItem(e), self->callbacks.userData);
}
void AppGrid::onTileLongPressed(lv_event_t* e) {
const auto* self = static_cast<AppGrid*>(lv_event_get_user_data(e));
self->callbacks.onLongPressed(getTileItem(e)->manifest, self->callbacks.userData);
void TileGrid::onTileLongPressed(lv_event_t* e) {
const auto* self = static_cast<TileGrid*>(lv_event_get_user_data(e));
self->callbacks.onLongPressed(*getTileItem(e), self->callbacks.userData);
}
void AppGrid::onTileKey(lv_event_t* e) {
const auto* self = static_cast<AppGrid*>(lv_event_get_user_data(e));
self->callbacks.onKey(getTileItem(e)->manifest, lv_event_get_key(e), self->callbacks.userData);
void TileGrid::onTileKey(lv_event_t* e) {
const auto* self = static_cast<TileGrid*>(lv_event_get_user_data(e));
self->callbacks.onKey(*getTileItem(e), lv_event_get_key(e), self->callbacks.userData);
}
void AppGrid::requestRepopulate(bool keepSelection) {
void TileGrid::requestRepopulate(bool keepSelection) {
keepSelectionOnRepopulate = keepSelection;
lv_async_call(onDeferredRepopulate, this);
}
void AppGrid::populate() {
void TileGrid::populate() {
// Captured before lv_obj_clean() deletes the currently focused tile below: creating
// replacement tiles doesn't restore the focus, so the app id is remembered here and
// replacement tiles doesn't restore the focus, so the item id is remembered here and
// re-focused on its new tile once rebuilt.
std::string focusedAppId;
std::string focusedId;
const bool keepSelection = keepSelectionOnRepopulate;
lv_group_t* group = lv_group_get_default();
if (group != nullptr && keepSelection) {
// Grid navigation focuses the grid in the group, and a tile within the grid
lv_obj_t* focused = lv_group_get_focused(group) == grid ? lvgl_grid_navigation_get_focused(grid) : nullptr;
if (focused != nullptr && lv_obj_get_parent(focused) == grid) {
focusedAppId = static_cast<const AppGridItem*>(lv_obj_get_user_data(focused))->manifest.id;
focusedId = static_cast<const TileGridItem*>(lv_obj_get_user_data(focused))->id;
}
}
keepSelectionOnRepopulate = true;
@ -231,10 +237,10 @@ void AppGrid::populate() {
const uint32_t item_count = items.size();
const uint32_t page_count = std::max<uint32_t>(1, (item_count + layout.pageSize - 1) / layout.pageSize);
// Follows the focused app onto its new page, e.g. after a reorder moved it.
if (!focusedAppId.empty()) {
const auto it = std::ranges::find_if(items, [&](const AppGridItem& item) {
return focusedAppId == item.manifest.id;
// Follows the focused item onto its new page, e.g. after a reorder moved it.
if (!focusedId.empty()) {
const auto it = std::ranges::find_if(items, [&](const TileGridItem& item) {
return focusedId == item.id;
});
if (it != items.end()) {
page = static_cast<uint32_t>(it - items.begin()) / layout.pageSize;
@ -243,11 +249,15 @@ void AppGrid::populate() {
page = std::min(page, page_count - 1);
pageCount = page_count;
lv_obj_set_hidden(pageIndicator, page_count <= 1);
lv_obj_set_hidden(prevButton, page_count <= 1);
lv_obj_set_hidden(nextButton, page_count <= 1);
lvgl_page_indicator_set_page_count(pageIndicator, page_count);
lvgl_page_indicator_set_page(pageIndicator, page);
if (pageIndicator != nullptr) {
lv_obj_set_hidden(pageIndicator, page_count <= 1);
lvgl_page_indicator_set_page_count(pageIndicator, page_count);
lvgl_page_indicator_set_page(pageIndicator, page);
}
if (prevButton != nullptr) {
lv_obj_set_hidden(prevButton, page_count <= 1);
lv_obj_set_hidden(nextButton, page_count <= 1);
}
lv_obj_t* focusedTile = nullptr;
const uint32_t first = page * layout.pageSize;
@ -262,13 +272,17 @@ void AppGrid::populate() {
lv_obj_set_style_pad_row(tile, layout.pad / 2, LV_STATE_DEFAULT);
lv_obj_set_flex_flow(tile, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(tile, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_user_data(tile, const_cast<AppGridItem*>(&item));
lv_obj_set_user_data(tile, const_cast<TileGridItem*>(&item));
lv_obj_t* icon = lv_label_create(tile);
lv_obj_set_style_text_font(icon, lvgl_get_shared_icon_large_font(), LV_STATE_DEFAULT);
lv_obj_set_style_text_align(icon, LV_TEXT_ALIGN_CENTER, LV_STATE_DEFAULT);
lv_obj_set_size(icon, layout.iconSize, layout.iconSize);
lv_label_set_text(icon, item.icon);
const char* icon_text = item.icon;
if (icon_text == nullptr && callbacks.resolveIcon != nullptr) {
icon_text = callbacks.resolveIcon(item, callbacks.userData);
}
lv_label_set_text(icon, icon_text != nullptr ? icon_text : "");
if (iconColor == IconColor::Primary) {
lv_obj_set_style_text_color(icon, lv_theme_get_color_primary(icon), LV_STATE_DEFAULT);
} else if (iconColor == IconColor::Secondary) {
@ -281,7 +295,7 @@ void AppGrid::populate() {
// Dots mode needs a fixed height, otherwise the label wraps instead of truncating
lv_obj_set_size(label, LV_PCT(100), lv_font_get_line_height(lvgl_get_text_font(FONT_SIZE_SMALL)));
lv_label_set_long_mode(label, LV_LABEL_LONG_MODE_DOTS);
lv_label_set_text(label, item.manifest.name);
lv_label_set_text(label, item.name.c_str());
// A badge marks favourites, as it doesn't look like the focus indicator
if (item.highlighted) {
@ -297,7 +311,7 @@ void AppGrid::populate() {
lv_obj_add_event_cb(tile, onTileKey, LV_EVENT_KEY, this);
}
if (!focusedAppId.empty() && focusedAppId == item.manifest.id) {
if (!focusedId.empty() && focusedId == item.id) {
focusedTile = tile;
}
}
@ -312,7 +326,7 @@ void AppGrid::populate() {
}
}
void AppGrid::createGrid(lv_obj_t* parent) {
void TileGrid::createGrid(lv_obj_t* parent) {
grid = lv_obj_create(parent);
lv_obj_set_width(grid, LV_PCT(100));
lv_obj_set_flex_grow(grid, 1);
@ -333,8 +347,8 @@ void AppGrid::createGrid(lv_obj_t* parent) {
}
// Keys enter the bar on the previous page button, or on the first button when there's only one page
void AppGrid::onBottomBarFocused(lv_event_t* e) {
const auto* self = static_cast<AppGrid*>(lv_event_get_user_data(e));
void TileGrid::onBottomBarFocused(lv_event_t* 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* target = lv_obj_is_hidden(self->prevButton) ? lv_obj_get_child(self->bottomBar, 0) : self->prevButton;
if (current == nullptr || target == current || target == self->barSpacer) {
@ -347,7 +361,7 @@ void AppGrid::onBottomBarFocused(lv_event_t* e) {
}
}
void AppGrid::createWidgetsWithBottomBar(lv_obj_t* parent) {
void TileGrid::createWidgetsWithBottomBar(lv_obj_t* parent) {
bottomBar = lv_obj_create(parent);
lv_obj_set_size(bottomBar, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(bottomBar, 0, LV_STATE_DEFAULT);
@ -380,6 +394,21 @@ void AppGrid::createWidgetsWithBottomBar(lv_obj_t* parent) {
lvgl_grid_navigation_add(bottomBar);
lv_obj_add_event_cb(bottomBar, onBottomBarFocused, LV_EVENT_FOCUSED, this);
finishCreate(parent);
}
void TileGrid::createWidgets(lv_obj_t* parent) {
// Widgets of a previous build are deleted by now
prevButton = nullptr;
nextButton = nullptr;
pageIndicator = nullptr;
bottomBar = nullptr;
barSpacer = nullptr;
createGrid(parent);
finishCreate(parent);
}
void TileGrid::finishCreate(lv_obj_t* parent) {
// Resolves the grid's flex_grow height, which the page size is derived from.
lv_obj_update_layout(parent);
lv_obj_add_event_cb(grid, onGridSizeChanged, LV_EVENT_SIZE_CHANGED, this);
@ -392,7 +421,27 @@ void AppGrid::createWidgetsWithBottomBar(lv_obj_t* parent) {
}
}
lv_obj_t* AppGrid::addBarButton(const char* icon, lv_event_cb_t onClicked, void* userData) {
void TileGrid::setPageButtons(lv_obj_t* previous, lv_obj_t* next) {
prevButton = previous;
nextButton = next;
lv_obj_add_event_cb(prevButton, onPrevPressed, LV_EVENT_SHORT_CLICKED, this);
lv_obj_add_event_cb(nextButton, onNextPressed, LV_EVENT_SHORT_CLICKED, this);
lv_obj_set_hidden(prevButton, pageCount <= 1);
lv_obj_set_hidden(nextButton, pageCount <= 1);
}
lv_obj_t* TileGrid::findTile(const std::string& id) const {
const uint32_t count = lv_obj_get_child_count(grid);
for (uint32_t i = 0; i < count; i++) {
lv_obj_t* tile = lv_obj_get_child(grid, static_cast<int32_t>(i));
if (static_cast<const TileGridItem*>(lv_obj_get_user_data(tile))->id == id) {
return tile;
}
}
return nullptr;
}
lv_obj_t* TileGrid::addBarButton(const char* icon, lv_event_cb_t onClicked, void* userData) {
auto* button = lvgl_icon_button_create(bottomBar);
lv_obj_move_to_index(button, lv_obj_get_index(barSpacer));
auto* label = lv_label_create(button);

View File

@ -64,7 +64,6 @@ int32_t appMain(int argc, char* argv[]) {
}
}
view.deinit();
window_manager_remove(window);
check(app_event_unsubscribe(&sub) == ERROR_NONE);
task_event_group_destruct(&event_group);

View File

@ -58,18 +58,4 @@ bool State::setEntriesForChildPath(const std::string& childPath) {
return setEntriesForPath(path);
}
bool State::getDirent(uint32_t index, dirent& dirent) {
auto lock = mutex.asScopedLock();
if (!lock.lock(50 / portTICK_PERIOD_MS)) {
return false;
}
if (index < dir_entries.size()) {
dirent = dir_entries[index];
return true;
} else {
return false;
}
}
}

View File

@ -4,8 +4,13 @@
#include <app/start.h>
#include <app/stream.h>
#include <lvgl/fonts.h>
#include <lvgl/grid_navigation.h>
#include <lvgl/icons/shared.h>
#include <lvgl/insets.h>
#include <lvgl/lvgl.h>
#include <lvgl/widgets/list.h>
#include <lvgl/widgets/card.h>
#include <lvgl/widgets/icon_button.h>
#include <lvgl/widgets/toolbar.h>
#include <Tactility/app/files/SupportedFiles.h>
@ -22,6 +27,7 @@
#include <tactility/drivers/usb_host_msc.h>
#include <tactility/log.h>
#include <algorithm>
#include <cctype>
#include <cerrno>
#include <cstdio>
@ -36,36 +42,11 @@ constexpr auto* TAG = "Files";
// region Callbacks
static void dirEntryListScrollBeginCallback(lv_event_t* event) {
auto* view = static_cast<files::View*>(lv_event_get_user_data(event));
view->onDirEntryListScrollBegin();
}
static void onBackPressedCallback(lv_event_t* event) {
auto* view = static_cast<files::View*>(lv_event_get_user_data(event));
view->onBackPressed();
}
static void onDirEntryPressedCallback(lv_event_t* event) {
auto* view = static_cast<View*>(lv_event_get_user_data(event));
auto* button = lv_event_get_target_obj(event);
auto index = lv_obj_get_index(button);
view->onDirEntryPressed(index);
}
static void onDirEntryLongPressedCallback(lv_event_t* event) {
auto* view = static_cast<View*>(lv_event_get_user_data(event));
auto* button = lv_event_get_target_obj(event);
auto index = lv_obj_get_index(button);
view->onDirEntryLongPressed(index);
}
static void onDirEntryKeyCallback(lv_event_t* event) {
auto* view = static_cast<View*>(lv_event_get_user_data(event));
auto* button = lv_event_get_target_obj(event);
view->onDirEntryKeyPressed(lv_obj_get_index(button), lv_event_get_key(event));
}
static void onRenamePressedCallback(lv_event_t* event) {
auto* view = static_cast<View*>(lv_event_get_user_data(event));
view->onRenamePressed();
@ -81,6 +62,15 @@ static void onNavigateUpPressedCallback(lv_event_t* event) {
view->onNavigateUpPressed();
}
static void onOverflowPressedCallback(lv_event_t* event) {
auto* view = static_cast<View*>(lv_event_get_user_data(event));
view->onOverflowPressed();
}
static void onPageButtonPressedCallback(lv_event_t*) {
// TileGrid::setPageButtons() makes the page buttons change the page
}
static void onNewFilePressedCallback(lv_event_t* event) {
auto* view = static_cast<View*>(lv_event_get_user_data(event));
view->onNewFilePressed();
@ -116,6 +106,11 @@ static void onRunPressedCallback(lv_event_t* event) {
view->onRunPressed();
}
static void onOverlayKeyCallback(lv_event_t* event) {
auto* view = static_cast<View*>(lv_event_get_user_data(event));
view->onOverlayKey(lv_event_get_key(event));
}
// endregion
// region File helpers
@ -125,6 +120,33 @@ static bool isExecutablePath(const std::string& path) {
return app_is_executable(location);
}
static bool isUsbMountPoint(const char* name) {
return strncmp(name, "usb", 3) == 0 && isdigit((unsigned char)name[3]);
}
// Returns nullptr for the files that View::resolveIcon() checks, as that reads the file
static const char* getIcon(const dirent& entry, bool isRoot) {
if (entry.d_type == file::TT_DT_DIR || entry.d_type == file::TT_DT_CHR) {
if (isRoot && strncmp(entry.d_name, "sd", 2) == 0) {
return LVGL_ICON_SHARED_SD_CARD;
} else if (isRoot && isUsbMountPoint(entry.d_name)) {
return LVGL_ICON_SHARED_USB;
} else {
return LVGL_ICON_SHARED_FOLDER;
}
} else if (entry.d_type == file::TT_DT_LNK) {
return LVGL_ICON_SHARED_LINK;
} else if (isSupportedImageFile(entry.d_name)) {
return LVGL_ICON_SHARED_IMAGE;
} else if (isSupportedTextFile(entry.d_name)) {
return LVGL_ICON_SHARED_DESCRIPTION;
} else if (isSupportedAppFile(entry.d_name)) {
return LVGL_ICON_SHARED_DEPLOYED_CODE;
} else {
return nullptr;
}
}
static bool copyFileContents(const std::string& src, const std::string& dst) {
FILE* in = fopen(src.c_str(), "rb");
if (in == nullptr) {
@ -233,23 +255,75 @@ void View::runFile(const std::string& file_path) {
}
}
bool View::resolveDirentFromListIndex(int32_t list_index, dirent& out_entry) {
const bool is_root = (state->getCurrentPath() == "/");
const bool has_back = (!is_root && current_start_index > 0);
View::View(const std::shared_ptr<State>& state, TaskEventGroup* eventGroup) :
state(state),
eventGroup(eventGroup),
grid(TileGrid::Callbacks {
.collect = collectItems,
.onClicked = onTileClicked,
.onLongPressed = onTileLongPressed,
.onKey = onTileKey,
.resolveIcon = resolveIcon,
.onPageChanged = onPageChanged,
.userData = this
}) {}
if (has_back && list_index == 0) {
return false; // Back button
}
const size_t adjusted_index =
current_start_index + static_cast<size_t>(list_index) - (has_back ? 1 : 0);
return state->getDirent(static_cast<uint32_t>(adjusted_index), out_entry);
std::vector<TileGridItem> View::collectItems(void* userData) {
auto* view = static_cast<View*>(userData);
const bool is_root = (view->state->getCurrentPath() == "/");
std::vector<TileGridItem> items;
view->state->withEntries([&](const std::vector<dirent>& entries) {
items.reserve(entries.size());
for (const auto& entry : entries) {
items.push_back({ entry.d_name, entry.d_name, getIcon(entry, is_root), false });
}
});
return items;
}
void View::onDirEntryPressed(uint32_t index) {
const char* View::resolveIcon(const TileGridItem& item, void* userData) {
auto* view = static_cast<View*>(userData);
const std::string path = file::getChildPath(view->state->getCurrentPath(), item.id);
return isExecutablePath(path) ? LVGL_ICON_SHARED_PLAY_ARROW : LVGL_ICON_SHARED_DRAFT;
}
void View::onTileClicked(const TileGridItem& item, void* userData) {
static_cast<View*>(userData)->onDirEntryPressed(item.id);
}
void View::onTileLongPressed(const TileGridItem& item, void* userData) {
static_cast<View*>(userData)->onDirEntryLongPressed(item.id);
}
void View::onTileKey(const TileGridItem& item, uint32_t key, void* userData) {
static_cast<View*>(userData)->onDirEntryKeyPressed(item.id, key);
}
void View::onPageChanged(void* userData) {
static_cast<View*>(userData)->hideOverlay();
}
bool View::findDirent(const std::string& name, dirent& out_entry) {
bool found = false;
state->withEntries([&](const std::vector<dirent>& entries) {
const auto it = std::ranges::find_if(entries, [&](const dirent& entry) { return name == entry.d_name; });
if (it != entries.end()) {
out_entry = *it;
found = true;
}
});
return found;
}
void View::onDirEntryPressed(const std::string& name) {
// A tap only closes the overlay, so it can't open an entry by accident
if (!lv_obj_is_hidden(overlay)) {
hideOverlay();
return;
}
dirent dir_entry;
if (!resolveDirentFromListIndex(static_cast<int32_t>(index), dir_entry)) {
if (!findDirent(name, dir_entry)) {
return;
}
@ -262,7 +336,7 @@ void View::onDirEntryPressed(uint32_t index) {
case TT_DT_CHR:
state->setEntriesForChildPath(dir_entry.d_name);
onNavigate();
update();
update(true);
break;
case TT_DT_LNK:
@ -276,21 +350,24 @@ void View::onDirEntryPressed(uint32_t index) {
}
}
void View::onDirEntryLongPressed(int32_t index) {
void View::onDirEntryLongPressed(const std::string& name) {
// Deselects the tile of an overlay that is still open
hideOverlay();
dirent dir_entry;
if (!resolveDirentFromListIndex(index, dir_entry)) {
if (!findDirent(name, dir_entry)) {
return;
}
LOG_I(TAG, "Long-pressed %s %d", dir_entry.d_name, (int)dir_entry.d_type);
state->setSelectedChildEntry(dir_entry.d_name);
lv_obj_t* tile = grid.findTile(name);
if (state->getCurrentPath() == "/") {
// At root, only USB mount points support actions (eject).
// Other root-level entries intentionally have no context actions.
const char* name = dir_entry.d_name;
if (strncmp(name, "usb", 3) == 0 && isdigit((unsigned char)name[3])) {
showActionsForMountPoint();
if (isUsbMountPoint(dir_entry.d_name)) {
showActionsForMountPoint(tile);
}
return;
}
@ -299,7 +376,7 @@ void View::onDirEntryLongPressed(int32_t index) {
switch (dir_entry.d_type) {
case TT_DT_DIR:
case TT_DT_CHR:
showActionsForDirectory();
showActionsForDirectory(tile);
break;
case TT_DT_LNK:
@ -307,63 +384,17 @@ void View::onDirEntryLongPressed(int32_t index) {
break;
default:
showActionsForFile();
showActionsForFile(tile);
break;
}
}
void View::createDirEntryWidget(lv_obj_t* list, dirent& dir_entry) {
check(list);
const char* symbol;
if (dir_entry.d_type == file::TT_DT_DIR || dir_entry.d_type == file::TT_DT_CHR) {
symbol = LV_SYMBOL_DIRECTORY;
} else if (isSupportedImageFile(dir_entry.d_name)) {
symbol = LV_SYMBOL_IMAGE;
} else if (dir_entry.d_type == file::TT_DT_LNK) {
symbol = LV_SYMBOL_LOOP;
} else if (isExecutablePath(file::getChildPath(state->getCurrentPath(), dir_entry.d_name))) {
symbol = LV_SYMBOL_PLAY;
} else {
symbol = LV_SYMBOL_FILE;
void View::onDirEntryKeyPressed(const std::string& name, uint32_t key) {
if (key == ' ') {
onDirEntryLongPressed(name);
return;
}
// Get file size for regular files
std::string label_text = dir_entry.d_name;
if (dir_entry.d_type == file::TT_DT_REG) {
std::string file_path = file::getChildPath(state->getCurrentPath(), dir_entry.d_name);
struct stat st;
if (stat(file_path.c_str(), &st) == 0) {
// Format file size in human-readable format
const char* size_suffix;
double size;
if (st.st_size < 1024) {
size = st.st_size;
size_suffix = " B";
} else if (st.st_size < 1024 * 1024) {
size = st.st_size / 1024.0;
size_suffix = " KB";
} else {
size = st.st_size / (1024.0 * 1024.0);
size_suffix = " MB";
}
char size_str[32];
if (st.st_size < 1024) {
snprintf(size_str, sizeof(size_str), " (%d%s)", (int)size, size_suffix);
} else {
snprintf(size_str, sizeof(size_str), " (%.1f%s)", size, size_suffix);
}
label_text += size_str;
}
}
lv_obj_t* button = lvgl_list_add_button(list, symbol, label_text.c_str());
lv_obj_add_event_cb(button, &onDirEntryPressedCallback, LV_EVENT_SHORT_CLICKED, this);
lv_obj_add_event_cb(button, &onDirEntryLongPressedCallback, LV_EVENT_LONG_PRESSED, this);
lv_obj_add_event_cb(button, &onDirEntryKeyCallback, LV_EVENT_KEY, this);
}
void View::onDirEntryKeyPressed(int32_t index, uint32_t key) {
// Keyboards report their delete key as either delete or backspace (e.g. Cardputer's "del" key)
const bool is_delete = key == LV_KEY_DEL || key == LV_KEY_BACKSPACE;
const bool is_rename = key == 'r' || key == 'R';
@ -372,7 +403,7 @@ void View::onDirEntryKeyPressed(int32_t index, uint32_t key) {
}
dirent dir_entry;
if (!resolveDirentFromListIndex(index, dir_entry)) {
if (!findDirent(name, dir_entry)) {
return;
}
// Same rules as the long-press actions: root entries are mount points, links have no actions
@ -399,11 +430,27 @@ void View::onNavigateUpPressed() {
state->setEntriesForPath(new_absolute_path);
}
onNavigate();
update();
update(true);
}
}
void View::onOverflowPressed() {
if (!lv_obj_is_hidden(overlay)) {
hideOverlay();
return;
}
lv_obj_clean(overlay);
addOverlayButton(LVGL_ICON_SHARED_NOTE_ADD, onNewFilePressedCallback);
addOverlayButton(LVGL_ICON_SHARED_CREATE_NEW_FOLDER, onNewFolderPressedCallback);
if (state->hasClipboard()) {
addOverlayButton(LVGL_ICON_SHARED_CONTENT_PASTE, onPastePressedCallback);
}
showOverlay(nullptr);
}
void View::onRenamePressed() {
onNavigate();
std::string entry_name = state->getSelectedChildEntry();
LOG_I(TAG, "Pending rename %s", entry_name.c_str());
state->setPendingAction(State::ActionRename);
@ -411,6 +458,7 @@ void View::onRenamePressed() {
}
void View::onDeletePressed() {
onNavigate();
std::string file_path = state->getSelectedChildPath();
LOG_I(TAG, "Pending delete %s", file_path.c_str());
state->setPendingAction(State::ActionDelete);
@ -420,55 +468,112 @@ void View::onDeletePressed() {
}
void View::onNewFilePressed() {
onNavigate();
LOG_I(TAG, "Creating new file");
state->setPendingAction(State::ActionCreateFile);
inputdialog::start(appInstanceId, "New File", "Enter filename:", "", inputDialogStream, inputDialogBuffer, sizeof(inputDialogBuffer), eventGroup);
}
void View::onNewFolderPressed() {
onNavigate();
LOG_I(TAG, "Creating new folder");
state->setPendingAction(State::ActionCreateFolder);
inputdialog::start(appInstanceId, "New Folder", "Enter folder name:", "", inputDialogStream, inputDialogBuffer, sizeof(inputDialogBuffer), eventGroup);
}
lv_obj_t* View::addOverlayButton(const char* icon, lv_event_cb_t callback) {
auto* button = lvgl_icon_button_create(overlay);
auto* label = lv_label_create(button);
lv_obj_set_style_text_font(label, lvgl_get_shared_icon_default_font(), LV_STATE_DEFAULT);
lv_label_set_text(label, icon);
lv_obj_add_event_cb(button, callback, LV_EVENT_SHORT_CLICKED, this);
return button;
}
void View::addCommonFileActions() {
auto* copy_button = lvgl_list_add_button(action_list, LV_SYMBOL_COPY, "Copy");
lv_obj_add_event_cb(copy_button, onCopyPressedCallback, LV_EVENT_SHORT_CLICKED, this);
auto* cut_button = lvgl_list_add_button(action_list, LV_SYMBOL_CUT, "Cut");
lv_obj_add_event_cb(cut_button, onCutPressedCallback, LV_EVENT_SHORT_CLICKED, this);
auto* rename_button = lvgl_list_add_button(action_list, LV_SYMBOL_EDIT, "Rename");
lv_obj_add_event_cb(rename_button, onRenamePressedCallback, LV_EVENT_SHORT_CLICKED, this);
auto* delete_button = lvgl_list_add_button(action_list, LV_SYMBOL_TRASH, "Delete");
lv_obj_add_event_cb(delete_button, onDeletePressedCallback, LV_EVENT_SHORT_CLICKED, this);
addOverlayButton(LVGL_ICON_SHARED_CONTENT_COPY, onCopyPressedCallback);
addOverlayButton(LVGL_ICON_SHARED_CONTENT_CUT, onCutPressedCallback);
if (state->hasClipboard()) {
addOverlayButton(LVGL_ICON_SHARED_CONTENT_PASTE, onPastePressedCallback);
}
addOverlayButton(LVGL_ICON_SHARED_EDIT, onRenamePressedCallback);
addOverlayButton(LVGL_ICON_SHARED_DELETE, onDeletePressedCallback);
}
void View::showActions() {
lv_obj_clean(action_list);
addCommonFileActions();
lv_obj_set_hidden(action_list, false);
}
void View::showActionsForDirectory() { showActions(); }
void View::showActionsForFile() {
lv_obj_clean(action_list);
if (isExecutablePath(state->getSelectedChildPath())) {
auto* run_button = lvgl_list_add_button(action_list, LV_SYMBOL_PLAY, "Run");
lv_obj_add_event_cb(run_button, onRunPressedCallback, LV_EVENT_SHORT_CLICKED, this);
// The overlay covers the bottom of the grid, or its top when it would cover the anchor tile
void View::showOverlay(lv_obj_t* anchorTile) {
lv_obj_set_hidden(overlay, false);
lv_obj_align(overlay, LV_ALIGN_BOTTOM_MID, 0, 0);
lv_obj_update_layout(overlay);
if (anchorTile != nullptr) {
lv_obj_add_state(anchorTile, LV_STATE_CHECKED);
lv_area_t tile_area;
lv_area_t overlay_area;
lv_obj_get_coords(anchorTile, &tile_area);
lv_obj_get_coords(overlay, &overlay_area);
if (tile_area.y2 > overlay_area.y1) {
lv_obj_align_to(overlay, lv_obj_get_parent(anchorTile), LV_ALIGN_TOP_MID, 0, 0);
}
}
addCommonFileActions();
lv_obj_set_hidden(action_list, false);
lv_group_t* group = lv_group_get_default();
if (group != nullptr && lv_obj_get_child_count(overlay) > 0) {
lv_group_focus_obj(overlay);
lv_gridnav_set_focused(overlay, lv_obj_get_child(overlay, 0), LV_ANIM_OFF);
// Touch shows no selection, until a key moves the focus
lv_indev_t* indev = lv_indev_active();
if (indev != nullptr && lv_indev_get_type(indev) == LV_INDEV_TYPE_POINTER) {
lvgl_focus_hide_key_selection(group);
}
}
}
void View::showActionsForMountPoint() {
lv_obj_clean(action_list);
void View::hideOverlay() {
if (overlay == nullptr || lv_obj_is_hidden(overlay)) {
return;
}
lv_obj_set_hidden(overlay, true);
auto* eject_button = lvgl_list_add_button(action_list, LV_SYMBOL_EJECT, "Eject");
lv_obj_add_event_cb(eject_button, onEjectPressedCallback, LV_EVENT_SHORT_CLICKED, this);
lv_obj_t* tile = grid.findTile(state->getSelectedChildEntry());
if (tile != nullptr) {
lv_obj_remove_state(tile, LV_STATE_CHECKED);
}
lv_obj_set_hidden(action_list, false);
// Keys continue on the tile that the overlay was for
lv_group_t* group = lv_group_get_default();
if (group != nullptr && lv_group_get_focused(group) == overlay) {
if (tile != nullptr) {
lv_group_focus_obj(lv_obj_get_parent(tile));
lv_gridnav_set_focused(lv_obj_get_parent(tile), tile, LV_ANIM_OFF);
}
}
}
void View::onOverlayKey(uint32_t key) {
if (key == LV_KEY_ESC) {
hideOverlay();
}
}
void View::showActionsForDirectory(lv_obj_t* tile) {
lv_obj_clean(overlay);
addCommonFileActions();
showOverlay(tile);
}
void View::showActionsForFile(lv_obj_t* tile) {
lv_obj_clean(overlay);
if (isExecutablePath(state->getSelectedChildPath())) {
addOverlayButton(LVGL_ICON_SHARED_PLAY_ARROW, onRunPressedCallback);
}
addCommonFileActions();
showOverlay(tile);
}
void View::showActionsForMountPoint(lv_obj_t* tile) {
lv_obj_clean(overlay);
addOverlayButton(LVGL_ICON_SHARED_EJECT, onEjectPressedCallback);
showOverlay(tile);
}
void View::onRunPressed() {
@ -493,76 +598,23 @@ void View::onEjectPressed() {
onNavigate();
state->setEntriesForPath(state->getCurrentPath());
update();
update(false);
}
void View::update(size_t start_index) {
const bool is_root = (state->getCurrentPath() == "/");
void View::update(bool firstPage) {
if (!lvgl_try_lock(500 / portTICK_PERIOD_MS)) {
LOG_E(TAG, "Mutex acquisition timeout (%s)", "lvgl");
return;
}
lv_obj_clean(dir_entry_list);
const bool is_root = (state->getCurrentPath() == "/");
lv_obj_set_hidden(navigate_up_button, is_root);
lv_obj_set_hidden(overflow_button, is_root);
current_start_index = start_index;
state->withEntries([this, is_root](const std::vector<dirent>& entries) {
size_t total_entries = entries.size();
if (current_start_index >= total_entries) {
current_start_index = (total_entries > MAX_BATCH)
? (total_entries - MAX_BATCH)
: 0;
}
size_t count = 0;
if (!is_root && current_start_index > 0) {
auto* back_btn = lvgl_list_add_button(dir_entry_list, LV_SYMBOL_LEFT, "Back");
lv_obj_add_event_cb(back_btn, [](lv_event_t* event) {
auto* view = static_cast<View*>(lv_event_get_user_data(event));
size_t new_index = (view->current_start_index >= view->MAX_BATCH) ?
view->current_start_index - view->MAX_BATCH : 0;
view->update(new_index); }, LV_EVENT_SHORT_CLICKED, this);
}
for (size_t i = current_start_index; i < total_entries; ++i) {
auto entry = entries[i];
createDirEntryWidget(dir_entry_list, entry);
count++;
if (count >= MAX_BATCH) {
break;
}
}
last_loaded_index = std::min(current_start_index + count, total_entries);
if (!is_root && last_loaded_index < total_entries) {
if (total_entries > current_start_index &&
(total_entries - current_start_index) > MAX_BATCH) {
auto* next_btn = lvgl_list_add_button(dir_entry_list, LV_SYMBOL_RIGHT, "Next");
lv_obj_add_event_cb(next_btn, [](lv_event_t* event) {
auto* view = static_cast<View*>(lv_event_get_user_data(event));
view->update(view->last_loaded_index); }, LV_EVENT_SHORT_CLICKED, this);
}
} else {
last_loaded_index = total_entries;
}
});
if (is_root) {
lv_obj_set_hidden(navigate_up_button, true);
} else {
lv_obj_set_hidden(navigate_up_button, false);
}
if (state->hasClipboard() && !is_root) {
lv_obj_set_hidden(paste_button, false);
} else {
lv_obj_set_hidden(paste_button, true);
if (firstPage) {
grid.showFirstPage();
}
grid.requestRepopulate(!firstPage);
lvgl_unlock();
}
@ -573,48 +625,38 @@ void View::init(uint32_t appInstanceId, lv_obj_t* parent) {
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
const bool is_root = (state->getCurrentPath() == "/");
auto* toolbar = lvgl_toolbar_create(parent, "Files");
// The global toolbar nav callback only knows how to stop old-model apps.
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressedCallback, this);
navigate_up_button = lvgl_toolbar_add_image_button_action(toolbar, LV_SYMBOL_UP, &onNavigateUpPressedCallback, this);
new_file_button = lvgl_toolbar_add_image_button_action(toolbar, LV_SYMBOL_FILE, &onNewFilePressedCallback, this);
new_folder_button = lvgl_toolbar_add_image_button_action(toolbar, LV_SYMBOL_DIRECTORY, &onNewFolderPressedCallback, this);
paste_button = lvgl_toolbar_add_image_button_action(toolbar, LV_SYMBOL_PASTE, &onPastePressedCallback, this);
lv_obj_set_hidden(paste_button, true);
lv_obj_set_hidden(navigate_up_button, is_root);
auto* previous_button = lvgl_toolbar_add_text_button_action(toolbar, "<", onPageButtonPressedCallback, this);
auto* next_button = lvgl_toolbar_add_text_button_action(toolbar, ">", onPageButtonPressedCallback, this);
overflow_button = lvgl_toolbar_add_text_button_action(toolbar, LVGL_ICON_SHARED_MORE_VERT, onOverflowPressedCallback, this);
lv_obj_set_style_text_font(overflow_button, lvgl_get_shared_icon_default_font(), LV_STATE_DEFAULT);
lv_obj_set_hidden(overflow_button, is_root);
auto* wrapper = lv_obj_create(parent);
lv_obj_set_width(wrapper, LV_PCT(100));
lv_obj_set_style_border_width(wrapper, 0, 0);
lv_obj_set_style_pad_all(wrapper, 0, 0);
lv_obj_set_flex_grow(wrapper, 1);
lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_ROW);
grid.setSwipeNavigation(true);
grid.createWidgets(parent);
grid.setPageButtons(previous_button, next_button);
dir_entry_list = lvgl_list_create(wrapper);
lv_obj_set_height(dir_entry_list, LV_PCT(100));
lv_obj_set_flex_grow(dir_entry_list, 1);
lv_obj_add_event_cb(dir_entry_list, dirEntryListScrollBeginCallback, LV_EVENT_SCROLL_BEGIN, this);
action_list = lvgl_list_create(wrapper);
lv_obj_set_height(action_list, LV_PCT(100));
lv_obj_set_flex_grow(action_list, 1);
lv_obj_set_hidden(action_list, true);
update();
}
void View::onDirEntryListScrollBegin() {
if (lvgl_try_lock(500 / portTICK_PERIOD_MS)) {
lv_obj_set_hidden(action_list, true);
lvgl_unlock();
}
// Floating, so showing it doesn't change the grid's size and page layout
overlay = lvgl_card_create(parent);
lv_obj_set_floating(overlay, true);
lv_obj_set_size(overlay, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(overlay, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(overlay, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_scrollable(overlay, false);
lvgl_obj_add_edge_padding(overlay);
// The arrow keys move between the overlay's buttons as one row
lvgl_grid_navigation_add(overlay);
lv_obj_add_event_cb(overlay, onOverlayKeyCallback, LV_EVENT_KEY, this);
lv_obj_set_hidden(overlay, true);
}
void View::onNavigate() {
if (lvgl_try_lock(500 / portTICK_PERIOD_MS)) {
lv_obj_set_hidden(action_list, true);
lvgl_unlock();
}
hideOverlay();
}
void View::onResult(uint32_t launchId, int32_t result) {
@ -656,7 +698,7 @@ void View::onResult(uint32_t launchId, int32_t result) {
}
state->setEntriesForPath(state->getCurrentPath());
update();
update(false);
}
break;
}
@ -678,7 +720,7 @@ void View::onResult(uint32_t launchId, int32_t result) {
}
state->setEntriesForPath(state->getCurrentPath());
update();
update(false);
}
break;
}
@ -705,7 +747,7 @@ void View::onResult(uint32_t launchId, int32_t result) {
}
state->setEntriesForPath(state->getCurrentPath());
update();
update(false);
}
break;
}
@ -727,7 +769,7 @@ void View::onResult(uint32_t launchId, int32_t result) {
}
state->setEntriesForPath(state->getCurrentPath());
update();
update(false);
}
break;
}
@ -790,7 +832,7 @@ void View::onCopyPressed() {
state->setClipboard(path, false);
LOG_I(TAG, "Copied to clipboard: %s", path.c_str());
onNavigate();
update();
update(false);
}
void View::onCutPressed() {
@ -798,10 +840,11 @@ void View::onCutPressed() {
state->setClipboard(path, true);
LOG_I(TAG, "Cut to clipboard: %s", path.c_str());
onNavigate();
update();
update(false);
}
void View::onPastePressed() {
onNavigate();
auto clipboard = state->getClipboard();
if (!clipboard.has_value()) return;
@ -873,11 +916,7 @@ void View::doPaste(const std::string& src, bool is_cut, const std::string& dst)
}
state->setEntriesForPath(state->getCurrentPath());
update();
}
void View::deinit() {
lv_obj_remove_event_cb(dir_entry_list, dirEntryListScrollBeginCallback);
update(false);
}
} // namespace tt::app::files

View File

@ -76,7 +76,7 @@ void collectIncompatibleAppIds(const AppPackage* package, void* context) {
}
}
std::vector<AppGridItem> collectItems() {
std::vector<TileGridItem> collectItems() {
const std::vector<std::string> favouriteIds = favourites.load();
std::vector<::AppManifest> collected;
@ -87,34 +87,34 @@ std::vector<AppGridItem> collectItems() {
return !isShown(manifest) || std::ranges::find(incompatibleIds, std::string(manifest.id)) != incompatibleIds.end();
});
std::vector<AppGridItem> items;
std::vector<TileGridItem> items;
items.reserve(collected.size());
for (const auto& manifest : collected) {
items.push_back({ manifest, appIcon(&manifest), Favourites::contains(favouriteIds, manifest.id) });
items.push_back({ manifest.id, manifest.name, appIcon(&manifest), Favourites::contains(favouriteIds, manifest.id) });
}
std::ranges::sort(items, [](const AppGridItem& a, const AppGridItem& b) {
std::ranges::sort(items, [](const TileGridItem& a, const TileGridItem& b) {
if (a.highlighted != b.highlighted) {
return a.highlighted;
}
return strcmp(a.manifest.name, b.manifest.name) < 0;
return a.name < b.name;
});
return items;
}
void toggleFavourite(AppGrid& grid, const char* appId, bool keepSelection) {
void toggleFavourite(TileGrid& grid, const char* appId, bool keepSelection) {
if (!favourites.toggle(appId)) {
LOG_E(TAG, "Failed to save favourites");
}
grid.requestRepopulate(keepSelection);
}
void onLongPressed(AppGrid& grid, const ::AppManifest& manifest) {
toggleFavourite(grid, manifest.id, false);
void onLongPressed(TileGrid& grid, const TileGridItem& item) {
toggleFavourite(grid, item.id.c_str(), false);
}
void onKey(AppGrid& grid, const ::AppManifest& manifest, uint32_t key) {
void onKey(TileGrid& grid, const TileGridItem& item, uint32_t key) {
if (key == 'f' || key == 'F') {
toggleFavourite(grid, manifest.id, true);
toggleFavourite(grid, item.id.c_str(), true);
}
}
@ -122,7 +122,7 @@ void onKey(AppGrid& grid, const ::AppManifest& manifest, uint32_t key) {
const LauncherMode APPS_MODE = {
.buttonIcon = LVGL_ICON_SHARED_APPS,
.iconColor = AppGrid::IconColor::Primary,
.iconColor = TileGrid::IconColor::Primary,
.collect = collectItems,
.onLongPressed = onLongPressed,
.onKey = onKey

View File

@ -18,7 +18,7 @@
#include <tactility/drivers/power_supply.h>
#include <tactility/log.h>
#include <Tactility/app/AppGrid.h>
#include <Tactility/app/TileGrid.h>
#include <Tactility/app/launcher/LauncherMode.h>
#include <Tactility/app/setup/Setup.h>
#include <Tactility/settings/BootSettings.h>
@ -30,10 +30,10 @@ constexpr auto* TAG = "Launcher";
namespace {
std::vector<AppGridItem> collectItems(void* userData);
void onAppClicked(const ::AppManifest& manifest, void* userData);
void onAppLongPressed(const ::AppManifest& manifest, void* userData);
void onAppKey(const ::AppManifest& manifest, uint32_t key, void* userData);
std::vector<TileGridItem> collectItems(void* userData);
void onAppClicked(const TileGridItem& item, void* userData);
void onAppLongPressed(const TileGridItem& item, void* userData);
void onAppKey(const TileGridItem& item, uint32_t key, void* userData);
enum class Mode {
Apps,
@ -51,16 +51,18 @@ Mode getOtherMode(Mode mode) {
struct Context {
Mode mode = Mode::Apps;
lv_obj_t* modeButtonIcon = nullptr;
AppGrid grid { AppGrid::Callbacks {
TileGrid grid { TileGrid::Callbacks {
.collect = collectItems,
.onClicked = onAppClicked,
.onLongPressed = onAppLongPressed,
.onKey = onAppKey,
.resolveIcon = nullptr,
.onPageChanged = nullptr,
.userData = this
} };
};
std::vector<AppGridItem> collectItems(void* userData) {
std::vector<TileGridItem> collectItems(void* userData) {
return getLauncherMode(static_cast<Context*>(userData)->mode).collect();
}
@ -72,23 +74,23 @@ void startApp(const char* appId) {
}
}
void onAppClicked(const ::AppManifest& manifest, void*) {
startApp(manifest.id);
void onAppClicked(const TileGridItem& item, void*) {
startApp(item.id.c_str());
}
void onAppLongPressed(const ::AppManifest& manifest, void* userData) {
void onAppLongPressed(const TileGridItem& item, void* userData) {
auto* ctx = static_cast<Context*>(userData);
const LauncherMode& mode = getLauncherMode(ctx->mode);
if (mode.onLongPressed != nullptr) {
mode.onLongPressed(ctx->grid, manifest);
mode.onLongPressed(ctx->grid, item);
}
}
void onAppKey(const ::AppManifest& manifest, uint32_t key, void* userData) {
void onAppKey(const TileGridItem& item, uint32_t key, void* userData) {
auto* ctx = static_cast<Context*>(userData);
const LauncherMode& mode = getLauncherMode(ctx->mode);
if (mode.onKey != nullptr) {
mode.onKey(ctx->grid, manifest, key);
mode.onKey(ctx->grid, item, key);
}
}

View File

@ -49,17 +49,17 @@ void collectManifest(const ::AppManifest* manifest, void* context) {
manifests->push_back(*manifest);
}
std::vector<AppGridItem> collectItems() {
std::vector<TileGridItem> collectItems() {
std::vector<::AppManifest> collected;
app_manager_for_each_manifest(collectManifest, &collected);
std::ranges::sort(collected, [](const ::AppManifest& a, const ::AppManifest& b) {
return strcmp(a.name, b.name) < 0;
});
std::vector<AppGridItem> items;
std::vector<TileGridItem> items;
for (const auto& manifest : collected) {
if (manifest.category == APP_CATEGORY_SETTINGS && (manifest.flags & APP_MANIFEST_FLAG_HIDDEN) == 0) {
items.push_back({ manifest, appIcon(&manifest), false });
items.push_back({ manifest.id, manifest.name, appIcon(&manifest), false });
}
}
return items;
@ -69,7 +69,7 @@ std::vector<AppGridItem> collectItems() {
const LauncherMode SETTINGS_MODE = {
.buttonIcon = LVGL_ICON_SHARED_SETTINGS,
.iconColor = AppGrid::IconColor::Secondary,
.iconColor = TileGrid::IconColor::Secondary,
.collect = collectItems,
.onLongPressed = nullptr,
.onKey = nullptr

View File

@ -15,7 +15,13 @@
#include <lvgl_window_manager/window_manager.h>
#include <lvgl.h>
#include <lvgl/fonts.h>
#include <lvgl/grid_navigation.h>
#include <lvgl/icons/shared.h>
#include <lvgl/insets.h>
#include <lvgl/lvgl.h>
#include <lvgl/widgets/card.h>
#include <lvgl/widgets/icon_button.h>
#include <lvgl/widgets/toolbar.h>
#include <tactility/check.h>
#include <tactility/log.h>
@ -33,7 +39,9 @@ struct Context {
TaskEventGroup* eventGroup = nullptr;
lv_obj_t* uiCurrentFileName = nullptr;
lv_obj_t* uiDropDownMenu = nullptr;
lv_obj_t* uiToolbar = nullptr;
lv_obj_t* uiOverflowButton = nullptr;
lv_obj_t* uiOverlay = nullptr;
lv_obj_t* uiNoteText = nullptr;
std::string filePath;
@ -77,50 +85,100 @@ bool saveFile(Context* ctx, const std::string& path) {
return result;
}
void appNotesEventCb(lv_event_t* e) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(e));
lv_event_code_t code = lv_event_get_code(e);
lv_obj_t* obj = lv_event_get_target_obj(e);
void hideOverlay(Context* ctx) {
if (lv_obj_is_hidden(ctx->uiOverlay)) {
return;
}
lv_obj_set_hidden(ctx->uiOverlay, true);
if (code == LV_EVENT_VALUE_CHANGED) {
if (obj == ctx->uiDropDownMenu) {
switch (lv_dropdown_get_selected(obj)) {
case 0: // New
resetFileContent(ctx);
break;
case 1: // Save
if (!ctx->filePath.empty()) {
lvgl_lock();
ctx->saveBuffer = lv_textarea_get_text(ctx->uiNoteText);
lvgl_unlock();
saveFile(ctx, ctx->filePath);
}
break;
case 2: // Save as...
lvgl_lock();
ctx->saveBuffer = lv_textarea_get_text(ctx->uiNoteText);
lvgl_unlock();
ctx->saveFileLaunchId = fileselection::startForExistingOrNewFile(ctx->appInstanceId, ctx->saveResultStream, ctx->saveResultBuffer, sizeof(ctx->saveResultBuffer), ctx->eventGroup);
LOG_I(TAG, "launched with id %u", ctx->saveFileLaunchId);
break;
case 3: // Load
ctx->loadFileLaunchId = fileselection::startForExistingFile(ctx->appInstanceId, ctx->loadResultStream, ctx->loadResultBuffer, sizeof(ctx->loadResultBuffer), ctx->eventGroup);
LOG_I(TAG, "launched with id %u", ctx->loadFileLaunchId);
break;
}
} else {
auto* cont = lv_event_get_current_target_obj(e);
if (obj == cont) return;
if (lv_obj_get_child(cont, 1)) {
ctx->saveFileLaunchId = fileselection::startForExistingOrNewFile(ctx->appInstanceId, ctx->saveResultStream, ctx->saveResultBuffer, sizeof(ctx->saveResultBuffer), ctx->eventGroup);
LOG_I(TAG, "launched with id %u", ctx->saveFileLaunchId);
} else { //Reset
resetFileContent(ctx);
}
// Keys continue on the button that opened the overlay
lv_group_t* group = lv_group_get_default();
if (group != nullptr && lv_group_get_focused(group) == ctx->uiOverlay) {
lv_group_focus_obj(ctx->uiToolbar);
lv_gridnav_set_focused(ctx->uiToolbar, ctx->uiOverflowButton, LV_ANIM_OFF);
}
}
void onNewPressed(lv_event_t* e) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(e));
hideOverlay(ctx);
resetFileContent(ctx);
}
void onSavePressed(lv_event_t* e) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(e));
hideOverlay(ctx);
ctx->saveBuffer = lv_textarea_get_text(ctx->uiNoteText);
saveFile(ctx, ctx->filePath);
}
void onSaveAsPressed(lv_event_t* e) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(e));
hideOverlay(ctx);
ctx->saveBuffer = lv_textarea_get_text(ctx->uiNoteText);
ctx->saveFileLaunchId = fileselection::startForExistingOrNewFile(ctx->appInstanceId, ctx->saveResultStream, ctx->saveResultBuffer, sizeof(ctx->saveResultBuffer), ctx->eventGroup);
LOG_I(TAG, "launched with id %u", ctx->saveFileLaunchId);
}
void onOpenPressed(lv_event_t* e) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(e));
hideOverlay(ctx);
ctx->loadFileLaunchId = fileselection::startForExistingFile(ctx->appInstanceId, ctx->loadResultStream, ctx->loadResultBuffer, sizeof(ctx->loadResultBuffer), ctx->eventGroup);
LOG_I(TAG, "launched with id %u", ctx->loadFileLaunchId);
}
void addOverlayButton(Context* ctx, const char* icon, lv_event_cb_t callback) {
auto* button = lvgl_icon_button_create(ctx->uiOverlay);
auto* label = lv_label_create(button);
lv_obj_set_style_text_font(label, lvgl_get_shared_icon_default_font(), LV_STATE_DEFAULT);
lv_label_set_text(label, icon);
lv_obj_add_event_cb(button, callback, LV_EVENT_SHORT_CLICKED, ctx);
}
void showOverlay(Context* ctx) {
lv_obj_clean(ctx->uiOverlay);
addOverlayButton(ctx, LVGL_ICON_SHARED_NOTE_ADD, onNewPressed);
// Saving needs a file, which "save as" picks
if (!ctx->filePath.empty()) {
addOverlayButton(ctx, LVGL_ICON_SHARED_SAVE, onSavePressed);
}
addOverlayButton(ctx, LVGL_ICON_SHARED_SAVE_AS, onSaveAsPressed);
addOverlayButton(ctx, LVGL_ICON_SHARED_FOLDER_OPEN, onOpenPressed);
lv_obj_set_hidden(ctx->uiOverlay, false);
lv_obj_align_to(ctx->uiOverlay, ctx->uiToolbar, LV_ALIGN_OUT_BOTTOM_MID, 0, 0);
lv_group_t* group = lv_group_get_default();
if (group != nullptr) {
lv_group_focus_obj(ctx->uiOverlay);
lv_gridnav_set_focused(ctx->uiOverlay, lv_obj_get_child(ctx->uiOverlay, 0), LV_ANIM_OFF);
// Touch shows no selection, until a key moves the focus
lv_indev_t* indev = lv_indev_active();
if (indev != nullptr && lv_indev_get_type(indev) == LV_INDEV_TYPE_POINTER) {
lvgl_focus_hide_key_selection(group);
}
}
}
void onOverflowPressed(lv_event_t* e) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(e));
if (lv_obj_is_hidden(ctx->uiOverlay)) {
showOverlay(ctx);
} else {
hideOverlay(ctx);
}
}
void onOverlayKey(lv_event_t* e) {
if (lv_event_get_key(e) == LV_KEY_ESC) {
hideOverlay(static_cast<Context*>(lv_event_get_user_data(e)));
}
}
void onNoteTextPressed(lv_event_t* e) {
hideOverlay(static_cast<Context*>(lv_event_get_user_data(e)));
}
void createWidgets(lv_obj_t* parent, void* userData) {
auto* ctx = static_cast<Context*>(userData);
@ -128,16 +186,10 @@ void createWidgets(lv_obj_t* parent, void* userData) {
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
lv_obj_t* toolbar = lvgl_toolbar_create(parent, "Notes");
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
ctx->uiDropDownMenu = lv_dropdown_create(toolbar);
lv_dropdown_set_options(ctx->uiDropDownMenu, LV_SYMBOL_FILE " New File\n" LV_SYMBOL_SAVE " Save\n" LV_SYMBOL_SAVE " Save As...\n" LV_SYMBOL_DIRECTORY " Open File");
lv_dropdown_set_text(ctx->uiDropDownMenu, "Menu");
lv_dropdown_set_symbol(ctx->uiDropDownMenu, LV_SYMBOL_DOWN);
lv_dropdown_set_selected_highlight(ctx->uiDropDownMenu, false);
lv_obj_align(ctx->uiDropDownMenu, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_add_event_cb(ctx->uiDropDownMenu, appNotesEventCb, LV_EVENT_VALUE_CHANGED, ctx);
ctx->uiToolbar = lvgl_toolbar_create(parent, "Notes");
lv_obj_align(ctx->uiToolbar, LV_ALIGN_TOP_MID, 0, 0);
ctx->uiOverflowButton = lvgl_toolbar_add_text_button_action(ctx->uiToolbar, LVGL_ICON_SHARED_MORE_VERT, onOverflowPressed, ctx);
lv_obj_set_style_text_font(ctx->uiOverflowButton, lvgl_get_shared_icon_default_font(), LV_STATE_DEFAULT);
lv_obj_t* wrapper = lv_obj_create(parent);
lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN);
@ -158,17 +210,18 @@ void createWidgets(lv_obj_t* parent, void* userData) {
lv_obj_set_style_bg_color(ctx->uiNoteText, lv_color_hex(0x262626), LV_PART_MAIN);
}
lv_textarea_set_placeholder_text(ctx->uiNoteText, "Notes...");
lv_obj_add_event_cb(ctx->uiNoteText, onNoteTextPressed, LV_EVENT_PRESSED, ctx);
lv_obj_t* footer = lv_obj_create(wrapper);
lv_obj_set_flex_flow(footer, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(footer, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
// Shows the screen background
lv_obj_set_style_bg_opa(footer, LV_OPA_TRANSP, LV_PART_MAIN);
if (lv_display_get_color_format(lv_obj_get_display(parent)) == LV_COLOR_FORMAT_L8) {
lv_obj_set_style_bg_color(footer, lv_color_hex(0xEEEEEE), LV_PART_MAIN);
lv_obj_set_style_border_width(footer, 1, LV_PART_MAIN);
lv_obj_set_style_border_color(footer, lv_theme_get_color_secondary(footer), LV_PART_MAIN);
lv_obj_set_style_border_side(footer, LV_BORDER_SIDE_TOP, LV_PART_MAIN);
} else {
lv_obj_set_style_bg_color(footer, lv_color_hex(0x262626), LV_PART_MAIN);
lv_obj_set_style_border_width(footer, 0, LV_PART_MAIN);
}
lv_obj_set_width(footer, LV_PCT(100));
@ -183,6 +236,19 @@ void createWidgets(lv_obj_t* parent, void* userData) {
lv_label_set_text(ctx->uiCurrentFileName, "Untitled");
lv_obj_align(ctx->uiCurrentFileName, LV_ALIGN_CENTER, 0, 0);
// Floating over the text area, below the toolbar
ctx->uiOverlay = lvgl_card_create(parent);
lv_obj_set_floating(ctx->uiOverlay, true);
lv_obj_set_size(ctx->uiOverlay, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(ctx->uiOverlay, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(ctx->uiOverlay, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_scrollable(ctx->uiOverlay, false);
lvgl_obj_add_edge_padding(ctx->uiOverlay);
// The arrow keys move between the overlay's buttons as one row
lvgl_grid_navigation_add(ctx->uiOverlay);
lv_obj_add_event_cb(ctx->uiOverlay, onOverlayKey, LV_EVENT_KEY, ctx);
lv_obj_set_hidden(ctx->uiOverlay, true);
if (!ctx->filePath.empty()) {
openFile(ctx, ctx->filePath);
}