From a41745bc923df982f263ad7b1746b5b98dd00fa6 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Fri, 9 Oct 2026 01:08:15 +0200 Subject: [PATCH] Refactor files app + re-use app grid as tile grid --- Modules/lvgl-module/generate-icons.py | 12 + .../lvgl-module/include/lvgl/icons/names.h | 2 +- .../lvgl-module/include/lvgl/icons/shared.h | 11 + Modules/lvgl-module/source/icon_names.c | 11 + .../source/themes/lv_theme_material.c | 7 + .../source/themes/lv_theme_material_mono.c | 1 + Modules/lvgl-module/themes/material.lss | 8 + Modules/lvgl-module/themes/material_mono.lss | 1 + .../Tactility/app/{AppGrid.h => TileGrid.h} | 51 +- Tactility/Private/Tactility/app/files/State.h | 2 - Tactility/Private/Tactility/app/files/View.h | 51 +- .../Tactility/app/launcher/LauncherMode.h | 10 +- .../Source/app/{AppGrid.cpp => TileGrid.cpp} | 155 ++++-- Tactility/Source/app/files/FilesApp.cpp | 1 - Tactility/Source/app/files/State.cpp | 14 - Tactility/Source/app/files/View.cpp | 501 ++++++++++-------- Tactility/Source/app/launcher/AppsMode.cpp | 22 +- Tactility/Source/app/launcher/Launcher.cpp | 28 +- .../Source/app/launcher/SettingsMode.cpp | 8 +- 19 files changed, 524 insertions(+), 372 deletions(-) rename Tactility/Private/Tactility/app/{AppGrid.h => TileGrid.h} (63%) rename Tactility/Source/app/{AppGrid.cpp => TileGrid.cpp} (76%) diff --git a/Modules/lvgl-module/generate-icons.py b/Modules/lvgl-module/generate-icons.py index 3bfad102c..c983bd9cb 100755 --- a/Modules/lvgl-module/generate-icons.py +++ b/Modules/lvgl-module/generate-icons.py @@ -182,6 +182,18 @@ def generate_icon_names(codepoint_map: dict, codepoint_names: list, variable_nam "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", ] # Get more from https://fonts.google.com/icons?icon.set=Material+Symbols&icon.style=Rounded diff --git a/Modules/lvgl-module/include/lvgl/icons/names.h b/Modules/lvgl-module/include/lvgl/icons/names.h index fabc14950..9d08db392 100644 --- a/Modules/lvgl-module/include/lvgl/icons/names.h +++ b/Modules/lvgl-module/include/lvgl/icons/names.h @@ -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 0xB2450B17u // Icon names per icon font, resolved through the .codepoints file next to the TTF extern const char* const lvgl_icon_shared_names[]; diff --git a/Modules/lvgl-module/include/lvgl/icons/shared.h b/Modules/lvgl-module/include/lvgl/icons/shared.h index e9d473a02..fa63b1527 100644 --- a/Modules/lvgl-module/include/lvgl/icons/shared.h +++ b/Modules/lvgl-module/include/lvgl/icons/shared.h @@ -55,3 +55,14 @@ #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" diff --git a/Modules/lvgl-module/source/icon_names.c b/Modules/lvgl-module/source/icon_names.c index af585e7fa..e0cf41aa8 100644 --- a/Modules/lvgl-module/source/icon_names.c +++ b/Modules/lvgl-module/source/icon_names.c @@ -57,6 +57,17 @@ 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", }; const size_t lvgl_icon_shared_name_count = sizeof(lvgl_icon_shared_names) / sizeof(lvgl_icon_shared_names[0]); diff --git a/Modules/lvgl-module/source/themes/lv_theme_material.c b/Modules/lvgl-module/source/themes/lv_theme_material.c index 2db0fade5..88b0a58fd 100644 --- a/Modules/lvgl-module/source/themes/lv_theme_material.c +++ b/Modules/lvgl-module/source/themes/lv_theme_material.c @@ -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); diff --git a/Modules/lvgl-module/source/themes/lv_theme_material_mono.c b/Modules/lvgl-module/source/themes/lv_theme_material_mono.c index ec324bf6b..41d1b866c 100644 --- a/Modules/lvgl-module/source/themes/lv_theme_material_mono.c +++ b/Modules/lvgl-module/source/themes/lv_theme_material_mono.c @@ -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); diff --git a/Modules/lvgl-module/themes/material.lss b/Modules/lvgl-module/themes/material.lss index a4dab082e..e955685d5 100644 --- a/Modules/lvgl-module/themes/material.lss +++ b/Modules/lvgl-module/themes/material.lss @@ -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; diff --git a/Modules/lvgl-module/themes/material_mono.lss b/Modules/lvgl-module/themes/material_mono.lss index 09a9f8135..031be450d 100644 --- a/Modules/lvgl-module/themes/material_mono.lss +++ b/Modules/lvgl-module/themes/material_mono.lss @@ -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; diff --git a/Tactility/Private/Tactility/app/AppGrid.h b/Tactility/Private/Tactility/app/TileGrid.h similarity index 63% rename from Tactility/Private/Tactility/app/AppGrid.h rename to Tactility/Private/Tactility/app/TileGrid.h index 3b433cd74..49f323d0b 100644 --- a/Tactility/Private/Tactility/app/AppGrid.h +++ b/Tactility/Private/Tactility/app/TileGrid.h @@ -1,43 +1,47 @@ #pragma once -#include - #include #include +#include #include 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 (*collect)(void* userData); - void (*onClicked)(const ::AppManifest& manifest, void* userData); + std::vector (*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 @@ class AppGrid final { /** 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 @@ class AppGrid final { */ 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 @@ class AppGrid final { /** * 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 items; + std::vector items; uint32_t page = 0; uint32_t pageCount = 1; IconColor iconColor = IconColor::Default; @@ -99,6 +117,7 @@ class AppGrid final { lv_obj_t* barSpacer = nullptr; void createGrid(lv_obj_t* parent); + void finishCreate(lv_obj_t* parent); void goToPreviousPage(); void goToNextPage(); void populate(); diff --git a/Tactility/Private/Tactility/app/files/State.h b/Tactility/Private/Tactility/app/files/State.h index b619cab3a..81e71f73a 100644 --- a/Tactility/Private/Tactility/app/files/State.h +++ b/Tactility/Private/Tactility/app/files/State.h @@ -61,8 +61,6 @@ class State final { }); } - bool getDirent(uint32_t index, dirent& dirent); - void setSelectedChildEntry(const std::string& newFile) { selected_child_entry = newFile; action = ActionNone; diff --git a/Tactility/Private/Tactility/app/files/View.h b/Tactility/Private/Tactility/app/files/View.h index 6483cd6b6..679e972d9 100644 --- a/Tactility/Private/Tactility/app/files/View.h +++ b/Tactility/Private/Tactility/app/files/View.h @@ -2,6 +2,8 @@ #include "./State.h" +#include + #include #include @@ -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 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, TaskEventGroup* eventGroup) : state(state), eventGroup(eventGroup) {} + View(const std::shared_ptr& 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 @@ class View final { 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); }; diff --git a/Tactility/Private/Tactility/app/launcher/LauncherMode.h b/Tactility/Private/Tactility/app/launcher/LauncherMode.h index df6ae7c1a..43c23381d 100644 --- a/Tactility/Private/Tactility/app/launcher/LauncherMode.h +++ b/Tactility/Private/Tactility/app/launcher/LauncherMode.h @@ -1,6 +1,6 @@ #pragma once -#include +#include #include #include @@ -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 (*collect)(); + std::vector (*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; diff --git a/Tactility/Source/app/AppGrid.cpp b/Tactility/Source/app/TileGrid.cpp similarity index 76% rename from Tactility/Source/app/AppGrid.cpp rename to Tactility/Source/app/TileGrid.cpp index ce741a0ec..34a24c3a2 100644 --- a/Tactility/Source/app/AppGrid.cpp +++ b/Tactility/Source/app/TileGrid.cpp @@ -1,4 +1,4 @@ -#include +#include #include #include @@ -51,8 +51,8 @@ PageLayout computePageLayout(lv_obj_t* grid) { }; } -const AppGridItem* getTileItem(lv_event_t* e) { - return static_cast(lv_obj_get_user_data(lv_event_get_target_obj(e))); +const TileGridItem* getTileItem(lv_event_t* e) { + return static_cast(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(userData)->goToNextPage(); +void TileGrid::onDeferredNextPage(void* userData) { + static_cast(userData)->goToNextPage(); } -void AppGrid::onDeferredPreviousPage(void* userData) { - static_cast(userData)->goToPreviousPage(); +void TileGrid::onDeferredPreviousPage(void* userData) { + static_cast(userData)->goToPreviousPage(); } -void AppGrid::onDeferredRepopulate(void* userData) { - static_cast(userData)->populate(); +void TileGrid::onDeferredRepopulate(void* userData) { + static_cast(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(userData); +void TileGrid::onDeferredResize(void* userData) { + auto* self = static_cast(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(lv_event_get_user_data(e))->goToPreviousPage(); +void TileGrid::onPrevPressed(lv_event_t* e) { + static_cast(lv_event_get_user_data(e))->goToPreviousPage(); } -void AppGrid::onNextPressed(lv_event_t* e) { - static_cast(lv_event_get_user_data(e))->goToNextPage(); +void TileGrid::onNextPressed(lv_event_t* e) { + static_cast(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(lv_event_get_user_data(e)); +void TileGrid::onGridGesture(lv_event_t* e) { + auto* self = static_cast(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(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(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(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(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(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(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(lv_obj_get_user_data(focused))->manifest.id; + focusedId = static_cast(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(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(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(&item)); + lv_obj_set_user_data(tile, const_cast(&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(lv_event_get_user_data(e)); +void TileGrid::onBottomBarFocused(lv_event_t* e) { + const auto* self = static_cast(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(i)); + if (static_cast(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); diff --git a/Tactility/Source/app/files/FilesApp.cpp b/Tactility/Source/app/files/FilesApp.cpp index 62c72acaa..458f2bd18 100644 --- a/Tactility/Source/app/files/FilesApp.cpp +++ b/Tactility/Source/app/files/FilesApp.cpp @@ -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); diff --git a/Tactility/Source/app/files/State.cpp b/Tactility/Source/app/files/State.cpp index 7049a43a0..02c0c2a82 100644 --- a/Tactility/Source/app/files/State.cpp +++ b/Tactility/Source/app/files/State.cpp @@ -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; - } -} - } diff --git a/Tactility/Source/app/files/View.cpp b/Tactility/Source/app/files/View.cpp index 8974ddbd9..3ae28c6d7 100644 --- a/Tactility/Source/app/files/View.cpp +++ b/Tactility/Source/app/files/View.cpp @@ -4,8 +4,13 @@ #include #include +#include +#include +#include +#include #include -#include +#include +#include #include #include @@ -22,6 +27,7 @@ #include #include +#include #include #include #include @@ -36,36 +42,11 @@ constexpr auto* TAG = "Files"; // region Callbacks -static void dirEntryListScrollBeginCallback(lv_event_t* event) { - auto* view = static_cast(lv_event_get_user_data(event)); - view->onDirEntryListScrollBegin(); -} - static void onBackPressedCallback(lv_event_t* event) { auto* view = static_cast(lv_event_get_user_data(event)); view->onBackPressed(); } -static void onDirEntryPressedCallback(lv_event_t* event) { - auto* view = static_cast(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(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(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(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(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(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(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, TaskEventGroup* eventGroup) : + state(state), + eventGroup(eventGroup), + grid(TileGrid::Callbacks { + .collect = collectItems, + .onClicked = onTileClicked, + .onLongPressed = onTileLongPressed, + .onKey = onTileKey, + .resolveIcon = resolveIcon, + .onPageChanged = onPageChanged, + .userData = this + }) {} + +std::vector View::collectItems(void* userData) { + auto* view = static_cast(userData); + const bool is_root = (view->state->getCurrentPath() == "/"); + std::vector items; + view->state->withEntries([&](const std::vector& 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; +} - if (has_back && list_index == 0) { - return false; // Back button - } +const char* View::resolveIcon(const TileGridItem& item, void* userData) { + auto* view = static_cast(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(userData)->onDirEntryPressed(item.id); +} + +void View::onTileLongPressed(const TileGridItem& item, void* userData) { + static_cast(userData)->onDirEntryLongPressed(item.id); +} + +void View::onTileKey(const TileGridItem& item, uint32_t key, void* userData) { + static_cast(userData)->onDirEntryKeyPressed(item.id, key); +} - const size_t adjusted_index = - current_start_index + static_cast(list_index) - (has_back ? 1 : 0); +void View::onPageChanged(void* userData) { + static_cast(userData)->hideOverlay(); +} - return state->getDirent(static_cast(adjusted_index), out_entry); +bool View::findDirent(const std::string& name, dirent& out_entry) { + bool found = false; + state->withEntries([&](const std::vector& 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(uint32_t index) { +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(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; - } - - // 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; - } +void View::onDirEntryKeyPressed(const std::string& name, uint32_t key) { + if (key == ' ') { + onDirEntryLongPressed(name); + return; } - 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); -} - -void View::showActions() { - lv_obj_clean(action_list); - addCommonFileActions(); - lv_obj_set_hidden(action_list, false); + 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); +} + +// 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); + } + } + + 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::showActionsForDirectory() { showActions(); } +void View::hideOverlay() { + if (overlay == nullptr || lv_obj_is_hidden(overlay)) { + return; + } + lv_obj_set_hidden(overlay, true); -void View::showActionsForFile() { - lv_obj_clean(action_list); + lv_obj_t* tile = grid.findTile(state->getSelectedChildEntry()); + if (tile != nullptr) { + lv_obj_remove_state(tile, LV_STATE_CHECKED); + } - 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); + // 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); + } } +} - addCommonFileActions(); - lv_obj_set_hidden(action_list, false); +void View::onOverlayKey(uint32_t key) { + if (key == LV_KEY_ESC) { + hideOverlay(); + } } -void View::showActionsForMountPoint() { - lv_obj_clean(action_list); +void View::showActionsForDirectory(lv_obj_t* tile) { + lv_obj_clean(overlay); + addCommonFileActions(); + showOverlay(tile); +} - 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); +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); +} - lv_obj_set_hidden(action_list, false); +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); - - current_start_index = start_index; - - state->withEntries([this, is_root](const std::vector& 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(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(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); - } + const bool is_root = (state->getCurrentPath() == "/"); + lv_obj_set_hidden(navigate_up_button, is_root); + lv_obj_set_hidden(overflow_button, is_root); - 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); - - 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); - - 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(); - } + 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); + + grid.setSwipeNavigation(true); + grid.createWidgets(parent); + grid.setPageButtons(previous_button, next_button); + + // 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 diff --git a/Tactility/Source/app/launcher/AppsMode.cpp b/Tactility/Source/app/launcher/AppsMode.cpp index 8808f7f5f..8968e1943 100644 --- a/Tactility/Source/app/launcher/AppsMode.cpp +++ b/Tactility/Source/app/launcher/AppsMode.cpp @@ -76,7 +76,7 @@ void collectIncompatibleAppIds(const AppPackage* package, void* context) { } } -std::vector collectItems() { +std::vector collectItems() { const std::vector favouriteIds = favourites.load(); std::vector<::AppManifest> collected; @@ -87,34 +87,34 @@ std::vector collectItems() { return !isShown(manifest) || std::ranges::find(incompatibleIds, std::string(manifest.id)) != incompatibleIds.end(); }); - std::vector items; + std::vector 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 diff --git a/Tactility/Source/app/launcher/Launcher.cpp b/Tactility/Source/app/launcher/Launcher.cpp index 2923c1d3d..780044941 100644 --- a/Tactility/Source/app/launcher/Launcher.cpp +++ b/Tactility/Source/app/launcher/Launcher.cpp @@ -18,7 +18,7 @@ #include #include -#include +#include #include #include #include @@ -30,10 +30,10 @@ constexpr auto* TAG = "Launcher"; namespace { -std::vector 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 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 collectItems(void* userData) { +std::vector collectItems(void* userData) { return getLauncherMode(static_cast(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(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(userData); const LauncherMode& mode = getLauncherMode(ctx->mode); if (mode.onKey != nullptr) { - mode.onKey(ctx->grid, manifest, key); + mode.onKey(ctx->grid, item, key); } } diff --git a/Tactility/Source/app/launcher/SettingsMode.cpp b/Tactility/Source/app/launcher/SettingsMode.cpp index b8d95eb6a..8b8307706 100644 --- a/Tactility/Source/app/launcher/SettingsMode.cpp +++ b/Tactility/Source/app/launcher/SettingsMode.cpp @@ -49,17 +49,17 @@ void collectManifest(const ::AppManifest* manifest, void* context) { manifests->push_back(*manifest); } -std::vector collectItems() { +std::vector 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 items; + std::vector 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 collectItems() { const LauncherMode SETTINGS_MODE = { .buttonIcon = LVGL_ICON_SHARED_SETTINGS, - .iconColor = AppGrid::IconColor::Secondary, + .iconColor = TileGrid::IconColor::Secondary, .collect = collectItems, .onLongPressed = nullptr, .onKey = nullptr