diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a2c26e77..cb7373ae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -654,6 +654,7 @@ jobs: node tests/site/compass-capability-native.test.mjs MUSIC_UI_ARTIFACT_DIR=.codex-build/p4-preview/artifacts node tests/site/music-native-ui.test.mjs node tests/site/music-widget-refresh-native.test.mjs + WIDGET_UI_ARTIFACT_DIR=.codex-build/p4-preview/artifacts node tests/site/pager-input-native.test.mjs MUSIC_UI_ARTIFACT_DIR=.codex-build/p4-preview/artifacts node tests/site/music-cjk-native.test.mjs WIDGET_UI_ARTIFACT_DIR=.codex-build/p4-preview/artifacts node tests/site/quick-controls-native.test.mjs node tests/site/settings-catalog-native.test.mjs diff --git a/docs/uiux/components/pager-input.md b/docs/uiux/components/pager-input.md new file mode 100644 index 00000000..6ab8cdb5 --- /dev/null +++ b/docs/uiux/components/pager-input.md @@ -0,0 +1,68 @@ +# Pager keyboard and encoder navigation + +The Pager launcher keeps W/A/S/D for its adaptive icon grid. V toggles the +widget feed from any launcher position and restores the selected launcher +icon on return. A at the left edge no longer opens the feed. This shortcut is +scoped to the launcher: applications and text fields retain literal `v`. + +## Widget feed + +| Input | Action | +| --- | --- | +| V or Back | Return to the launcher | +| W/S or encoder rotation | Previous/next widget | +| A/D | Select a control within Music or Quick controls | +| Enter or encoder press | Activate the selected button; enter/leave slider editing | +| A/D while editing a slider | Decrease/increase its value by five percentage points | +| H | Open contextual launcher/widget help | + +Music initially selects Play/Pause. Previous, Play/Pause, Next, playback mode +and Open are all selectable controls. Space and J/K/R remain direct Music +shortcuts. Active walkie monitoring retains priority for its Space PTT action. +Quick controls exposes Wi-Fi, LoRa, GPS, volume and brightness in that order; +unavailable or changing controls cannot be activated. Both keyboard and touch +requests use the same application control coordinator and audio-focus policy. + +The encoder always pages widgets when rotated. Entering slider editing does +not change that rule. The widget footer distinguishes selection from editing. +Only the revealed widget owns its selection state, service subscription and +artwork, all released when it leaves. Selection state is part of the existing +PSRAM widget allocation, without a timer, framebuffer or hidden widget cache. +The container consumes a complete encoder press/release pair even if Open +changes applications while the switch is held. + +## Application pages + +The shared Pager input adapter repairs a missing initial focus before encoder +dispatch, uses a compact inset focus outline and leaves native LVGL editing +behavior available. Rotation selects controls by default. Press activates a +button or enters an editable/scrollable control; holding the encoder exits +editing when the group contains another control. Reused app groups start in +navigation mode on app entry/rebuild. Chat history also starts in navigation +mode, so Send and Back are reachable immediately. +Now Playing updates changed ID3, artwork and progress in place. Skipping tracks +keeps the same selected transport control, so repeated presses of Next keep +skipping instead of unexpectedly toggling Play/Pause after a page rebuild. +The Now Playing menu contains track information, Add to playlist and Stop; +volume remains available directly with W/S or the Quick controls slider. + +Team, Sky Plot, USB, Walkie, SSTV and Extensions also accept W/S to select +controls. Other pages keep their existing semantic shortcuts: for example, +Music uses W/S for volume while Now Playing, Calculator uses letter keys for +scientific operations, and Protocol Probe uses S to start/stop. + +Every shared Pager top bar displays `H Help`. H opens help outside text +editors; a focused text editor continues accepting the letter h. Mature +contextual help on Map, Contacts, Network and chat history remains owned by +those pages. Other pages use a shared help popup with navigation instructions +and the relevant app shortcuts. The popup owns keyboard/encoder input and +restores the previous focus on close. Page teardown releases its PSRAM state, +including when an input group was already destroyed. Map preserves the exact +control that opened its help. Two-pane pages retain their selected control on +background refresh and provide Menu/Back even with an empty list. + +`tests/site/pager-input-native.test.mjs` uses real LVGL keypad and encoder +input devices with press/release events. It covers launcher edge navigation, +widget buttons/sliders, held encoder ownership, app focus and help restoration, +literal text entry and teardown with help open. The older direct-event preview +helpers remain useful for layout tests but do not prove physical input routing. diff --git a/modules/ui_shared/include/ui/input/pager_navigation.h b/modules/ui_shared/include/ui/input/pager_navigation.h new file mode 100644 index 00000000..fee43018 --- /dev/null +++ b/modules/ui_shared/include/ui/input/pager_navigation.h @@ -0,0 +1,17 @@ +#pragma once +#include "lvgl.h" + +namespace ui::input::pager +{ +#if defined(ARDUINO_T_LORA_PAGER) +void install_help(lv_obj_t* root, lv_obj_t* title); +void prepare_group(lv_group_t* group); +bool handle_key(uint32_t key, bool pressed, lv_group_t* group); +#else +inline void install_help(lv_obj_t*, lv_obj_t*) +{ +} +inline void prepare_group(lv_group_t*) {} +inline bool handle_key(uint32_t, bool, lv_group_t*) { return false; } +#endif +} // namespace ui::input::pager diff --git a/modules/ui_shared/include/ui/menu/dashboard/dashboard_key_focus.h b/modules/ui_shared/include/ui/menu/dashboard/dashboard_key_focus.h new file mode 100644 index 00000000..663482a1 --- /dev/null +++ b/modules/ui_shared/include/ui/menu/dashboard/dashboard_key_focus.h @@ -0,0 +1,59 @@ +#pragma once + +#include "lvgl.h" +#include + +namespace ui::menu::dashboard +{ +// Stored in the visible widget's PSRAM state. The launcher retains its input +// group so the wheel can page widgets; keyboard selection never steals it. +struct DashboardKeyFocus +{ + lv_obj_t* items[5]{}; + unsigned count = 0; + unsigned selected = 0; + bool editing = false; + + void add(lv_obj_t* item) + { + if (count == 5) return; + items[count++] = item; +#if defined(ARDUINO_T_LORA_PAGER) + lv_obj_set_style_outline_width(item, 2, LV_STATE_FOCUS_KEY); + lv_obj_set_style_outline_pad(item, 1, LV_STATE_FOCUS_KEY); + lv_obj_set_style_outline_color(item, lv_color_hex(0x6B4A1E), LV_STATE_FOCUS_KEY); +#endif + } + lv_obj_t* current() const { return count ? items[selected] : nullptr; } + bool available(unsigned index) const + { + return index < count && items[index] && !lv_obj_has_state(items[index], LV_STATE_DISABLED); + } + void show() + { +#if defined(ARDUINO_T_LORA_PAGER) + for (unsigned i = 0; i < count; ++i) + { + constexpr auto state = static_cast(LV_STATE_FOCUSED | LV_STATE_FOCUS_KEY); + if (i == selected && available(i)) lv_obj_add_state(items[i], state); + else lv_obj_remove_state(items[i], state); + } +#endif + } + void move(int direction) + { + editing = false; + for (unsigned i = 0; i < count; ++i) + { + selected = (selected + count + direction) % count; + if (available(selected)) break; + } + show(); + } + void ensure() + { + if (count && !available(selected)) move(1); + else show(); + } +}; +} // namespace ui::menu::dashboard diff --git a/modules/ui_shared/include/ui/menu/dashboard/dashboard_state.h b/modules/ui_shared/include/ui/menu/dashboard/dashboard_state.h index 50eb697b..b48db062 100644 --- a/modules/ui_shared/include/ui/menu/dashboard/dashboard_state.h +++ b/modules/ui_shared/include/ui/menu/dashboard/dashboard_state.h @@ -36,6 +36,7 @@ struct DashboardUi int page_delta = 0; bool settling = false; bool feed_target = false; + bool encoder_button_owned = false; }; DashboardUi* dashboard_state(); diff --git a/modules/ui_shared/include/ui/menu/menu_dashboard.h b/modules/ui_shared/include/ui/menu/menu_dashboard.h index 3e9be1a6..b1a70d76 100644 --- a/modules/ui_shared/include/ui/menu/menu_dashboard.h +++ b/modules/ui_shared/include/ui/menu/menu_dashboard.h @@ -35,6 +35,7 @@ void bindMusicSource(::music::IService* source); bool isFeedOpen(); bool handleKey(uint32_t key, bool at_left_edge = false); bool handleEncoder(int delta); +bool handleEncoderButton(bool pressed); #else inline void init(lv_obj_t* menu_panel, lv_obj_t* grid_panel, const ui::menu_layout::InitOptions& options) { @@ -61,6 +62,7 @@ inline void bindMusicSource(::music::IService*) {} inline bool isFeedOpen() { return false; } inline bool handleKey(uint32_t, bool = false) { return false; } inline bool handleEncoder(int) { return false; } +inline bool handleEncoderButton(bool) { return false; } #endif } // namespace ui::menu::dashboard diff --git a/modules/ui_shared/include/ui/widgets/ime/ime_widget.h b/modules/ui_shared/include/ui/widgets/ime/ime_widget.h index af4e843f..b0c699c1 100644 --- a/modules/ui_shared/include/ui/widgets/ime/ime_widget.h +++ b/modules/ui_shared/include/ui/widgets/ime/ime_widget.h @@ -24,6 +24,10 @@ namespace ui namespace widgets { +// The active IME may temporarily focus a hidden candidate-routing proxy. +// Global navigation/help must treat that proxy as owned text input. +bool ime_owns_text_focus(lv_obj_t* object); + // Transient UI recovery metadata. Text storage belongs to the caller; this is // not a persistent format and does not retain widgets or candidate collections. struct ImeEditState diff --git a/modules/ui_shared/src/ui/app_runtime.cpp b/modules/ui_shared/src/ui/app_runtime.cpp index 1ff05afb..7d9b3074 100644 --- a/modules/ui_shared/src/ui/app_runtime.cpp +++ b/modules/ui_shared/src/ui/app_runtime.cpp @@ -344,6 +344,9 @@ void rebuild_active_app_timer_cb(lv_timer_t* timer) { lv_obj_clean(parent); } +#if defined(ARDUINO_T_LORA_PAGER) + if (app_g) lv_group_set_editing(app_g, false); +#endif app->enter(parent); #if defined(ESP_PLATFORM) ESP_LOGI(kTag, @@ -449,6 +452,11 @@ void ui_switch_to_app(AppScreen* app, lv_obj_t* parent) s_active_app ? s_active_app->name() : "", app->name(), parent ? 1 : 0); +#if defined(ARDUINO_T_LORA_PAGER) + // App groups are reused. Editing in the previous app must not turn + // the next page's wheel into a value/cursor editor on entry. + if (app_g) lv_group_set_editing(app_g, false); +#endif app->enter(parent); ui::menu_runtime::setScene(ui::menu_runtime::Scene::App); } diff --git a/modules/ui_shared/src/ui/components/shortcut_help_modal.cpp b/modules/ui_shared/src/ui/components/shortcut_help_modal.cpp index 419f5809..6ac01d36 100644 --- a/modules/ui_shared/src/ui/components/shortcut_help_modal.cpp +++ b/modules/ui_shared/src/ui/components/shortcut_help_modal.cpp @@ -28,8 +28,6 @@ constexpr uint32_t kKeycap = 0xF8E6C3; constexpr uint32_t kBorder = 0x8A6E43; constexpr uint32_t kText = 0x25170D; constexpr uint32_t kTextDim = 0x3E2B18; -constexpr uint32_t kPagerRotateUpKey = 19; -constexpr uint32_t kPagerRotateDownKey = 20; bool close_key(uint32_t key) { @@ -42,12 +40,12 @@ bool close_key(uint32_t key) bool up_key(uint32_t key) { - return key == LV_KEY_UP || key == kPagerRotateUpKey || key == 'w' || key == 'W'; + return key == LV_KEY_UP || key == LV_KEY_LEFT || key == 'w' || key == 'W'; } bool down_key(uint32_t key) { - return key == LV_KEY_DOWN || key == kPagerRotateDownKey || key == 's' || key == 'S'; + return key == LV_KEY_DOWN || key == LV_KEY_RIGHT || key == 's' || key == 'S'; } void consume(lv_event_t* event) @@ -79,7 +77,7 @@ lv_coord_t keycap_width(const char* text, bool compact) const std::size_t len = text ? std::strlen(text) : 0; if (compact) { - return len > 4 ? 48 : (len > 2 ? 34 : 22); + return len > 4 ? 36 : (len > 2 ? 34 : 22); } return 72; } @@ -216,7 +214,12 @@ void on_key(lv_event_t* event) if ((up_key(key) || down_key(key)) && state->body && lv_obj_is_valid(state->body)) { - lv_obj_scroll_by(state->body, 0, up_key(key) ? 18 : -18, LV_ANIM_OFF); + const lv_coord_t current = lv_obj_get_scroll_y(state->body); + const lv_coord_t maximum = current + lv_obj_get_scroll_bottom(state->body); + lv_coord_t target = current + (up_key(key) ? -18 : 18); + if (target > maximum) target = maximum; + if (target < 0) target = 0; + lv_obj_scroll_to_y(state->body, target, LV_ANIM_OFF); consume(event); return; } @@ -393,10 +396,17 @@ void close(State& state) state.overlay = nullptr; state.panel = nullptr; state.body = nullptr; - if (state.previous_group) + bool restore_valid = false; + for (uint32_t i = 0; i < lv_group_get_count(); ++i) + if (lv_group_by_index(i) == state.previous_group) restore_valid = true; + if (restore_valid) { set_default_group(state.previous_group); } + else if (state.group && lv_group_get_default() == state.group) + { + set_default_group(nullptr); + } if (state.group) { lv_group_del(state.group); diff --git a/modules/ui_shared/src/ui/components/two_pane_nav.cpp b/modules/ui_shared/src/ui/components/two_pane_nav.cpp index 0c3778a7..42ff624d 100644 --- a/modules/ui_shared/src/ui/components/two_pane_nav.cpp +++ b/modules/ui_shared/src/ui/components/two_pane_nav.cpp @@ -529,6 +529,10 @@ static void bind_list_column(Binding* binding) } add_top_back(binding, binding->adapter.list_top_back_placement, BackPlacement::Trailing); +#if defined(ARDUINO_T_LORA_PAGER) + if (binding->adapter.list_top_back_placement == BackPlacement::None) + group_add_if_visible(binding, get_top_back_button(binding)); +#endif group_add_if_visible(binding, binding->menu.button()); lv_group_focus_freeze(binding->group, false); @@ -563,6 +567,8 @@ static void bind_list_column(Binding* binding) } } prepare_touch_routing(binding); + if (!target || lv_obj_get_group(target) != binding->group) + target = binding->menu.button(); if (target) { focus_first_valid(binding, target); @@ -646,11 +652,15 @@ static void root_key_event_cb(lv_event_t* e) const uint32_t key = lv_event_get_key(e); if (binding->adapter.filter_panel && (key == 'm' || key == 'M')) { + lv_event_stop_bubbling(e); + lv_event_stop_processing(e); binding->menu.toggle(); return; } if (key == LV_KEY_BACKSPACE || key == LV_KEY_ESC) { + lv_event_stop_bubbling(e); + lv_event_stop_processing(e); if (lv_obj_t* back = get_top_back_button(binding)) { lv_obj_send_event(back, LV_EVENT_CLICKED, nullptr); @@ -658,10 +668,32 @@ static void root_key_event_cb(lv_event_t* e) return; } - if (key != LV_KEY_ENTER) return; + if (key != LV_KEY_ENTER) + { +#if defined(ARDUINO_T_LORA_PAGER) + auto* target = lv_event_get_target_obj(e); + auto* root = get_key_target(binding); + if (target != root && is_valid(root) && !lv_obj_has_flag(target, LV_OBJ_FLAG_EVENT_BUBBLE)) + { + uint32_t forwarded = key; + lv_obj_send_event(root, LV_EVENT_KEY, &forwarded); + lv_event_stop_processing(e); + } +#endif + return; + } lv_obj_t* focused = binding->group ? lv_group_get_focused(binding->group) : nullptr; if (!focused) return; + lv_event_stop_bubbling(e); + lv_event_stop_processing(e); + if (auto* device = lv_indev_active(); device && lv_indev_get_type(device) == LV_INDEV_TYPE_KEYPAD) + { + // This handler activates manually. Suppress LVGL's later Enter-release + // click, which would toggle the menu twice or activate the new focus. + lv_indev_wait_release(device); + lv_indev_stop_processing(device); + } if (binding->menu.in_submenu()) { lv_obj_send_event(focused, LV_EVENT_CLICKED, nullptr); @@ -883,8 +915,12 @@ void on_ui_refreshed(Binding* binding) { if (!binding_alive(binding) || !binding->group) return; if (binding->menu.in_submenu()) return; + auto* focused = lv_group_get_focused(binding->group); lv_group_set_editing(binding->group, false); rebind_by_column(binding); + // Background status refresh must not move the user's current selection. + if (is_visible(focused) && lv_obj_get_group(focused) == binding->group) + lv_group_focus_obj(focused); } void focus_filter(Binding* binding) diff --git a/modules/ui_shared/src/ui/input/pager_navigation.cpp b/modules/ui_shared/src/ui/input/pager_navigation.cpp new file mode 100644 index 00000000..f19203fa --- /dev/null +++ b/modules/ui_shared/src/ui/input/pager_navigation.cpp @@ -0,0 +1,227 @@ +#include "ui/input/pager_navigation.h" +#if defined(ARDUINO_T_LORA_PAGER) +#include "platform/memory/psram_ptr.h" +#include "platform/ui/psram_ui_lease.h" +#include "ui/app_runtime.h" +#include "ui/components/shortcut_help_modal.h" +#include "ui/localization.h" +#include "ui/menu/menu_runtime.h" +#include "ui/widgets/ime/ime_widget.h" +#include + +namespace ui::input::pager +{ +namespace +{ +namespace help = ui::components::shortcut_help_modal; +struct PageHelp +{ + lv_obj_t* root = nullptr; + lv_obj_t* title = nullptr; + PageHelp* next = nullptr; + help::State modal{}; +}; +PageHelp* pages = nullptr; +uint32_t consumed_key = 0; + +bool group_exists(lv_group_t* target) +{ + for (uint32_t i = 0; i < lv_group_get_count(); ++i) + if (lv_group_by_index(i) == target) return true; + return false; +} +bool group_bound(lv_group_t* target) +{ + for (auto* device = lv_indev_get_next(nullptr); device; device = lv_indev_get_next(device)) + if (lv_indev_get_type(device) == LV_INDEV_TYPE_KEYPAD && lv_indev_get_group(device) == target) return true; + return false; +} +void close_help(PageHelp& page) +{ + // Some app exits destroy their input group before deleting page objects. + if (!group_exists(page.modal.previous_group)) page.modal.previous_group = nullptr; + help::close(page.modal); +} +void deleted(lv_event_t* event) +{ + auto* page = static_cast(lv_event_get_user_data(event)); + close_help(*page); + auto** link = &pages; + while (*link && *link != page) link = &(*link)->next; + if (*link) *link = page->next; + platform::memory::destroyPsram(page); +} +PageHelp* page_for(lv_obj_t* focused) +{ + // The closest registered ancestor owns nested pages. A modal outside the + // page's subtree keeps its own shortcuts and input group. + for (auto* ancestor = focused; ancestor; ancestor = lv_obj_get_parent(ancestor)) + for (auto* page = pages; page; page = page->next) + if (page->root == ancestor) return page; + return nullptr; +} +bool in_text_editor(lv_obj_t* focused) +{ + for (auto* object = focused; object; object = lv_obj_get_parent(object)) + if (lv_obj_check_type(object, &lv_textarea_class)) return true; + return false; +} +bool contains_editor(lv_obj_t* root) +{ + if (lv_obj_check_type(root, &lv_textarea_class)) return true; + for (uint32_t i = 0; i < lv_obj_get_child_count(root); ++i) + if (contains_editor(lv_obj_get_child(root, i))) return true; + return false; +} +bool native_help(const PageHelp& page, const char* id) +{ + if (!id) return false; + if (!std::strcmp(id, "tracker") && page.title && + !std::strcmp(lv_label_get_text(page.title), ui::i18n::tr("Route Preview"))) return true; + if (!std::strcmp(id, "map") || !std::strcmp(id, "network") || !std::strcmp(id, "contacts") || !std::strcmp(id, "node_info")) return true; + return !std::strcmp(id, "chat") && page.title && + std::strcmp(lv_label_get_text(page.title), ui::i18n::tr("MESSAGES")) && !contains_editor(page.root); +} +bool simple_navigation(const char* id) +{ + return id && (!std::strcmp(id, "team") || !std::strcmp(id, "sky_plot") || !std::strcmp(id, "usb_mass_storage") || + !std::strcmp(id, "walkie_talkie") || !std::strcmp(id, "sstv") || !std::strcmp(id, "extensions")); +} +void show_help(PageHelp& page, lv_group_t* group) +{ + help::Row rows[16] = { + {"Wheel", nullptr, "Select control"}, + {"Press", "Enter", "Activate / enter editing"}, + {"Hold wheel", nullptr, "Leave editing / select controls"}, + {"Back", nullptr, "Back (delete text inside editor)"}, + {"H", nullptr, "Help outside text editors"}, + }; + unsigned count = 5; + const auto* app = ui_get_active_app(); + const char* id = app ? app->stable_id() : ""; + if (simple_navigation(id)) rows[count++] = {"W/S", nullptr, "Previous / next control"}; + if (id && std::strcmp(id, "music") == 0) + { + rows[count++] = {"Space", nullptr, "Play / pause"}; + rows[count++] = {"A/D", nullptr, "Previous / next track in player"}; + rows[count++] = {"W/S", nullptr, "Volume in player; browse in lists"}; + rows[count++] = {"B", "L", "Library / lyrics"}; + } + else if (id && std::strcmp(id, "calculator") == 0) + { + rows[count++] = {"0-9", "+ - * /", "Enter expression"}; + rows[count++] = {"F", "M", "Second functions / DEG-RAD"}; + rows[count++] = {"P", "A", "Pi / previous answer"}; + rows[count++] = {"Q", "R", "Square / square root"}; + } + else if (id && std::strcmp(id, "energy_sweep") == 0) + rows[count++] = {"S", nullptr, "Start / stop protocol probe"}; + else if (id && (std::strcmp(id, "contacts") == 0 || std::strcmp(id, "settings") == 0 || + std::strcmp(id, "tracker") == 0 || std::strcmp(id, "extensions") == 0 || std::strcmp(id, "network") == 0)) + rows[count++] = {"M", nullptr, "Open / close page menu"}; + rows[count++] = {"H", "Enter", "Close help; restore focus"}; + platform::ui::PsramUiLease memory; + help::Config config; + config.title = page.title ? lv_label_get_text(page.title) : "Page Help"; + config.rows = rows; + config.row_count = count; + config.width = 360; + config.height = 192; + config.restore_group = group; + help::open(page.modal, page.root, config); +} +} // namespace + +void install_help(lv_obj_t* root, lv_obj_t* title) +{ + if (!root) return; + auto* bar = title ? lv_obj_get_parent(title) : root; + lv_obj_update_layout(root); + // Some pages wrap the bar in a fixed-height header. Register that header's + // page, but keep nested page/dialog roots separate from the outer app. + if (lv_obj_get_parent(root) && lv_obj_get_height(root) <= lv_obj_get_height(bar)) + root = lv_obj_get_parent(root); + auto* page = platform::memory::createPsram(); + if (!page) return; + page->root = root; + page->title = title; + page->next = pages; + pages = page; + lv_obj_add_event_cb(bar, deleted, LV_EVENT_DELETE, page); +} +void prepare_group(lv_group_t* group) +{ + if (!group) return; + // LVGL's encoder processing returns immediately with a null focus, even + // when its group has navigable objects. Repair that before dispatch. + auto* focused = lv_group_get_focused(group); + if (ui::widgets::ime_owns_text_focus(focused)) return; + if (!focused || !lv_obj_is_visible(focused) || lv_obj_has_state(focused, LV_STATE_DISABLED)) + { + lv_group_set_editing(group, false); + lv_group_focus_next(group); + } + platform::ui::PsramUiLease memory; + for (unsigned i = 0; i < lv_group_get_obj_count(group); ++i) + { + auto* object = lv_group_get_obj_by_index(group, i); + // An inset outline is visible without joining the top bar or clipping + // adjacent rows. Preserve each page's ordinary pointer/checked style. + lv_style_value_t value; + if (lv_obj_get_local_style_prop(object, LV_STYLE_OUTLINE_WIDTH, &value, LV_STATE_FOCUS_KEY) != LV_STYLE_RES_FOUND || value.num != 2) + lv_obj_set_style_outline_width(object, 2, LV_STATE_FOCUS_KEY); + if (lv_obj_get_local_style_prop(object, LV_STYLE_OUTLINE_PAD, &value, LV_STATE_FOCUS_KEY) != LV_STYLE_RES_FOUND || value.num != -2) + lv_obj_set_style_outline_pad(object, -2, LV_STATE_FOCUS_KEY); + const auto color = lv_color_hex(0x6B4A1E); + if (lv_obj_get_local_style_prop(object, LV_STYLE_OUTLINE_COLOR, &value, LV_STATE_FOCUS_KEY) != LV_STYLE_RES_FOUND || + lv_color_to_int(value.color) != lv_color_to_int(color)) + lv_obj_set_style_outline_color(object, color, LV_STATE_FOCUS_KEY); + } +} +bool handle_key(uint32_t key, bool pressed, lv_group_t* group) +{ + if (!pressed && consumed_key != 0 && consumed_key == key) + { + consumed_key = 0; + return true; + } + if (!pressed || + ui::menu_runtime::currentScene() != ui::menu_runtime::Scene::App || !group) return false; + auto* focused = lv_group_get_focused(group); + auto* page = page_for(focused); + if (!page || in_text_editor(focused) || ui::widgets::ime_owns_text_focus(focused)) return false; + auto* app = ui_get_active_app(); + const char* id = app ? app->stable_id() : nullptr; + if (!help::is_open(page->modal) && simple_navigation(id) && + (key == 'w' || key == 'W' || key == 's' || key == 'S')) + { + lv_group_set_editing(group, false); + if (key == 'w' || key == 'W') lv_group_focus_prev(group); + else lv_group_focus_next(group); + consumed_key = key; + return true; + } + if (key != 'h' && key != 'H') return false; + if (help::is_open(page->modal)) close_help(*page); + else + { + if (ui_is_overlay_active()) return false; + // Keep mature page-specific help (Map, Contacts, Network, chat + // history), including their context-sensitive modal behavior. + if (native_help(*page, id)) + { + lv_group_send_data(group, key); + // Some focusable containers have no key bubbling route to the + // page (e.g. Network's browser rail). The page's own handler still + // owns its detailed help, regardless of which control was selected. + if (group_bound(group) && group_exists(group) && lv_group_get_focused(group) == focused && + (std::strcmp(id, "network") == 0 || std::strcmp(id, "contacts") == 0 || std::strcmp(id, "map") == 0)) + lv_obj_send_event(page->root, LV_EVENT_KEY, &key); + } + else show_help(*page, group); + } + consumed_key = key; + return true; +} +} // namespace ui::input::pager +#endif diff --git a/modules/ui_shared/src/ui/menu/dashboard/dashboard_controls_widget.cpp b/modules/ui_shared/src/ui/menu/dashboard/dashboard_controls_widget.cpp index 1a8be000..742ee577 100644 --- a/modules/ui_shared/src/ui/menu/dashboard/dashboard_controls_widget.cpp +++ b/modules/ui_shared/src/ui/menu/dashboard/dashboard_controls_widget.cpp @@ -1,4 +1,5 @@ #include "ui/localization.h" +#include "ui/menu/dashboard/dashboard_key_focus.h" #include "ui/menu/dashboard/dashboard_style.h" #include "ui/menu/dashboard/dashboard_widgets.h" #include "ui_presentation/controls/quick_controls.h" @@ -11,11 +12,12 @@ namespace { struct ControlsWidget : WidgetLifetime { + DashboardKeyFocus focus{}; DashboardCardChrome chrome{}; ui::controls::IQuickControls* source = nullptr; ui::controls::Snapshot snapshot{}; lv_obj_t *switches[3]{}, *names[3]{}, *status[3]{}; - lv_obj_t *sliders[2]{}, *values[2]{}, *message = nullptr; + lv_obj_t *sliders[2]{}, *values[2]{}, *message = nullptr, *hint = nullptr; bool applying = false; }; lv_obj_t* caption(lv_obj_t* parent, const char* text, lv_coord_t x, lv_coord_t y, lv_coord_t w, bool muted = false) @@ -49,7 +51,9 @@ void refresh_controls(void* context) : on ? "On" : "Off"; changed(self.status[i], ui::i18n::tr(text)); - lv_obj_set_style_bg_color(self.switches[i], on ? color_soft_amber() : color_panel_bg(), 0); + const auto background = on ? color_soft_amber() : color_panel_bg(); + if (lv_color_to_int(lv_obj_get_style_bg_color(self.switches[i], LV_PART_MAIN)) != lv_color_to_int(background)) + lv_obj_set_style_bg_color(self.switches[i], background, 0); if (enabled) lv_obj_remove_state(self.switches[i], LV_STATE_DISABLED); else lv_obj_add_state(self.switches[i], LV_STATE_DISABLED); } @@ -69,6 +73,9 @@ void refresh_controls(void* context) changed(self.message, data.message); if (data.message[0]) lv_obj_remove_flag(self.message, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(self.message, LV_OBJ_FLAG_HIDDEN); + self.focus.ensure(); + if (self.hint) + changed(self.hint, self.focus.editing ? "A/D adjust Enter done W/S wheel widgets V menu" : "A/D select Enter edit W/S wheel widgets V menu"); } void switch_event(lv_event_t* event) { @@ -99,6 +106,32 @@ void slider_event(lv_event_t* event) bool controls_key(void* context, uint32_t key) { auto& self = *static_cast(context); +#if defined(ARDUINO_T_LORA_PAGER) + const bool left = key == 'a' || key == 'A' || key == LV_KEY_LEFT; + const bool right = key == 'd' || key == 'D' || key == LV_KEY_RIGHT; + if (left || right || key == LV_KEY_ENTER) + { + if (key == LV_KEY_ENTER) + { + if (self.focus.available(self.focus.selected)) + { + if (self.focus.selected < 3) lv_obj_send_event(self.focus.current(), LV_EVENT_CLICKED, nullptr); + else self.focus.editing = !self.focus.editing; + } + } + else if (self.focus.editing && self.focus.selected >= 3) + { + const bool brightness = self.focus.selected == 4; + if (self.source && self.focus.available(self.focus.selected)) + self.source->set(brightness ? ui::controls::Control::Brightness : ui::controls::Control::Volume, + std::clamp((brightness ? self.snapshot.brightness : self.snapshot.volume) + (left ? -5 : 5), brightness ? 10 : 0, 100)); + refresh_controls(&self); + } + else self.focus.move(left ? -1 : 1); + changed(self.hint, self.focus.editing ? "A/D adjust Enter done W/S wheel widgets V menu" : "A/D select Enter edit W/S wheel widgets V menu"); + return true; + } +#endif if (!self.source) return false; using namespace ui::controls; bool handled = true; @@ -126,11 +159,19 @@ WidgetHandle create_controls_widget(lv_obj_t* parent, lv_coord_t width, lv_coord self->source = source; self->chrome = create_card_chrome(parent, ui::i18n::tr("Quick controls"), width, height); auto* body = self->chrome.body; - // This widget has no persistent footer content. Reclaim the shared chrome's footer entirely. + // Touch devices need no footer. Pager uses a compact, transparent key hint. +#if defined(ARDUINO_T_LORA_PAGER) + lv_obj_set_height(self->chrome.footer, 18); + lv_obj_set_style_bg_opa(self->chrome.footer, LV_OPA_TRANSP, 0); + lv_obj_set_style_pad_top(self->chrome.footer, 0, 0); + lv_obj_set_style_pad_bottom(self->chrome.footer, 0, 0); + self->hint = caption(self->chrome.footer, "A/D select Enter edit W/S wheel widgets V menu", 0, 0, LV_PCT(100), true); +#else lv_obj_delete(self->chrome.footer); self->chrome.footer = nullptr; +#endif lv_obj_set_style_bg_opa(body, LV_OPA_TRANSP, 0); - lv_obj_set_height(body, lv_obj_get_content_height(self->chrome.card) - lv_obj_get_y(body)); + lv_obj_set_height(body, lv_obj_get_content_height(self->chrome.card) - lv_obj_get_y(body) - (self->hint ? 18 : 0)); lv_obj_update_layout(body); const auto w = lv_obj_get_content_width(body), h = lv_obj_get_content_height(body); const bool tall = h > 180; @@ -142,6 +183,7 @@ WidgetHandle create_controls_widget(lv_obj_t* parent, lv_coord_t width, lv_coord { auto* button = lv_button_create(body); self->switches[i] = button; + self->focus.add(button); lv_group_remove_obj(button); lv_obj_set_pos(button, x + i * (toggle_w + gap), top); lv_obj_set_size(button, toggle_w, toggle_h); @@ -166,6 +208,7 @@ WidgetHandle create_controls_widget(lv_obj_t* parent, lv_coord_t width, lv_coord lv_obj_set_style_text_align(self->values[i], LV_TEXT_ALIGN_RIGHT, 0); auto* slider = lv_slider_create(body); self->sliders[i] = slider; + self->focus.add(slider); lv_group_remove_obj(slider); lv_obj_set_pos(slider, sx + 7, sy + (tall ? 34 : 26)); lv_obj_set_size(slider, slider_w - 14, tall ? 8 : 6); diff --git a/modules/ui_shared/src/ui/menu/dashboard/dashboard_music_widget.cpp b/modules/ui_shared/src/ui/menu/dashboard/dashboard_music_widget.cpp index 1b3f892e..3a9b0351 100644 --- a/modules/ui_shared/src/ui/menu/dashboard/dashboard_music_widget.cpp +++ b/modules/ui_shared/src/ui/menu/dashboard/dashboard_music_widget.cpp @@ -1,6 +1,7 @@ #include "music/service.h" #include "src/misc/cache/instance/lv_image_cache.h" #include "ui/localization.h" +#include "ui/menu/dashboard/dashboard_key_focus.h" #include "ui/menu/dashboard/dashboard_style.h" #include "ui/menu/dashboard/dashboard_widgets.h" #include "ui/menu/menu_layout.h" @@ -17,6 +18,7 @@ namespace { struct MusicWidget : WidgetLifetime { + DashboardKeyFocus focus{}; DashboardCardChrome chrome{}; ::music::IService* source = nullptr; ::music::Snapshot snapshot{}; @@ -104,6 +106,13 @@ void clicked(lv_event_t* event) bool music_key(void* context, uint32_t key) { auto& self = *static_cast(context); +#if defined(ARDUINO_T_LORA_PAGER) + if (key == 'a' || key == 'A' || key == LV_KEY_LEFT || key == 'd' || key == 'D' || key == LV_KEY_RIGHT) + { + self.focus.move(key == 'a' || key == 'A' || key == LV_KEY_LEFT ? -1 : 1); + return true; + } +#endif if (key == ' ') { send(self, 1); @@ -126,7 +135,11 @@ bool music_key(void* context, uint32_t key) } if (key == LV_KEY_ENTER) { +#if defined(ARDUINO_T_LORA_PAGER) + send(self, self.focus.selected); +#else send(self, 4); +#endif return true; } return false; @@ -181,6 +194,7 @@ WidgetHandle create_music_widget(lv_obj_t* parent, lv_coord_t width, lv_coord_t for (unsigned i = 0; i < controls; i++) { auto* button = lv_button_create(body); + self->focus.add(button); lv_group_remove_obj(button); lv_obj_set_pos(button, bx, by); lv_obj_set_size(button, widths[i], 24); @@ -200,7 +214,10 @@ WidgetHandle create_music_widget(lv_obj_t* parent, lv_coord_t width, lv_coord_t lv_obj_set_user_data(footer, reinterpret_cast(4)); lv_obj_add_event_cb(footer, clicked, LV_EVENT_CLICKED, self); #if defined(ARDUINO_T_LORA_PAGER) - ui::i18n::set_label_text(footer, "Space play J/K tracks R mode Enter open"); + lv_obj_set_style_bg_opa(self->chrome.footer, LV_OPA_TRANSP, 0); + ui::i18n::set_label_text(footer, "A/D select Enter run W/S wheel widgets V menu"); + self->focus.selected = 1; + self->focus.show(); #endif // Observe existing playback only. A widget never scans or opens the song library. if (source) diff --git a/modules/ui_shared/src/ui/menu/menu_dashboard.cpp b/modules/ui_shared/src/ui/menu/menu_dashboard.cpp index 829dd7f4..5354fc13 100644 --- a/modules/ui_shared/src/ui/menu/menu_dashboard.cpp +++ b/modules/ui_shared/src/ui/menu/menu_dashboard.cpp @@ -443,24 +443,62 @@ bool handleKey(uint32_t key, bool at_left_edge) if (!state || !state->active || state->dock_right || ui::menu_runtime::currentScene() != ui::menu_runtime::Scene::Menu || ui_is_overlay_active() || (lv_group_get_default() && lv_group_get_default() != menu_g)) return false; +#if defined(ARDUINO_T_LORA_PAGER) + if (key == 'v' || key == 'V') + { + set_feed_open(!state->feed_open); + return true; + } +#endif if (state->feed_open) { for (auto& widget : state->widgets) if (widget.key && widget.key(widget.state, key)) return true; if (key == 'w' || key == 'W' || key == LV_KEY_UP || key == LV_KEY_PREV) change_page(-1); else if (key == 's' || key == 'S' || key == LV_KEY_DOWN || key == LV_KEY_NEXT) change_page(1); - else if (key == 'd' || key == 'D' || key == LV_KEY_RIGHT || key == LV_KEY_ESC || key == LV_KEY_BACKSPACE) set_feed_open(false); + else if (key == LV_KEY_ESC || key == LV_KEY_BACKSPACE) set_feed_open(false); +#if !defined(ARDUINO_T_LORA_PAGER) + else if (key == 'd' || key == 'D' || key == LV_KEY_RIGHT) set_feed_open(false); +#endif + else if (key == 'd' || key == 'D' || key == LV_KEY_RIGHT) return true; else if (key != LV_KEY_ENTER && key != 'a' && key != 'A' && key != LV_KEY_LEFT) return false; return true; } +#if !defined(ARDUINO_T_LORA_PAGER) if (at_left_edge && (key == LV_KEY_LEFT || key == 'a' || key == 'A')) { set_feed_open(true); return true; } +#else + (void)at_left_edge; +#endif return false; } +bool handleEncoderButton(bool pressed) +{ +#if defined(ARDUINO_T_LORA_PAGER) + auto* state = dashboard_state(); + if (!state) return false; + if (state->encoder_button_owned) + { + if (!pressed) state->encoder_button_owned = false; + return true; + } + if (!pressed || !isFeedOpen() || ui_is_overlay_active() || + (lv_group_get_default() && lv_group_get_default() != menu_g)) return false; + // Own the whole press/release pair, even if activation opens an app and + // releases the widget. A held switch must never fire on the new page. + state->encoder_button_owned = true; + handleKey(LV_KEY_ENTER); + return true; +#else + (void)pressed; + return false; +#endif +} + void init(lv_obj_t* menu_panel, lv_obj_t* grid_panel, const ui::menu_layout::InitOptions&) { if (!dashboard_enabled() || !menu_panel || !grid_panel) return; diff --git a/modules/ui_shared/src/ui/menu/menu_runtime.cpp b/modules/ui_shared/src/ui/menu/menu_runtime.cpp index 0f74b51f..a3e852e2 100644 --- a/modules/ui_shared/src/ui/menu/menu_runtime.cpp +++ b/modules/ui_shared/src/ui/menu/menu_runtime.cpp @@ -251,7 +251,7 @@ void openMenuHelpModal() return; } - ::ui::components::shortcut_help_modal::Row rows[8] = { + ::ui::components::shortcut_help_modal::Row rows[12] = { {"WASD", nullptr, "Select app"}, {"Enter", nullptr, "Open app"}, {"B", nullptr, "Cycle screen brightness"}, @@ -264,11 +264,20 @@ void openMenuHelpModal() if (ui::menu::dashboard::isFeedOpen()) { rows[0] = {"W/S", "Wheel", "Switch widget"}; +#if defined(ARDUINO_T_LORA_PAGER) + rows[1] = {"V", "Back", "Return to menu"}; + rows[row_count++] = {"A/D", nullptr, "Select widget control"}; + rows[row_count++] = {"Enter", nullptr, "Run / edit selected control"}; + rows[row_count++] = {"A/D", nullptr, "Adjust slider while editing"}; +#else rows[1] = {"D", "Back", "Return to menu"}; +#endif } else { -#if defined(ARDUINO_T_DECK) || defined(ARDUINO_T_LORA_PAGER) || defined(ARDUINO_WIO_TRACKER_L2) +#if defined(ARDUINO_T_LORA_PAGER) + rows[row_count++] = {"V", nullptr, "Toggle widgets / main menu"}; +#elif defined(ARDUINO_T_DECK) || defined(ARDUINO_WIO_TRACKER_L2) rows[row_count++] = {"A", nullptr, "Widgets at left edge"}; #elif defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) rows[row_count++] = {"Swipe", nullptr, "More dashboard widgets"}; diff --git a/modules/ui_shared/src/ui/screens/chat/chat_conversation_input.cpp b/modules/ui_shared/src/ui/screens/chat/chat_conversation_input.cpp index 860b2d39..dbb6af86 100644 --- a/modules/ui_shared/src/ui/screens/chat/chat_conversation_input.cpp +++ b/modules/ui_shared/src/ui/screens/chat/chat_conversation_input.cpp @@ -17,8 +17,8 @@ namespace chat::ui::conversation::input namespace { -constexpr int kEncoderKeyRotateUp = 19; -constexpr int kEncoderKeyRotateDown = 20; +constexpr int kEncoderKeyRotateUp = LV_KEY_LEFT; +constexpr int kEncoderKeyRotateDown = LV_KEY_RIGHT; constexpr lv_coord_t kScrollStep = 24; constexpr lv_coord_t kPageScrollPadding = 12; } // namespace @@ -50,7 +50,10 @@ static bool scroll_messages(ChatConversationScreen* screen, { return false; } - lv_obj_scroll_by(msg_list, 0, delta, LV_ANIM_OFF); + const lv_coord_t current = lv_obj_get_scroll_y(msg_list); + const lv_coord_t maximum = current + lv_obj_get_scroll_bottom(msg_list); + const lv_coord_t target = std::max(0, std::min(maximum, current + delta)); + lv_obj_scroll_to_y(msg_list, target, LV_ANIM_OFF); consume(e); return true; } @@ -258,11 +261,7 @@ static void on_msg_list_key(lv_event_t* e) } if (delta != 0) { - if (lv_obj_t* msg_list = screen->getMsgList()) - { - lv_obj_scroll_by(msg_list, 0, delta, LV_ANIM_OFF); - consume(e); - } + scroll_messages(screen, e, delta); } } } @@ -302,7 +301,12 @@ void init(ChatConversationScreen* screen, Binding* binding) { lv_group_add_obj(binding->group, binding->msg_list); lv_group_focus_obj(binding->msg_list); +#if defined(ARDUINO_T_LORA_PAGER) + // The wheel can reach Send and Back; press to scroll, hold to leave editing. + lv_group_set_editing(binding->group, false); +#else lv_group_set_editing(binding->group, true); +#endif lv_obj_add_event_cb(binding->msg_list, on_msg_list_key, LV_EVENT_KEY, screen); } if (binding->reply_btn) diff --git a/modules/ui_shared/src/ui/screens/gps/gps_page_runtime.cpp b/modules/ui_shared/src/ui/screens/gps/gps_page_runtime.cpp index ca7cb134..dd25abf7 100644 --- a/modules/ui_shared/src/ui/screens/gps/gps_page_runtime.cpp +++ b/modules/ui_shared/src/ui/screens/gps/gps_page_runtime.cpp @@ -249,6 +249,7 @@ lv_obj_t* s_map_notice_label = nullptr; lv_obj_t* s_map_context_rail = nullptr; lv_obj_t* s_map_route_btn = nullptr; lv_obj_t* s_map_help_modal = nullptr; +lv_obj_t* s_map_help_previous_focus = nullptr; lv_obj_t* s_tracker_modal = nullptr; bool s_map_help_open_pending = false; bool s_map_refresh_pending = false; @@ -3567,11 +3568,17 @@ void close_map_help_modal() lv_group_add_obj(app_g, s_top_bar.back_btn); } add_map_controls_to_group(app_g); - if (s_map_help_btn) + if (s_map_help_previous_focus && lv_obj_is_valid(s_map_help_previous_focus) && + lv_obj_get_group(s_map_help_previous_focus) == app_g) + { + lv_group_focus_obj(s_map_help_previous_focus); + } + else if (s_map_help_btn) { lv_group_focus_obj(s_map_help_btn); } } + s_map_help_previous_focus = nullptr; } void on_map_help_modal_key(lv_event_t* e) @@ -3623,6 +3630,7 @@ void open_map_help_modal() return; } + s_map_help_previous_focus = app_g ? lv_group_get_focused(app_g) : nullptr; const auto frame = ::ui::components::dialog_frame::create(s_root, {"Map Help", 304, 216, [](lv_event_t*) { close_map_help_modal(); }, diff --git a/modules/ui_shared/src/ui/screens/music/music_page_input.cpp b/modules/ui_shared/src/ui/screens/music/music_page_input.cpp index e3d12da1..aab81166 100644 --- a/modules/ui_shared/src/ui/screens/music/music_page_input.cpp +++ b/modules/ui_shared/src/ui/screens/music/music_page_input.cpp @@ -24,7 +24,7 @@ bool command(::music::Action action, uint64_t id, int32_t value, const char* tex return false; } self.service->snapshot(self.snapshot); - if ((self.view == View::Playing || self.view == View::Details) && self.rendered_track != self.snapshot.current.id) draw(); + if (self.view == View::Details && self.rendered_track != self.snapshot.current.id) draw(); else update(); return true; } diff --git a/modules/ui_shared/src/ui/screens/music/music_page_layout.cpp b/modules/ui_shared/src/ui/screens/music/music_page_layout.cpp index ace7346b..7b355cd3 100644 --- a/modules/ui_shared/src/ui/screens/music/music_page_layout.cpp +++ b/modules/ui_shared/src/ui/screens/music/music_page_layout.cpp @@ -280,8 +280,6 @@ void menu_items() { add("Track info", Details); add("Add to playlist", AddToPlaylist); - add("Volume -", VolumeDown); - add("Volume +", VolumeUp); add("Stop playback", Stop); } else @@ -351,6 +349,9 @@ void draw() { auto* panel = card(self.body, 8, 6, width - 16, height - 12); lyrics(panel, width - 16, height - 12); + lv_group_add_obj(app_g, panel); + lv_obj_add_event_cb(panel, key_event, LV_EVENT_KEY, nullptr); + lv_group_focus_obj(panel); } else if (self.view == View::NewPlaylist) { @@ -395,6 +396,7 @@ void draw() } if (self.snapshot.metadata.unsupported_flags) label(panel, "Some compressed or encrypted tags could not be read", 0, 0, LV_PCT(100), &lv_font_montserrat_12, true); lv_obj_add_event_cb(panel, key_event, LV_EVENT_KEY, nullptr); + lv_group_add_obj(app_g, panel); lv_group_focus_obj(panel); } else if (self.view == View::ConfirmDelete) @@ -447,6 +449,15 @@ void draw() void update() { auto& self = *state(); + // Track changes keep the player controls and their rotary focus alive. + // Refresh raw ID3/artwork in place instead of rebuilding the whole page. + if (self.view == View::Playing) + { + changed(self.title, self.snapshot.current.id ? self.snapshot.current.title : ui::i18n::tr("Choose a song"), FontScope::Content); + changed(self.artist, self.snapshot.current.artist, FontScope::Content); + changed(self.album, self.snapshot.current.album, FontScope::Content); + self.rendered_track = self.snapshot.current.id; + } char time[32]; std::snprintf(time, sizeof(time), "%u:%02u", static_cast(self.snapshot.position_ms / 60000), static_cast((self.snapshot.position_ms / 1000) % 60)); changed(self.elapsed, time); diff --git a/modules/ui_shared/src/ui/screens/music/music_page_shell.cpp b/modules/ui_shared/src/ui/screens/music/music_page_shell.cpp index 84f80c88..928bbbeb 100644 --- a/modules/ui_shared/src/ui/screens/music/music_page_shell.cpp +++ b/modules/ui_shared/src/ui/screens/music/music_page_shell.cpp @@ -37,7 +37,7 @@ void tick(lv_timer_t*) if ((self->view == View::Library || self->view == View::ChoosePlaylist) && self->rendered_library != self->snapshot.library_revision) draw(); - else if ((self->view == View::Playing || self->view == View::Details) && self->rendered_track != self->snapshot.current.id) + else if (self->view == View::Details && self->rendered_track != self->snapshot.current.id) draw(); else if (self->view == View::Details && self->rendered_metadata != self->snapshot.metadata_revision) draw(); diff --git a/modules/ui_shared/src/ui/screens/network/network_page_shell.cpp b/modules/ui_shared/src/ui/screens/network/network_page_shell.cpp index 0609f275..0f4bb899 100644 --- a/modules/ui_shared/src/ui/screens/network/network_page_shell.cpp +++ b/modules/ui_shared/src/ui/screens/network/network_page_shell.cpp @@ -319,8 +319,8 @@ constexpr uint32_t kMicronHeading3Bg = 0x777777; constexpr const char* kMicronUnsupportedInline = "[unsupported inline]"; constexpr const char* kMicronUnsupportedBlock = "[unsupported block]"; constexpr uint32_t kPageLoadPollMs = 500; -constexpr uint32_t kPagerRotateUpKey = 19; -constexpr uint32_t kPagerRotateDownKey = 20; +constexpr uint32_t kPagerRotateUpKey = LV_KEY_LEFT; +constexpr uint32_t kPagerRotateDownKey = LV_KEY_RIGHT; constexpr lv_coord_t kViewportScrollStep = 28; constexpr lv_coord_t kViewportPageScrollPadding = 12; constexpr BuiltInFavourite kBuiltInFavourites[] = { @@ -4953,22 +4953,22 @@ bool viewport_scroll_delta_for_key(uint32_t key, lv_coord_t* delta) } if (key == LV_KEY_UP || key == kPagerRotateUpKey) { - *delta = -kViewportScrollStep; + *delta = kViewportScrollStep; return true; } if (key == LV_KEY_DOWN || key == kPagerRotateDownKey) { - *delta = kViewportScrollStep; + *delta = -kViewportScrollStep; return true; } if (key == LV_KEY_PREV) { - *delta = -viewport_page_scroll_step(); + *delta = viewport_page_scroll_step(); return true; } if (key == LV_KEY_NEXT) { - *delta = viewport_page_scroll_step(); + *delta = -viewport_page_scroll_step(); return true; } return false; diff --git a/modules/ui_shared/src/ui/widgets/ime/ime_widget.cpp b/modules/ui_shared/src/ui/widgets/ime/ime_widget.cpp index 4aff0557..b133702e 100644 --- a/modules/ui_shared/src/ui/widgets/ime/ime_widget.cpp +++ b/modules/ui_shared/src/ui/widgets/ime/ime_widget.cpp @@ -354,6 +354,11 @@ extern "C" bool ui_ime_is_active() return s_active_ime != nullptr; } +bool ime_owns_text_focus(lv_obj_t* object) +{ + return object && s_active_ime && s_active_ime->focus_obj() == object; +} + void ImeWidget::init(lv_obj_t* parent, lv_obj_t* textarea, bool force_touch_keyboard) { editor_keyboard_ = force_touch_keyboard; diff --git a/modules/ui_shared/src/ui/widgets/reticulum_call_overlay.cpp b/modules/ui_shared/src/ui/widgets/reticulum_call_overlay.cpp index d1e6aa9c..44d7e440 100644 --- a/modules/ui_shared/src/ui/widgets/reticulum_call_overlay.cpp +++ b/modules/ui_shared/src/ui/widgets/reticulum_call_overlay.cpp @@ -34,8 +34,6 @@ constexpr uint32_t kAccept = 0x3E7D3E; constexpr uint32_t kAcceptPressed = 0x2F662F; constexpr uint8_t kVolumeStep = 5; constexpr uint32_t kActivationGuardMs = 400; -constexpr int kEncoderKeyRotateUp = 19; -constexpr int kEncoderKeyRotateDown = 20; enum class Action : uint8_t { @@ -172,16 +170,14 @@ bool handle_focus_key(lv_event_t* event) return false; } const uint32_t key = lv_event_get_key(event); - if (key == LV_KEY_UP || key == LV_KEY_LEFT || key == LV_KEY_PREV || - key == static_cast(kEncoderKeyRotateUp)) + if (key == LV_KEY_UP || key == LV_KEY_LEFT || key == LV_KEY_PREV) { lv_group_focus_prev(s_page.group); lv_group_set_editing(s_page.group, false); stop_event(event); return true; } - if (key == LV_KEY_DOWN || key == LV_KEY_RIGHT || key == LV_KEY_NEXT || - key == static_cast(kEncoderKeyRotateDown)) + if (key == LV_KEY_DOWN || key == LV_KEY_RIGHT || key == LV_KEY_NEXT) { lv_group_focus_next(s_page.group); lv_group_set_editing(s_page.group, false); diff --git a/modules/ui_shared/src/ui/widgets/top_bar.cpp b/modules/ui_shared/src/ui/widgets/top_bar.cpp index 5b0e0925..5fe1ced1 100644 --- a/modules/ui_shared/src/ui/widgets/top_bar.cpp +++ b/modules/ui_shared/src/ui/widgets/top_bar.cpp @@ -4,12 +4,16 @@ */ #include "ui/widgets/top_bar.h" +#if defined(ARDUINO_T_LORA_PAGER) +#include "platform/ui/psram_ui_lease.h" +#endif #include #include #include "ui/assets/fonts/font_utils.h" #include "ui/components/bottom_bar.h" +#include "ui/input/pager_navigation.h" #include "ui/localization.h" #include "ui/page/page_profile.h" #include "ui/ui_theme.h" @@ -80,6 +84,9 @@ static const lv_font_t* resolve_top_bar_font(lv_coord_t height) void top_bar_init(TopBar& bar, lv_obj_t* parent, const TopBarConfig& config) { +#if defined(ARDUINO_T_LORA_PAGER) + platform::ui::PsramUiLease memory; +#endif const auto& profile = ::ui::page_profile::current(); const lv_coord_t resolved_height = resolve_top_bar_height(config); const bool large_touch = resolved_height > ::ui::kUnifiedTopBarHeight && @@ -205,6 +212,16 @@ void top_bar_init(TopBar& bar, lv_obj_t* parent, const TopBarConfig& config) { top_bar_power::bind(bar); } +#if defined(ARDUINO_T_LORA_PAGER) + // A shared, visible hint applies to every page, including text editors + // where H stays text until another control is selected with the wheel. + auto* help_hint = lv_label_create(bar.container); + lv_label_set_text(help_hint, "H Help"); + lv_obj_set_style_text_font(help_hint, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(help_hint, ui::theme::text(), 0); + lv_obj_move_to_index(help_hint, lv_obj_get_index(bar.right_label)); + ui::input::pager::install_help(parent, bar.title_label); +#endif } void top_bar_set_title(TopBar& bar, const char* title) diff --git a/platform/esp/arduino_common/src/LV_Helper_v9.cpp b/platform/esp/arduino_common/src/LV_Helper_v9.cpp index 8e7d3497..138ea1cb 100644 --- a/platform/esp/arduino_common/src/LV_Helper_v9.cpp +++ b/platform/esp/arduino_common/src/LV_Helper_v9.cpp @@ -17,6 +17,7 @@ #include "sys/clock.h" #include "ui/LV_Helper.h" #include "ui/app_runtime.h" +#include "ui/input/pager_navigation.h" #include "ui/menu/menu_dashboard.h" #include "ui/menu/menu_runtime.h" #include "ui/support/lvgl_fs_utils.h" @@ -1177,6 +1178,7 @@ static void lv_encoder_read(lv_indev_t* drv, lv_indev_data_t* data) // Screen is awake, process input normally if (msg.dir != ROTARY_DIR_NONE || msg.centerBtnPressed) { + ::ui::input::pager::prepare_group(lv_indev_get_group(drv)); ::platform::ui::screen::record_activity(); } @@ -1195,6 +1197,11 @@ static void lv_encoder_read(lv_indev_t* drv, lv_indev_data_t* data) break; } if (::ui::menu::dashboard::handleEncoder(data->enc_diff)) data->enc_diff = 0; + if (::ui::menu::dashboard::handleEncoderButton(msg.centerBtnPressed)) + { + data->state = LV_INDEV_STATE_RELEASED; + return; + } if (msg.centerBtnPressed) { data->state = LV_INDEV_STATE_PRESSED; @@ -1271,6 +1278,14 @@ static void keypad_read(lv_indev_t* drv, lv_indev_data_t* data) } // Screen is awake, process input normally + if (state == KEYBOARD_PRESSED) ::ui::input::pager::prepare_group(lv_indev_get_group(drv)); + if (::ui::input::pager::handle_key(key, state == KEYBOARD_PRESSED, lv_indev_get_group(drv))) + { + ::platform::ui::screen::record_activity(); + if (state == KEYBOARD_PRESSED) plane->feedback((void*)drv); + data->state = LV_INDEV_STATE_REL; + return; + } if (!from_nav && (state == KEYBOARD_PRESSED || state == KEYBOARD_RELEASED)) { AppScreen* active_app = ui_get_active_app(); diff --git a/tests/site/compass-capability-native.test.mjs b/tests/site/compass-capability-native.test.mjs index cc8d31c1..31129dd5 100644 --- a/tests/site/compass-capability-native.test.mjs +++ b/tests/site/compass-capability-native.test.mjs @@ -31,7 +31,7 @@ for (const device of ['pager','tdeck','wio-tracker-l2','t-display-p4-tft','t-dis assert.ok(snapshot().some(item=>item.text==='Agenda'),'filtered overview keeps Agenda as next page'); } else { const map=snapshot().find(item=>item.button&&item.text==='Map');assert.ok(map); - runtime._native_focus(map.id);runtime._native_key('a'.charCodeAt(0));tick(); + runtime._native_focus(map.id);runtime._native_key((device==='pager'?'v':'a').charCodeAt(0));tick(); for(let page=0;page<6;page++) { assert.equal(runtime._native_widget_page(),page); assert.ok(!compass(),'absent magnetometer never creates Compass'); diff --git a/tests/site/menu-widgets-native.test.mjs b/tests/site/menu-widgets-native.test.mjs index 03391a01..f6bd59b9 100644 --- a/tests/site/menu-widgets-native.test.mjs +++ b/tests/site/menu-widgets-native.test.mjs @@ -95,7 +95,7 @@ for (const [device,width,height] of boards.filter(([name])=>!process.env.WIDGET_ assert.notEqual(runtime._native_menu_focus(),focused,'menu encoder changes launcher focus'); assert.equal(runtime._native_widget_page(),-1,'menu encoder never opens widgets'); runtime._native_focus(map.id); - if(device==='pager')key('a');else swipe(30,80,100,0); + if(device==='pager')key('v');else swipe(30,80,100,0); assert.equal(runtime._native_widget_page(),0,'open negative screen'); assert.equal(runtime._native_widget_count(),1); key('h'); @@ -122,14 +122,14 @@ for (const [device,width,height] of boards.filter(([name])=>!process.env.WIDGET_ key('w');assert.equal(runtime._native_widget_page(),2); const before=runtime._native_preview_agenda_queries(); tick(35000);assert.equal(runtime._native_preview_agenda_queries(),before,'hidden Agenda is never queried'); - if(device==='pager')key('d');else swipe(width-30,80,-100,0); + if(device==='pager')key('v');else swipe(width-30,80,-100,0); assert.equal(runtime._native_widget_page(),-1); assert.equal(runtime._native_widget_leases(),0); runtime._native_encoder(1);tick(); assert.equal(runtime._native_widget_page(),-1,'encoder resumes launcher navigation'); for(let cycle=0;cycle<50;cycle++){ - runtime._native_focus(map.id);key('a'); - runtime._native_encoder(1);tick();key('d'); + runtime._native_focus(map.id);key(device==='pager'?'v':'a'); + runtime._native_encoder(1);tick();key(device==='pager'?'v':'d'); assert.equal(runtime._native_widget_leases(),0,`${device}: repeated feed exit frees state`); } } diff --git a/tests/site/music-cjk-native.test.mjs b/tests/site/music-cjk-native.test.mjs index 8e927cd4..5ceff9ae 100644 --- a/tests/site/music-cjk-native.test.mjs +++ b/tests/site/music-cjk-native.test.mjs @@ -66,7 +66,7 @@ for (const [device,width,height] of boards.filter(([name]) => !process.env.MUSIC if(device.startsWith('t-display')) {click('\uf054');click('\uf054');} else { const map=snapshot().find(item => item.button && item.text==='Map');assert.ok(map); - runtime._native_focus(map.id);runtime._native_key('a'.charCodeAt(0));tick(); + runtime._native_focus(map.id);runtime._native_key((device==='pager'?'v':'a').charCodeAt(0));tick(); for(let i=0;i<4;i++){runtime._native_encoder(1);tick();} } text();await verify('widget',['Music','空白格','蔡健雅','中文专辑']); diff --git a/tests/site/music-native-ui.test.mjs b/tests/site/music-native-ui.test.mjs index 9e24a3a6..1217ecbe 100644 --- a/tests/site/music-native-ui.test.mjs +++ b/tests/site/music-native-ui.test.mjs @@ -30,7 +30,9 @@ for(const [device,width,height] of boards.filter(([name])=>!process.env.MUSIC_UI await capture('interrupted'); runtime._native_preview_music_playing(1);tick();tick(); click('Lyrics');assert.ok(snapshot().some(item=>item.text==='We take the quiet way home'));await capture('lyrics'); - click('\uf053');click('Menu');click('Track info');await capture('metadata');assert.ok(snapshot().some(item=>item.text==='Aster')); + click('\uf053');click('Menu'); + assert.ok(!snapshot().some(item=>item.button&&/^Volume [+-]$/.test(item.text)),`${device}: Now Playing menu excludes incremental volume actions`); + click('Track info');await capture('metadata');assert.ok(snapshot().some(item=>item.text==='Aster')); click('\uf053');click('Library');click('Browse');click('Playlists');await capture('playlists'); click('Browse');click('New playlist');await capture('new-playlist'); const editor=snapshot().find(item=>item.input);assert.ok(editor); diff --git a/tests/site/music-widget-refresh-native.test.mjs b/tests/site/music-widget-refresh-native.test.mjs index ce85d061..e5bab7bd 100644 --- a/tests/site/music-widget-refresh-native.test.mjs +++ b/tests/site/music-widget-refresh-native.test.mjs @@ -27,7 +27,7 @@ for (const [device,width,height] of boards) { swipe();swipe(); assert.equal(runtime._native_widget_page(),2); } else { - runtime._native_key('a'.charCodeAt(0));tick(300); + runtime._native_key((device==='pager'?'v':'a').charCodeAt(0));tick(300); for(let page=0;page<4;page++){runtime._native_encoder(1);tick(100);} assert.equal(runtime._native_widget_page(),4); } diff --git a/tests/site/pager-input-native.test.mjs b/tests/site/pager-input-native.test.mjs new file mode 100644 index 00000000..04bf717e --- /dev/null +++ b/tests/site/pager-input-native.test.mjs @@ -0,0 +1,135 @@ +import assert from 'node:assert/strict'; +import {readFile,mkdir,writeFile} from 'node:fs/promises'; +import {join} from 'node:path'; +import {framePng} from './native-frame-png.mjs'; + +const base=new URL('../../site/assets/native/pager',import.meta.url); +const {default:create}=await import(`${base}.js`); +const data=await readFile(new URL(`${base}.data`)); +let runtime,frame; +runtime=await create({wasmBinary:await readFile(new URL(`${base}.wasm`)), + getPreloadedPackage(){return data.buffer.slice(data.byteOffset,data.byteOffset+data.byteLength);}, + onFrame(ptr,w,h){frame=Buffer.from(runtime.HEAPU8.subarray(ptr,ptr+w*h*2));},print(){},printErr:console.error}); +const tick=(ms=250)=>{for(let i=0;iJSON.parse(runtime.UTF8ToString(runtime._native_snapshot())); +const has=text=>snapshot().some(item=>item.text===text); +const key=k=>{const code=typeof k==='string'?k.charCodeAt(0):k;runtime._native_keyboard_input(code,1);tick(20);runtime._native_keyboard_input(code,0);tick();}; +const wheel=delta=>{runtime._native_encoder_input(delta,0);tick();}; +const press=()=>{runtime._native_encoder_input(0,1);tick(30);runtime._native_encoder_input(0,0);tick();}; +const capture=async name=>{ + if(!process.env.WIDGET_UI_ARTIFACT_DIR)return; + const dir=join(process.env.WIDGET_UI_ARTIFACT_DIR,'pager');await mkdir(dir,{recursive:true}); + await writeFile(join(dir,`${name}.png`),framePng(frame,480,222)); +}; +runtime._native_init(0);runtime._native_open_page(10);tick(); +const map=snapshot().find(item=>item.button&&item.text==='Map'); +runtime._native_focus(map.id); +for(const k of ['a','w','s','d','A','W','S','D']){ + key(k);assert.equal(runtime._native_widget_page(),-1,'WASD never opens widgets at an edge'); +} +const menuFocus=runtime._native_menu_focus(); +key('v');assert.equal(runtime._native_widget_page(),0); +key('V');assert.equal(runtime._native_widget_page(),-1); +assert.equal(runtime._native_menu_focus(),menuFocus,'V preserves launcher selection'); +runtime._native_preview_music_playing(1); +key('v');for(let i=0;i<4;i++)wheel(1); +assert.ok(has('Music')&&has('Pause')); +assert.ok(snapshot().some(item=>item.button&&item.text==='Pause'&&item.focused),'visible initial Play/Pause selection'); +key(10);assert.ok(has('Play'),'keyboard Enter toggles pause exactly once'); +press();assert.ok(has('Pause'),'encoder press toggles play exactly once'); +key('d');key(10);assert.ok(has('Pines at dusk'),'select and activate Next'); +key('a');key('a');key(10);assert.ok(has('After the rain'),'select and activate Previous'); +key('d');key('d');key('d');press();assert.ok(has('Repeat one'),'encoder activates play mode'); +await capture('music-keyboard-focus'); +wheel(1);assert.ok(has('Quick controls')); +const state=name=>{const id=snapshot().find(item=>item.button&&item.text===name)?.id;return snapshot().find(item=>item.id===id&&['On','Off'].includes(item.text))?.text;}; +assert.equal(state('Wi-Fi'),'Off');key(10);assert.equal(state('Wi-Fi'),'On'); +key('d');press();assert.equal(state('LoRa'),'Off'); +key('d');key(10);assert.equal(state('GPS'),'Off'); +key('d');key(10);assert.ok(snapshot().some(item=>item.text?.includes('A/D adjust'))); +const volume=snapshot().find(item=>item.slider).value; +key('d');assert.equal(snapshot().find(item=>item.slider).value,volume+5); +tick(1500);assert.equal(snapshot().find(item=>item.slider).value,volume+5,'cached refresh preserves applied volume'); +key('a');key(10);key('d');press(); +const brightness=snapshot().filter(item=>item.slider)[1].value; +key('d');assert.equal(snapshot().filter(item=>item.slider)[1].value,brightness+5); +key('a');press(); +await capture('controls-keyboard-focus'); +key('h');assert.ok(has('Select widget control')); +const page=runtime._native_widget_page();wheel(-1);key('d');assert.equal(runtime._native_widget_page(),page,'Help owns input'); +key('h');wheel(-1);assert.equal(runtime._native_widget_page(),4); +// Open is a real selectable widget control; ownership includes a held press +// and its release after the app has replaced the widget. +for(let i=0;i<3;i++)key('d'); +runtime._native_encoder_input(0,1);tick(250); +runtime._native_encoder_input(0,1);tick(250); +runtime._native_encoder_input(0,0);tick(); +assert.equal(runtime._native_page_value(),19); +assert.ok(has('Now Playing')); +assert.equal(runtime._native_widget_count(),0); +const next=snapshot().find(item=>item.button&&item.text==='\uf051');assert.ok(next); +runtime._native_focus(next.id); +const selectedTrack=snapshot().find(item=>item.text==='After the rain')?.text; +press();tick(600); +assert.equal(runtime._native_input_focus(),next.id,'skip does not rebuild controls or move rotary focus'); +press();tick(600); +assert.equal(runtime._native_input_focus(),next.id,'repeated skips retain the same control'); +assert.ok(!selectedTrack||!has(selectedTrack),'ID3 changes while the controls remain alive'); + +const pages=[[0,'calculator'],[1,'sky-plot'],[2,'USB'],[3,'walkie'],[4,'SSTV'],[5,'chat-list'], + [6,'conversation'],[7,'compose'],[8,'extensions'],[9,'network'],[11,'settings'],[12,'contacts'], + [13,'team'],[14,'tracker'],[15,'map'],[16,'probe'],[19,'music'],[20,'geocaching'],[21,'agenda']]; +for(const [id,name] of pages){ + runtime._native_open_page(id);tick(); + assert.ok(has('H Help'),`${name}: visible Help hint`); + assert.ok(runtime._native_input_focus(),`${name}: initial focus exists`); + const before=runtime._native_input_focus(),count=runtime._native_input_count(); + assert.equal(runtime._native_input_editing(),0,`${name}: starts in navigation mode`); + wheel(1); + if(count>1)assert.notEqual(runtime._native_input_focus(),before,`${name}: actual encoder moves focus`); + // H is ordinary text inside an editor. Move to a button for page help. + for(let n=0;nitem.text==='Select control'||(/Help$/.test(item.text)&&item.text!=='H Help')),`${name}: Help opens: ${snapshot().map(x=>x.text).join(' | ')}`); + await capture(`help-${name}`); + const shouldScroll=runtime._native_input_editing()&&runtime._native_input_scroll_bottom()>0; + const scrollBefore=runtime._native_input_scroll_y(); + wheel(1); + if(shouldScroll)assert.ok(runtime._native_input_scroll_y()>scrollBefore,`${name}: real encoder scrolls Help content`); + key('h'); + assert.equal(runtime._native_page_value(),id,`${name}: Help closes without leaving app`); + assert.equal(runtime._native_input_focus(),focused,`${name}: Help restores exact focus`); + await capture(`input-${name}`); +} +// Empty Tracker must still expose Menu and Back, including after refresh. +runtime._native_open_page(14);tick();wheel(1); +const trackerFocus=runtime._native_input_focus();tick(6000); +assert.equal(runtime._native_input_focus(),trackerFocus,'periodic refresh retains selection'); +key('m');tick();assert.ok(has('Tracks')&&has('Routes')); +key(10);tick();assert.ok(has('View')&&has('New')&&has('Current'),'keypad Enter opens the Tracks submenu exactly once'); +key('m');tick(); +const selectMenu=()=>{const menu=snapshot().find(item=>item.button&&item.text==='Menu');assert.ok(menu);runtime._native_focus(menu.id);}; +selectMenu();key(10);assert.ok(has('Tracks')&&has('Routes'),'keypad Enter opens Menu exactly once'); +key(27);tick();assert.equal(runtime._native_page_value(),14); +selectMenu();press();assert.ok(has('Tracks')&&has('Routes'),'encoder click opens Menu exactly once'); +key(27);tick(); +// A focused text editor keeps literal h, w, s, and v. Encoder navigation can +// leave it without first finding an undisclosed edit-mode shortcut. +runtime._native_open_page(7);tick(); +const editor=snapshot().find(item=>item.input);assert.ok(editor); +runtime._native_focus(editor.id); +runtime.ccall('native_set_text',null,['number','string'],[editor.id,'']); +for(const k of ['h','w','s','v'])key(k); +assert.equal(snapshot().find(item=>item.input)?.text,'hwsv'); +wheel(1);assert.notEqual(runtime._native_input_focus(),editor.id); +assert.equal(runtime._native_ime_proxy_protected(),1,'active IME proxy keeps virtual focus and literal h'); +// Destroy pages with Help still open: no stale restore group or orphan modal. +for(let cycle=0;cycle<20;cycle++){ + runtime._native_open_page(0);tick();key('h'); + runtime._native_open_page(13);tick();wheel(1);key('h');key('h'); + assert.ok(runtime._native_input_focus()); +} +runtime._native_open_page(10);tick(); +assert.equal(runtime._native_widget_leases(),0); +console.log('Pager: physical WASD/V, all Music/Controls buttons and sliders, encoder press ownership, 19 app focus/help routes and modal teardown passed.'); diff --git a/tests/site/quick-controls-native.test.mjs b/tests/site/quick-controls-native.test.mjs index 64db67bf..99712e29 100644 --- a/tests/site/quick-controls-native.test.mjs +++ b/tests/site/quick-controls-native.test.mjs @@ -16,7 +16,7 @@ for(const [device,width,height] of boards.filter(([name])=>!process.env.WIDGET_U runtime._native_init(device==='pager'?0:1);runtime._native_preview_music_playing(1);runtime._native_open_page(10);tick(); const p4=device.startsWith('t-display'); if(p4){click('\uf054');click('\uf054');}else{ - const map=snapshot().find(item=>item.button&&item.text==='Map');runtime._native_focus(map.id);key('a'); + const map=snapshot().find(item=>item.button&&item.text==='Map');runtime._native_focus(map.id);key(device==='pager'?'v':'a'); for(let i=0;i<4;i++){runtime._native_encoder(1);tick();} } assert.ok(snapshot().some(item=>item.text==='Music'));assert.ok(snapshot().some(item=>item.text==='After the rain')); @@ -51,7 +51,7 @@ for(const [device,width,height] of boards.filter(([name])=>!process.env.WIDGET_U drag(0,-80); assert.equal(runtime._native_widget_page(),pageBefore,'vertical slider drag retains the current widget'); if(device==='pager'){ - runtime._native_encoder(-1);tick();key(10); // LV_KEY_ENTER is '\n'. + runtime._native_encoder(-1);tick();key('d');key('d');key('d');key(10); // Play -> Next -> Mode -> Open. assert.ok(snapshot().some(item=>item.text==='Now Playing'),'Enter on music widget opens the full player'); assert.equal(runtime._native_widget_count(),0,'opening music releases the widget'); assert.equal(runtime._native_widget_leases(),1,'only the full music page retains a PSRAM UI lease'); diff --git a/tools/web_simulator/CMakeLists.txt b/tools/web_simulator/CMakeLists.txt index 79016882..7308d557 100644 --- a/tools/web_simulator/CMakeLists.txt +++ b/tools/web_simulator/CMakeLists.txt @@ -28,6 +28,17 @@ set(UI "${REPO_ROOT}/modules/ui_shared/src/ui") function(add_native_preview PREVIEW_TARGET BOARD_DEFINITION OUTPUT_NAME) add_executable(${PREVIEW_TARGET} native_runtime.cpp + "${UI}/input/pager_navigation.cpp" + "${UI}/screens/agenda/agenda_state.cpp" + "${UI}/screens/agenda/agenda_page_shell.cpp" + "${UI}/screens/agenda/agenda_page_runtime.cpp" + "${UI}/screens/agenda/agenda_page_input.cpp" + "${UI}/screens/agenda/agenda_page_components.cpp" + "${UI}/screens/agenda/agenda_editor_runtime.cpp" + "${UI}/screens/agenda/agenda_editor_components.cpp" + "${REPO_ROOT}/modules/core_agenda/src/recurrence.cpp" + "${REPO_ROOT}/modules/core_agenda/src/event_codec.cpp" + "${REPO_ROOT}/modules/core_waypoint/src/waypoint.cpp" "${UI}/screens/common/placeholder_page.cpp" native_pages.cpp native_menu.cpp @@ -125,6 +136,7 @@ target_include_directories(${PREVIEW_TARGET} PRIVATE "${REPO_ROOT}/modules/core_chat/include" "${REPO_ROOT}/modules/core_gps/include" "${REPO_ROOT}/modules/core_agenda/include" + "${REPO_ROOT}/modules/core_waypoint/include" "${REPO_ROOT}/modules/core_music/include" "${REPO_ROOT}/modules/core_track_sharing/include" "${REPO_ROOT}/modules/core_geocaching/include" diff --git a/tools/web_simulator/native_menu.cpp b/tools/web_simulator/native_menu.cpp index 221b801c..eac9397d 100644 --- a/tools/web_simulator/native_menu.cpp +++ b/tools/web_simulator/native_menu.cpp @@ -24,12 +24,27 @@ static ui::AppCatalog catalog; static int page_for(const char* id) { if (std::strcmp(id, "music") == 0) return 19; + if (std::strcmp(id, "geocaching") == 0) return 20; const char* ids[] = {"calculator", "sky_plot", "usb_mass_storage", "walkie_talkie", "sstv", "chat", "", "", "extensions", "network", "", "settings", "contacts", "team", "tracker", "map", "energy_sweep"}; for (int i = 0; i < 17; ++i) if (std::strcmp(ids[i], id) == 0) return i; return -1; } +void preview_select_page(int page) +{ + active_app = nullptr; + for (size_t i = 0; i < ui::catalogCount(catalog); ++i) + { + auto* app = ui::catalogAt(catalog, i); + if (page_for(app->stable_id()) == page || (page >= 5 && page <= 7 && !std::strcmp(app->stable_id(), "chat"))) + { + active_app = app; + break; + } + } +} + void preview_menu_enter() { preview_bind_controls(); diff --git a/tools/web_simulator/native_pages.cpp b/tools/web_simulator/native_pages.cpp index b6bf4a0d..d7c92132 100644 --- a/tools/web_simulator/native_pages.cpp +++ b/tools/web_simulator/native_pages.cpp @@ -1,10 +1,12 @@ #include "lvgl.h" #include "ui/app_runtime.h" #include "ui/menu/menu_runtime.h" +#include "ui/screens/agenda/agenda_page_shell.h" #include "ui/screens/calculator/calculator_page_runtime.h" #include "ui/screens/contacts/contacts_page_runtime.h" #include "ui/screens/energy_sweep/energy_sweep_page_runtime.h" #include "ui/screens/extensions/extensions_page_runtime.h" +#include "ui/screens/geocaching/geocaching_page_shell.h" #include "ui/screens/gnss/gnss_skyplot_page_runtime.h" #include "ui/screens/gps/gps_page_runtime.h" #include "ui/screens/music/music_page_shell.h" @@ -26,11 +28,19 @@ void preview_power_exit(); void preview_chat_enter(int page); void preview_chat_exit(); void preview_bind_music(); +void preview_select_page(int page); +void preview_enter_agenda(lv_obj_t* parent); void preview_exit_page() { switch (current_page) { + case 20: + geocaching::ui::shell::exit(nullptr, lv_screen_active()); + break; + case 21: + ui::agenda::page::exit(nullptr, lv_screen_active()); + break; case 19: ui::music_page::exit(nullptr, lv_screen_active()); break; @@ -94,10 +104,22 @@ void preview_exit_page() extern "C" EMSCRIPTEN_KEEPALIVE void native_open_page(int page) { preview_exit_page(); + preview_select_page(page); lv_group_remove_all_objs(app_g); + lv_group_set_editing(app_g, false); if (page != 10 && page != 17 && page != 18) ui::menu_runtime::setScene(ui::menu_runtime::Scene::App); switch (page) { + case 20: + { + static const ui::page::Host host{nullptr, [](void*) + { ui_request_exit_to_menu(); }}; + geocaching::ui::shell::enter(const_cast(&host), lv_screen_active()); + break; + } + case 21: + preview_enter_agenda(lv_screen_active()); + break; case 19: preview_bind_music(); ui::music_page::enter(nullptr, lv_screen_active()); diff --git a/tools/web_simulator/native_runtime.cpp b/tools/web_simulator/native_runtime.cpp index 319a45b4..3b1e3bc7 100644 --- a/tools/web_simulator/native_runtime.cpp +++ b/tools/web_simulator/native_runtime.cpp @@ -3,11 +3,14 @@ #include "lvgl.h" #include "platform/ui/walkie_runtime.h" #include "ui/app_runtime.h" +#include "ui/input/pager_navigation.h" #include "ui/localization.h" #include "ui/menu/menu_dashboard.h" #include "ui/menu/menu_runtime.h" #include "ui/page/page_profile.h" #include "ui/screens/calculator/calculator_page_runtime.h" +#include "ui/widgets/ime/ime_widget.h" +#include "ui/widgets/top_bar.h" #include #include #include @@ -22,6 +25,10 @@ lv_group_t* menu_g = nullptr; lv_obj_t* main_screen = nullptr; static lv_display_t* display; static lv_indev_t* pointer_device; +static lv_indev_t* encoder_device; +static lv_indev_t* keyboard_device; +static int encoder_delta = 0, hardware_key = 0; +static bool encoder_pressed = false, hardware_key_pressed = false; static lv_group_t* bound_input_group; #if defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) #if defined(CONFIG_TRAIL_MATE_T_DISPLAY_P4_PANEL_HI8561) @@ -61,13 +68,30 @@ void set_default_group(lv_group_t* group) // Native keys/encoder are dispatched directly, independently of touch. // A pointer indev cannot retain a keypad/encoder group in LVGL. bound_input_group = group; + if (encoder_device) lv_indev_set_group(encoder_device, group); + if (keyboard_device) lv_indev_set_group(keyboard_device, group); lv_group_set_default(group); } static lv_group_t* input_group() { // Pages may clear LVGL's auto-add default after binding their input group. - return bound_input_group ? bound_input_group : lv_group_get_default(); + // The indev also clears a deleted group, unlike a borrowed host pointer. + return keyboard_device ? lv_indev_get_group(keyboard_device) : bound_input_group; +} + +static lv_obj_t* focused_scroll_target() +{ + auto* group = input_group(); + auto* focused = group ? lv_group_get_focused(group) : nullptr; + if (!focused) return nullptr; + if (lv_obj_has_flag(focused, LV_OBJ_FLAG_SCROLLABLE)) return focused; + for (uint32_t i = 0; i < lv_obj_get_child_count(focused); ++i) + { + auto* child = lv_obj_get_child(focused, i); + if (lv_obj_has_flag(child, LV_OBJ_FLAG_SCROLLABLE)) return child; + } + return nullptr; } void ui_request_exit_to_menu() { @@ -106,8 +130,8 @@ static void collect_labels(lv_obj_t* obj) lv_area_t area; lv_obj_get_coords(obj, &area); char data[220]; - std::snprintf(data, sizeof(data), "%s{\"slider\":true,\"x\":%d,\"y\":%d,\"w\":%d,\"h\":%d,\"value\":%d}", - snapshot.size() > 1 ? "," : "", static_cast(area.x1), static_cast(area.y1), + std::snprintf(data, sizeof(data), "%s{\"id\":%u,\"slider\":true,\"focused\":%s,\"x\":%d,\"y\":%d,\"w\":%d,\"h\":%d,\"value\":%d}", + snapshot.size() > 1 ? "," : "", static_cast(reinterpret_cast(obj)), lv_obj_has_state(obj, LV_STATE_FOCUS_KEY) ? "true" : "false", static_cast(area.x1), static_cast(area.y1), static_cast(lv_area_get_width(&area)), static_cast(lv_area_get_height(&area)), static_cast(lv_slider_get_value(obj))); snapshot += data; } @@ -129,9 +153,9 @@ static void collect_labels(lv_obj_t* obj) lv_area_t area; lv_obj_get_coords(button ? parent : obj, &area); char data[200]; - std::snprintf(data, sizeof(data), "%s{\"id\":%u,\"button\":%s,\"x\":%d,\"y\":%d,\"w\":%d,\"h\":%d,\"text\":", + std::snprintf(data, sizeof(data), "%s{\"id\":%u,\"button\":%s,\"focused\":%s,\"x\":%d,\"y\":%d,\"w\":%d,\"h\":%d,\"text\":", snapshot.size() > 1 ? "," : "", static_cast(reinterpret_cast(parent)), - button ? "true" : "false", static_cast(area.x1), static_cast(area.y1), + button ? "true" : "false", button && lv_obj_has_state(parent, LV_STATE_FOCUS_KEY) ? "true" : "false", static_cast(area.x1), static_cast(area.y1), static_cast(lv_area_get_width(&area)), static_cast(lv_area_get_height(&area))); snapshot += data; append_json_string(text); @@ -163,6 +187,21 @@ extern "C" { data->point.x = pointer_x; data->point.y = pointer_y; data->state = pointer_down ? LV_INDEV_STATE_PRESSED : LV_INDEV_STATE_RELEASED; }); + encoder_device = lv_indev_create(); + lv_indev_set_type(encoder_device, LV_INDEV_TYPE_ENCODER); + lv_indev_set_mode(encoder_device, LV_INDEV_MODE_EVENT); + lv_indev_set_read_cb(encoder_device, [](lv_indev_t*, lv_indev_data_t* data) + { + data->enc_diff = encoder_delta; + encoder_delta = 0; + data->state = encoder_pressed ? LV_INDEV_STATE_PRESSED : LV_INDEV_STATE_RELEASED; }); + keyboard_device = lv_indev_create(); + lv_indev_set_type(keyboard_device, LV_INDEV_TYPE_KEYPAD); + lv_indev_set_mode(keyboard_device, LV_INDEV_MODE_EVENT); + lv_indev_set_read_cb(keyboard_device, [](lv_indev_t*, lv_indev_data_t* data) + { + data->key = hardware_key; + data->state = hardware_key_pressed ? LV_INDEV_STATE_PRESSED : LV_INDEV_STATE_RELEASED; }); app_g = lv_group_create(); set_default_group(app_g); ui::i18n::set_locale("en", false); @@ -185,6 +224,11 @@ extern "C" { lv_tick_inc(elapsed); lv_timer_handler(); + // Hardware input readers keep polling while idle. In event-mode + // previews, drain group-reset/release state between deliberate events + // too, so a group's first real detent is not spent clearing its reset. + if (encoder_device && !encoder_pressed) lv_indev_read(encoder_device); + if (keyboard_device && !hardware_key_pressed) lv_indev_read(keyboard_device); ui::i18n::on_lvgl_frame_completed(); } EMSCRIPTEN_KEEPALIVE void native_pointer(int x, int y, int down) @@ -202,6 +246,7 @@ extern "C" } EMSCRIPTEN_KEEPALIVE void native_key(int key) { + if (ui::input::pager::handle_key(key, true, input_group())) return; if (preview_current_page() == 10 && ui::menu::dashboard::handleKey(key)) return; if (key == LV_KEY_ENTER && preview_current_page() == 10) { @@ -241,6 +286,7 @@ extern "C" } } if (pressed) native_key(key); + else ui::input::pager::handle_key(key, false, input_group()); } EMSCRIPTEN_KEEPALIVE void native_encoder(int delta) { @@ -251,6 +297,81 @@ extern "C" else lv_group_focus_next(group); } } + // Exercise LVGL's actual encoder edit/navigation and keypad press/release + // state machines. The older helpers above intentionally send events directly. + EMSCRIPTEN_KEEPALIVE void native_encoder_input(int delta, int pressed) + { + ui::input::pager::prepare_group(input_group()); + encoder_delta = ui::menu::dashboard::handleEncoder(delta) ? 0 : delta; + encoder_pressed = ui::menu::dashboard::handleEncoderButton(pressed != 0) ? false : pressed != 0; + lv_indev_read(encoder_device); + } + EMSCRIPTEN_KEEPALIVE void native_keyboard_input(int key, int pressed) + { + if (key == '\r') key = LV_KEY_ENTER; + if (pressed) ui::input::pager::prepare_group(input_group()); + if (ui::input::pager::handle_key(key, pressed != 0, input_group())) return; + if (key < 128 && (ui::menu_runtime::handleWalkieKey(static_cast(key), pressed) || + ui::menu_runtime::handleShortcutKey(static_cast(key), pressed))) return; + hardware_key = key; + hardware_key_pressed = pressed != 0; + lv_indev_read(keyboard_device); + } + EMSCRIPTEN_KEEPALIVE uint32_t native_input_focus() + { + auto* group = input_group(); + return group ? static_cast(reinterpret_cast(lv_group_get_focused(group))) : 0; + } + EMSCRIPTEN_KEEPALIVE int native_input_editing() + { + auto* group = input_group(); + return group && lv_group_get_editing(group); + } + EMSCRIPTEN_KEEPALIVE int native_input_is_editor() + { + auto* group = input_group(); + for (auto* object = group ? lv_group_get_focused(group) : nullptr; object; object = lv_obj_get_parent(object)) + if (lv_obj_check_type(object, &lv_textarea_class)) return 1; + return 0; + } + EMSCRIPTEN_KEEPALIVE int native_ime_proxy_protected() + { + auto* previous = input_group(); + auto* group = lv_group_create(); + set_default_group(group); + auto* root = lv_obj_create(lv_screen_active()); + ui::widgets::TopBar bar; + ui::widgets::top_bar_init(bar, root); + auto* editor = lv_textarea_create(root); + ui::widgets::ImeWidget ime; + ime.init(root, editor); + lv_group_add_obj(group, ime.focus_obj()); + lv_group_focus_obj(ime.focus_obj()); + lv_group_set_editing(group, true); + ui::input::pager::prepare_group(group); + const bool protected_focus = lv_group_get_focused(group) == ime.focus_obj(); + const bool protected_key = !ui::input::pager::handle_key('h', true, group); + ime.detach(); + lv_obj_delete(root); + set_default_group(previous); + lv_group_delete(group); + return protected_focus && protected_key; + } + EMSCRIPTEN_KEEPALIVE unsigned native_input_count() + { + auto* group = input_group(); + return group ? lv_group_get_obj_count(group) : 0; + } + EMSCRIPTEN_KEEPALIVE int native_input_scroll_y() + { + auto* target = focused_scroll_target(); + return target ? lv_obj_get_scroll_y(target) : 0; + } + EMSCRIPTEN_KEEPALIVE int native_input_scroll_bottom() + { + auto* target = focused_scroll_target(); + return target ? lv_obj_get_scroll_bottom(target) : 0; + } EMSCRIPTEN_KEEPALIVE const char* native_snapshot() { snapshot = "["; diff --git a/tools/web_simulator/preview_agenda.cpp b/tools/web_simulator/preview_agenda.cpp index f91365b9..43459735 100644 --- a/tools/web_simulator/preview_agenda.cpp +++ b/tools/web_simulator/preview_agenda.cpp @@ -1,5 +1,7 @@ // Deterministic service boundary for real widget rendering. Firmware binds the // target-owned Agenda model instead; this source never enters a device build. +#include "ui/app_runtime.h" +#include "ui/screens/agenda/agenda_page_shell.h" #include "ui_presentation/agenda/agenda_source.h" #include #include @@ -46,3 +48,22 @@ extern "C" EMSCRIPTEN_KEEPALIVE void native_preview_agenda_state(int ready, int clock_ready = clock != 0; } extern "C" EMSCRIPTEN_KEEPALIVE int native_preview_agenda_queries() { return queries; } + +void preview_enter_agenda(lv_obj_t* parent) +{ + class Actions : public ui::agenda::IAgendaActionSink + { + public: + ui::UiActionResult save(const agenda::EventRecord&) override { return ui::UiActionResult::fail(ui::UiActionFailure::Unsupported); } + ui::UiActionResult remove(uint32_t) override { return ui::UiActionResult::fail(ui::UiActionFailure::Unsupported); } + ui::UiActionResult dismissReminder(uint32_t) override { return ui::UiActionResult::fail(ui::UiActionFailure::Unsupported); } + ui::UiActionResult snoozeReminder(uint32_t) override { return ui::UiActionResult::fail(ui::UiActionFailure::Unsupported); } + }; + static Actions actions; + static ui::agenda::page::Host host; + host.source = &source; + host.actions = &actions; + host.navigation.request_exit = [](void*) + { ui_request_exit_to_menu(); }; + ui::agenda::page::enter(&host, parent); +}