diff --git a/firmware/application/ui_navigation.cpp b/firmware/application/ui_navigation.cpp index 331a59524..d8d4a8687 100644 --- a/firmware/application/ui_navigation.cpp +++ b/firmware/application/ui_navigation.cpp @@ -49,6 +49,7 @@ #include "ui_recon.hpp" #include "ui_search.hpp" #include "ui_settings.hpp" +#include "ui_textentry.hpp" #include "ui_sonde.hpp" #include "ui_ss_viewer.hpp" // #include "ui_test.hpp" @@ -139,6 +140,115 @@ bool NavigationView::StartAppByName(const char* name) { return false; } +/* App search ***********************************************************/ + +namespace { + +// ASCII, allocation-free, case-insensitive lowering. Kept local so this TU +// doesn't need to include just for one small comparison. +char ascii_lower(char c) { + return (c >= 'A' && c <= 'Z') ? static_cast(c - 'A' + 'a') : c; +} + +// True when 'needle' occurs anywhere in 'haystack', case-insensitive. +bool name_contains(const char* haystack, const std::string& needle) { + const size_t n = needle.size(); + if (n == 0) + return false; + for (size_t i = 0; haystack[i] != '\0'; ++i) { + size_t j = 0; + while (j < n && haystack[i + j] != '\0' && + ascii_lower(haystack[i + j]) == ascii_lower(needle[j])) + ++j; + if (j == n) + return true; + } + return false; +} + +} // namespace + +void NavigationView::start_app_search() { + app_search_query_.clear(); + app_search_committed_ = false; + + // Reuse the shared keyboard view; it is destroyed as soon as the user + // confirms or cancels, so no search UI ever lingers in RAM. + text_prompt(*this, app_search_query_, 20, ENTER_KEYBOARD_MODE_ALPHA, + [this](std::string& query) { + // Runs while the keyboard is still alive: only record that a + // query was confirmed; results are built once it has popped. + app_search_committed_ = !query.empty(); + }); + + // Defer building/showing the results until the keyboard view has popped. + set_on_pop([this]() { open_app_search_results(); }); +} + +void NavigationView::open_app_search_results() { + if (!app_search_committed_) + return; // user cancelled with Back, or the query was empty + app_search_committed_ = false; + + std::vector matches; + + // Internal apps: match on the name shown in the menus. + for (const auto& app : appList) { + if (app.displayName != nullptr && app.viewFactory != nullptr && + name_contains(app.displayName, app_search_query_)) + matches.push_back({app.displayName, app.viewFactory, {}}); + } + + // External (.ppma) and standalone (.ppmp) apps on the SD card. The + // enumerator hands back pointers to short-lived buffers, so the names are + // copied into owned strings here. Match on both the friendly name and the + // file (call) name. module_included=false: PPmod apps can't be launched by + // path, so they're intentionally excluded. + ExternalItemsMenuLoader::load_all_external_items_callback( + [this, &matches](AppInfoConsole& info) { + if (name_contains(info.appFriendlyName, app_search_query_) || + name_contains(info.appCallName, app_search_query_)) + matches.push_back({info.appFriendlyName, nullptr, info.appCallName}); + }, + false); + + if (matches.empty()) { + display_modal("Search", "No matching app found."); + return; + } + + push(std::move(matches)); +} + +void NavigationView::launch_search_entry(ViewFactoryBase* factory, std::string call_name) { + // Same close-then-open contract as StartAppByName: drop the search UI + // (freeing the results view and the very button that called us) before + // starting the target, so only one app is ever resident. Everything used + // below is a by-value argument or this resident NavigationView. + home(false); + + if (factory != nullptr) { + push_view(factory->produce(*this)); + return; + } + + // External/standalone: AppInfoConsole doesn't record which kind it is, so + // try both extensions the way handle_autostart() does. + std::wstring_convert, char16_t> conv; + + std::string appwithpath = "/" + apps_dir.string() + "/" + call_name + ".ppma"; + std::filesystem::path pth = conv.from_bytes(appwithpath.c_str()); + if (ExternalItemsMenuLoader::run_external_app(*this, pth)) + return; + + appwithpath = "/" + apps_dir.string() + "/" + call_name + ".ppmp"; + pth = conv.from_bytes(appwithpath.c_str()); + if (ExternalItemsMenuLoader::run_standalone_app(*this, pth)) + return; + + display_modal("Search", "Failed to start:\n" + call_name); +} + /* StatusTray ************************************************************/ StatusTray::StatusTray(Point pos) @@ -561,6 +671,7 @@ InformationView::InformationView( NavigationView& nav) : nav_(nav) { add_children({&backdrop, + &search_icon, &version, <ime}); @@ -604,6 +715,19 @@ bool InformationView::firmware_checksum_error() { return fw_checksum_error; } +bool InformationView::on_touch(const TouchEvent event) { + // The whole info bar is one touch target. Capture on Start so the End + // event is delivered here, then act on release like a normal button tap. + switch (event.type) { + case TouchEvent::Type::End: + if (nav_.is_valid()) + nav_.start_app_search(); + return true; + default: + return true; + } +} + /* Navigation ************************************************************/ bool NavigationView::is_top() const { @@ -873,6 +997,32 @@ void GamesMenuView::on_populate() { add_external_items(nav_, app_location_t::GAMES, *this, return_icon ? 1 : 0); } +/* AppSearchResultsView *************************************************/ + +AppSearchResultsView::AppSearchResultsView(NavigationView& nav, std::vector&& entries) + : nav_(nav), entries_(std::move(entries)) { + set_max_rows(2); // wider buttons: app names need the room +} + +void AppSearchResultsView::on_populate() { + for (size_t i = 0; i < entries_.size(); ++i) { + const auto& entry = entries_[i]; + // Internal apps are green, external/standalone orange, matching the + // "yellow-ish external" convention used elsewhere. No icon: the cheap + // enumerator doesn't decode external bitmaps. + add_item({entry.display, + entry.factory ? Color::green() : Color::orange(), + nullptr, + [this, i]() { + // Read the target off entries_ and pass it by value: the + // call below frees this view via home(), so nothing after + // it may touch 'this' or its members. + nav_.launch_search_entry(entries_[i].factory, entries_[i].call_name); + }}, + true); + } +} + /* SystemMenuView ********************************************************/ void SystemMenuView::hackrf_mode(NavigationView& nav) { diff --git a/firmware/application/ui_navigation.hpp b/firmware/application/ui_navigation.hpp index 9d958e645..af11ba097 100644 --- a/firmware/application/ui_navigation.hpp +++ b/firmware/application/ui_navigation.hpp @@ -86,6 +86,14 @@ struct AppInfoConsole { const app_location_t appLocation; }; +// One row in the app-search results list. Strings are owned copies: the +// external-app enumerator hands back pointers to short-lived buffers. +struct AppSearchEntry { + std::string display; // friendly name shown to the user + ViewFactoryBase* factory; // internal app factory, or nullptr for external/standalone + std::string call_name; // external/standalone file name (no extension); unused when internal +}; + class NavigationView : public View { public: std::function on_view_changed{}; @@ -138,12 +146,34 @@ class NavigationView : public View { bool StartAppByName(const char* name); // Starts a View (app) by name stored in appListFC. This is to start apps from console void handle_autostart(); + // Opens a keyboard; once confirmed, shows a pick-list of every internal, + // external (.ppma) and standalone (.ppmp) app whose name matches the query. + // Reuses the transient text-entry view, so the only steady-state cost is + // the two members below; the keyboard and the results list are allocated + // only while a search is in progress. + void start_app_search(); + + // Launches one search result and drops the search UI first, so only one app + // is ever resident. 'factory' non-null selects an internal app; otherwise + // 'call_name' is an external/standalone app file name (no extension). + // Arguments are taken by value on purpose: home() below frees the results + // view (and the button that called this), so nothing here may touch it. + void launch_search_entry(ViewFactoryBase* factory, std::string call_name); + private: struct ViewState { std::unique_ptr view; std::function on_pop; }; + // Builds and pushes the results list. Runs from the keyboard view's on_pop, + // i.e. after that view is fully destroyed, so pushing here can't be + // clobbered by the keyboard's own trailing pop(). + void open_app_search_results(); + + std::string app_search_query_{}; // keyboard buffer, owned across its lifetime + bool app_search_committed_{false}; // true only when the user confirmed a non-empty query + std::vector view_stack{}; Widget* view() const; @@ -329,6 +359,9 @@ class InformationView : public View { void refresh(); bool firmware_checksum_error(); + // Whole info bar is a touch target that opens the app search. + bool on_touch(const TouchEvent event) override; + private: // static constexpr auto version_string = "v1.4.4"; // This is commented out as we are now setting the version via ENV (VERSION_STRING=v1.0.0) NavigationView& nav_; @@ -337,8 +370,15 @@ class InformationView : public View { {0, 0, screen_width, 16}, Theme::getInstance()->bg_darker->background}; + // Visual hint that the info bar starts an app search when tapped. + Image search_icon{ + {2, 0, 16, 16}, + &bitmap_icon_search, + Color::yellow(), + Theme::getInstance()->bg_darker->background}; + Text version{ - {2, 0, 11 * 8, 16}, + {18, 0, 9 * 8, 16}, // 9 chars keeps room for the "FLASH ERR" indicator; right edge unchanged from before the icon VERSION_STRING}; LiveDateTime ltime{ @@ -422,6 +462,19 @@ class SystemMenuView : public BtnGridView { void hackrf_mode(NavigationView& nav); }; +// Pick-list of apps matching an app-search query. Reuses the button grid so it +// behaves (and launches on tap) exactly like the normal app menus. +class AppSearchResultsView : public BtnGridView { + public: + AppSearchResultsView(NavigationView& nav, std::vector&& entries); + std::string title() const override { return "Search"; }; + + private: + NavigationView& nav_; + std::vector entries_; + void on_populate() override; +}; + class SystemView : public View { public: SystemView(