From aba8c142c8d2697be8a3d20323f39488bb15d257 Mon Sep 17 00:00:00 2001 From: liu weikai Date: Mon, 9 Nov 2026 04:23:46 +0800 Subject: [PATCH] fix(ui): complete Zero keyboard navigation and safe page shutdown --- CHANGELOG.md | 24 +++ apps/linux_cardputer_zero/CMakeLists.txt | 13 ++ apps/linux_cardputer_zero/README.md | 36 +++- .../linux_cardputer_zero_calculator_test.cpp | 19 +- ...inux_cardputer_zero_chat_keyboard_test.cpp | 53 ++++- .../tests/linux_evdev_exit_test.cpp | 45 ++++- .../tests/linux_geocaching_page_test.cpp | 7 + .../linux_keyboard_modal_navigation_test.cpp | 184 ++++++++++++++++++ .../tests/linux_team_capability_test.cpp | 90 +++++++++ cmake/TrailMateLinuxSources.cmake | 1 + .../linux-paired-mesh-validation-20261108.md | 63 +++++- docs/engineering/release-1.1.0.md | 15 ++ .../map_poi/annotation_layout.h | 4 + .../src/map_poi/annotation_frame.cpp | 4 +- .../include/ui/components/dialog_frame.h | 17 ++ .../include/ui/input/cardputer_keyboard.h | 15 ++ .../include/ui/input/keyboard_shortcuts.h | 7 + .../include/ui/input/pager_navigation.h | 1 + .../calculator/calculator_page_runtime.h | 3 + .../ui/screens/team/team_page_lvgl_renderer.h | 4 +- .../src/ui/components/dialog_frame.cpp | 1 + .../src/ui/input/cardputer_keyboard.cpp | 177 +++++++++++++++++ .../src/ui/input/pager_navigation.cpp | 47 ++++- modules/ui_shared/src/ui/menu/menu_layout.cpp | 2 +- .../ui_shared/src/ui/menu/menu_runtime.cpp | 9 +- .../calculator/calculator_page_runtime.cpp | 22 +++ .../screens/chat/chat_compose_components.cpp | 15 ++ .../ui/screens/chat/chat_compose_layout.cpp | 2 +- .../contacts/contacts_page_components.cpp | 5 +- .../geocaching/geocaching_page_shell.cpp | 5 +- .../src/ui/screens/gps/gps_page_runtime.cpp | 17 +- .../ui/screens/music/music_page_layout.cpp | 7 + .../settings/settings_page_components.cpp | 2 + .../ui/screens/team/team_page_components.cpp | 23 +++ .../src/ui/screens/team/team_page_input.cpp | 2 +- .../screens/team/team_page_lvgl_renderer.cpp | 34 +++- .../tracker/tracker_page_components.cpp | 6 + .../src/ui/widgets/ime/ime_widget.cpp | 35 +++- .../src/ui/widgets/map/poi_overlay.cpp | 5 +- modules/ui_shared/src/ui/widgets/top_bar.cpp | 13 +- .../linux/common/include/ui/shell_ui_runner.h | 2 + platform/linux/common/src/app/demo_app.cpp | 6 + platform/linux/common/src/app/input_event.h | 5 + .../common/src/app/linux_app_services.cpp | 8 +- .../linux/common/src/ui/shell_ui_runner.cpp | 27 ++- .../rpi/src/platform/device/evdev_input.cpp | 39 +++- .../rpi/src/platform/device/evdev_input.h | 2 +- tests/map_poi/test_annotation_frame.cpp | 24 ++- tests/settings_navigation/CMakeLists.txt | 2 +- tests/settings_navigation/test_navigation.cpp | 1 + tests/tracker_card_view/CMakeLists.txt | 15 +- tests/tracker_card_view/test_dialog_frame.cpp | 40 ++++ 52 files changed, 1129 insertions(+), 76 deletions(-) create mode 100644 apps/linux_cardputer_zero/tests/linux_keyboard_modal_navigation_test.cpp create mode 100644 apps/linux_cardputer_zero/tests/linux_team_capability_test.cpp create mode 100644 modules/ui_shared/include/ui/input/cardputer_keyboard.h create mode 100644 modules/ui_shared/include/ui/input/keyboard_shortcuts.h create mode 100644 modules/ui_shared/src/ui/input/cardputer_keyboard.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 69b27b7b..ca8eb9db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,9 @@ target-specific; see the linked acceptance records below. detection, heading integration and the shared launcher/widget screens. - Cardputer Zero access to the shared music player, including its compact now-playing layout and launcher-widget controls. +- A dedicated Cardputer Zero keyboard profile: F/Z/X/C navigation outside + editors, context-specific 4–8 actions, Shift+Tab, Fn+H help, native Home/End + cursor movement and Ctrl+Enter chat submission. - T-Display-CM0 Raspbian ARMHF application and matching native 1seg backend packages, preserving the target's GCC 14 C++ ABI and ARMv7 receive contract. CM0 also gains the LR2021 FM/1seg views, OFDM learning workbench and receiver @@ -39,6 +42,27 @@ target-specific; see the linked acceptance records below. ### Fixed +- Shared keypad text editors reject non-text control characters, and Escape + cancels the Music playlist-name editor without appending an invisible byte. +- English IME input, touch tokens and Backspace respect the current text cursor; + Calculator preserves expression arrows and offers explicit Tab-based button + navigation on Zero. Consumed shortcuts refresh the display's activity timer. +- Linux Canvas shutdown closes the active page before releasing its providers + and LVGL, preventing Map timers and marker bindings from outliving their owners. +- Linux production modes no longer expose the synthetic Team pairing transport. + Devices without a real pairing service show disabled pairing actions and an + availability hint; existing team data remains accessible and Escape works. +- Contacts leaves the page on Escape after closing its filter menu instead of + reopening the menu from the focused node card. +- Route action/navigation dialogs and Map menus support directional keyboard + focus while retaining map panning, text editing and submenu back actions. +- Zero dialog titles retain a visible height before their first flex layout; + shared map truncation uses punctuation supported by built-in ASCII fonts. +- Holding Home for 1.5 seconds in the Cardputer Zero framebuffer application + exits cleanly to APPLaunch, alongside the existing Esc exit gesture. +- Native CM0 packaging checks every required application/producer ELF while + keeping CoreCLR's optional LTTng tracing provider optional; clean builders + also verify the pinned producer can start before compiling the application. - Linux Reticulum retains the original private message until an IP delivery finishes, allowing one policy-permitted LoRa retry after a transport failure with the same message ID, timestamp and full destination identity. diff --git a/apps/linux_cardputer_zero/CMakeLists.txt b/apps/linux_cardputer_zero/CMakeLists.txt index b4eb5bed..f4e6739e 100644 --- a/apps/linux_cardputer_zero/CMakeLists.txt +++ b/apps/linux_cardputer_zero/CMakeLists.txt @@ -580,6 +580,19 @@ if(BUILD_TESTING) target_compile_features(trailmate_linux_evdev_exit_test PRIVATE cxx_std_20) target_compile_options(trailmate_linux_evdev_exit_test PRIVATE -UNDEBUG) add_test(NAME trailmate_linux_evdev_exit COMMAND trailmate_linux_evdev_exit_test) + add_executable(trailmate_linux_keyboard_modal_test tests/linux_keyboard_modal_navigation_test.cpp) + target_link_libraries(trailmate_linux_keyboard_modal_test PRIVATE trailmate_cardputer_zero_ui_shell) + target_compile_definitions(trailmate_linux_keyboard_modal_test PRIVATE TRAIL_MATE_CARDPUTER_ZERO_LINUX=1) + target_compile_options(trailmate_linux_keyboard_modal_test PRIVATE -UNDEBUG) + add_test(NAME trailmate_linux_keyboard_modal COMMAND trailmate_linux_keyboard_modal_test) + add_test(NAME trailmate_linux_active_map_shutdown COMMAND trailmate_linux_keyboard_modal_test --shutdown-active-map) + add_executable(trailmate_linux_team_capability_test tests/linux_team_capability_test.cpp) + target_link_libraries(trailmate_linux_team_capability_test PRIVATE trailmate_cardputer_zero_ui_shell) + target_compile_definitions(trailmate_linux_team_capability_test PRIVATE TRAIL_MATE_CARDPUTER_ZERO_LINUX=1) + target_compile_options(trailmate_linux_team_capability_test PRIVATE -UNDEBUG) + foreach(team_mode IN ITEMS local mesh demo) + add_test(NAME trailmate_linux_team_${team_mode} COMMAND trailmate_linux_team_capability_test ${team_mode}) + endforeach() foreach(sensor_test IN ITEMS heading iio) if(sensor_test STREQUAL "heading") set(sensor_test_source tests/linux_cardputer_heading_test.cpp) diff --git a/apps/linux_cardputer_zero/README.md b/apps/linux_cardputer_zero/README.md index 45e9d355..d87c4a57 100644 --- a/apps/linux_cardputer_zero/README.md +++ b/apps/linux_cardputer_zero/README.md @@ -199,13 +199,35 @@ Chat, Contacts, Map, or Sky Plot page is not currently open. Pages consume runtime state and send commands; they do not own radio polling or GPS serial reads. -On the framebuffer keyboard interface, press **S** in a Chat conversation to -open Compose. Type the message, press **Tab** once to select **Send**, then -press **Enter**. Enter inside the text field inserts a newline. A visible -Compose hint describes this sequence, and the compact action bars preserve -message space on the 320 × 170 panel. Further Tab presses reach the IME, -symbols, emoji, voice and cancel controls. Press Esc briefly to go back, or -hold it for approximately 1.5 seconds to exit cleanly to APPLaunch. +The Zero has its own keyboard profile, following the official +[key specifications](https://cardputer.cc/docs/en/CP0_keys.md). Outside text +editors, **F/Z/X/C** move up/left/down/right without Fn. Text editors keep +those letters and digits as ordinary input. **Tab / Shift+Tab** move between +controls; **Enter** activates the selected control. **Fn+H** opens help. + +The **4–8** shortcuts depend on the current page: + +| Page | 4 | 5 | 6 | 7 | 8 | +| --- | --- | --- | --- | --- | --- | +| Main menu | Chat | Map | Tracker | Contacts | Settings | +| Map | Zoom out | Zoom in | Position | Layer | Menu | +| Tracker | Record / stop | Tracks | Routes | Actions | Menu | +| Route preview | Zoom out | Zoom in | Fit | Load | Help | +| Music | Previous | Play / pause | Next | Library | Menu | + +In a Chat conversation, **S** opens Compose. Type the message and press +**Ctrl+Enter** to send, or **Tab → Enter** to select and activate Send. +Enter inside the text field inserts a newline. Further Tab presses reach the +IME, symbols, emoji, voice and cancel controls. **Fn+K / Fn+N** move to the +start/end of an editor or the first/last available control outside an editor. +**Esc** closes the current popup or returns to the previous page. Hold the +upper-left Esc/Home key for approximately 1.5 seconds to exit cleanly to +APPLaunch; a standard Home key held that long also exits on evdev. + +Calculator starts in expression editing mode: Left/Right move its cursor and +`=` evaluates. Press Tab to select keypad controls, then use directions and +Enter to activate them. Typing a digit or arithmetic operator resumes expression +editing. This preserves calculator letter functions while editing an expression. Extensions → Make TMAP opens the five-step online creator. Search for a global city or district and select the result with the correct country/context; the diff --git a/apps/linux_cardputer_zero/tests/linux_cardputer_zero_calculator_test.cpp b/apps/linux_cardputer_zero/tests/linux_cardputer_zero_calculator_test.cpp index 0865e9c3..bdf239c2 100644 --- a/apps/linux_cardputer_zero/tests/linux_cardputer_zero_calculator_test.cpp +++ b/apps/linux_cardputer_zero/tests/linux_cardputer_zero_calculator_test.cpp @@ -73,11 +73,28 @@ int main() assert(hasLabel(lv_screen_active(), "CALCULATOR")); lv_obj_update_layout(lv_screen_active()); checkButtons(lv_screen_active()); + key(input::InputKey::Character, '1'); key(input::InputKey::Character, '2'); key(input::InputKey::Character, '+'); key(input::InputKey::Character, '3'); + key(input::InputKey::Left); + key(input::InputKey::Character, '9'); + key(input::InputKey::Character, '='); + assert(hasResult(lv_screen_active(), "105")); // 12 + 93, preserving expression cursor movement. + const auto focused_control = []() -> lv_obj_t* + { + for (auto* indev = lv_indev_get_next(nullptr); indev; indev = lv_indev_get_next(indev)) + if (lv_indev_get_type(indev) == LV_INDEV_TYPE_KEYPAD) + return lv_group_get_focused(lv_indev_get_group(indev)); + return nullptr; + }; + key(input::InputKey::Tab); + assert(focused_control() && hasLabel(focused_control(), "8")); + key(input::InputKey::Right); + assert(focused_control() && hasLabel(focused_control(), "9")); key(input::InputKey::Enter); - assert(hasResult(lv_screen_active(), "5")); + key(input::InputKey::Character, '='); + assert(hasResult(lv_screen_active(), "9")); // Tab enters button navigation; Enter inputs 9; '=' evaluates it. key(input::InputKey::Home); tick(650ms); assert(!hasLabel(lv_screen_active(), "CALCULATOR")); diff --git a/apps/linux_cardputer_zero/tests/linux_cardputer_zero_chat_keyboard_test.cpp b/apps/linux_cardputer_zero/tests/linux_cardputer_zero_chat_keyboard_test.cpp index 462a9ee2..e9f5604a 100644 --- a/apps/linux_cardputer_zero/tests/linux_cardputer_zero_chat_keyboard_test.cpp +++ b/apps/linux_cardputer_zero/tests/linux_cardputer_zero_chat_keyboard_test.cpp @@ -64,6 +64,23 @@ int main() lv_group_set_default(group); lv_indev_set_group(keypad, group); ui::menu_runtime::setScene(ui::menu_runtime::Scene::App); + { + auto* editor = lv_textarea_create(lv_screen_active()); + lv_group_add_obj(group, editor); + lv_group_focus_obj(editor); + lv_textarea_set_text(editor, "safe"); + trailmate::cardputer_zero::app::InputEvent reserved; + reserved.key = Key::Enter; + reserved.control = true; + shell.enqueueInputs({reserved}); + tick(120ms); + assert(!std::strcmp(lv_textarea_get_text(editor), "safe")); + key(Key::F1); + assert(!std::strcmp(lv_textarea_get_text(editor), "safe")); + key(Key::Character, 'A'); + assert(!std::strcmp(lv_textarea_get_text(editor), "safeA")); + lv_obj_delete(editor); + } { chat::ui::ChatComposeScreen compose(lv_screen_active(), {chat::ChannelId::PRIMARY, 123, chat::MeshProtocol::Meshtastic}); ui::widgets::ImeWidget ime; @@ -78,20 +95,42 @@ int main() &sends); tick(150ms); assert(lv_group_get_focused(group) == compose.getTextarea()); - for (char value : std::string("hwsv")) key(Key::Character, value); - assert(compose.getText() == "hwsv"); + for (char value : std::string("hwsvfzxc478")) key(Key::Character, value); + assert(compose.getText() == "hwsvfzxc478"); + key(Key::CursorHome); + assert(lv_textarea_get_cursor_pos(compose.getTextarea()) == 0); + key(Key::Character, 'A'); + assert(compose.getText() == "Ahwsvfzxc478"); + assert(lv_textarea_get_cursor_pos(compose.getTextarea()) == 1); + key(Key::Backspace); + assert(compose.getText() == "hwsvfzxc478"); + key(Key::CursorEnd); + assert(lv_textarea_get_cursor_pos(compose.getTextarea()) == compose.getText().size()); key(Key::Enter); - assert(compose.getText() == "hwsv\n" && sends == 0); + assert(compose.getText() == "hwsvfzxc478\n" && sends == 0); key(Key::Character, 'x'); auto* send = lv_obj_get_parent(label(compose.getActionBar(), "Send")); assert(send); key(Key::Tab); assert(lv_group_get_focused(group) == send); - assert(compose.getText() == "hwsv\nx" && sends == 0); + trailmate::cardputer_zero::app::InputEvent reverse; + reverse.key = Key::Tab; + reverse.shift = true; + shell.enqueueInputs({reverse}); + tick(120ms); + assert(lv_group_get_focused(group) == compose.getTextarea()); + trailmate::cardputer_zero::app::InputEvent submit; + submit.key = Key::Enter; + submit.control = true; + shell.enqueueInputs({submit}); + tick(120ms); + assert(compose.getText() == "hwsvfzxc478\nx" && sends == 1); + key(Key::Tab); + assert(lv_group_get_focused(group) == send); key(Key::Enter); - if (sends != 1) std::fprintf(stderr, "Send actions for one key press: %u\n", sends); - assert(sends == 1); // Key press/release must not duplicate the send. - assert(label(compose.getObj(), "Tab to Send, then Enter")); + if (sends != 2) std::fprintf(stderr, "Send actions for two submissions: %u\n", sends); + assert(sends == 2); // Each press/release pair produces exactly one send. + assert(label(compose.getObj(), "Ctrl+Enter Send / Tab controls")); lv_obj_update_layout(compose.getObj()); assert(lv_obj_get_height(send) == 20); assert(lv_obj_get_height(compose.getActionBar()) == 26); diff --git a/apps/linux_cardputer_zero/tests/linux_evdev_exit_test.cpp b/apps/linux_cardputer_zero/tests/linux_evdev_exit_test.cpp index 55367be0..dd6ef437 100644 --- a/apps/linux_cardputer_zero/tests/linux_evdev_exit_test.cpp +++ b/apps/linux_cardputer_zero/tests/linux_evdev_exit_test.cpp @@ -22,19 +22,21 @@ int main() raw.value = value; assert(write(file, &raw, sizeof(raw)) == sizeof(raw)); }; + for (const auto code : {KEY_ESC, KEY_HOME}) { + assert(ftruncate(file, 0) == 0 && lseek(file, 0, SEEK_SET) == 0); trailmate::cardputer_zero::platform::device::EvdevInput input(path); assert(input.isOpen() && !input.quitRequested()); - event(EV_KEY, KEY_ESC, 1); + event(EV_KEY, code, 1); auto keys = input.drainInput(); - assert(keys.size() == 1 && keys[0].key == trailmate::cardputer_zero::app::InputKey::Power); - event(EV_KEY, KEY_ESC, 0); + assert(keys.size() == 1 && keys[0].key == (code == KEY_ESC ? trailmate::cardputer_zero::app::InputKey::Power : trailmate::cardputer_zero::app::InputKey::CursorHome)); + event(EV_KEY, code, 0); assert(input.drainInput().empty()); std::this_thread::sleep_for(1600ms); input.drainInput(); assert(!input.quitRequested()); - event(EV_KEY, KEY_ESC, 1); + event(EV_KEY, code, 1); input.drainInput(); event(EV_SYN, SYN_DROPPED, 0); input.drainInput(); @@ -42,14 +44,45 @@ int main() input.drainInput(); assert(!input.quitRequested()); - event(EV_KEY, KEY_ESC, 1); + event(EV_KEY, code, 1); input.drainInput(); std::this_thread::sleep_for(1600ms); - event(EV_KEY, KEY_ESC, 2); // Kernel repeats do not restart the hold timer. + event(EV_KEY, code, 2); // Kernel repeats do not restart the hold timer. assert(input.drainInput().empty()); assert(input.quitRequested()); assert(input.reopen(path) && !input.quitRequested()); } + // Releasing Home cannot cancel an independent Esc hold (or vice versa). + assert(ftruncate(file, 0) == 0 && lseek(file, 0, SEEK_SET) == 0); + { + trailmate::cardputer_zero::platform::device::EvdevInput input(path); + event(EV_KEY, KEY_HOME, 1); + event(EV_KEY, KEY_ESC, 1); + event(EV_KEY, KEY_ESC, 0); + input.drainInput(); + std::this_thread::sleep_for(1600ms); + event(EV_KEY, KEY_HOME, 0); + input.drainInput(); + assert(input.quitRequested()); + } + assert(ftruncate(file, 0) == 0 && lseek(file, 0, SEEK_SET) == 0); + { + trailmate::cardputer_zero::platform::device::EvdevInput input(path); + event(EV_KEY, KEY_LEFTSHIFT, 1); + event(EV_KEY, KEY_TAB, 1); + event(EV_KEY, KEY_LEFTSHIFT, 0); + event(EV_KEY, KEY_LEFTCTRL, 1); + event(EV_KEY, KEY_ENTER, 1); + event(EV_KEY, KEY_LEFTCTRL, 0); + auto keys = input.drainInput(); + bool shift_tab = false, control_enter = false; + for (const auto& key : keys) + { + if (key.key == trailmate::cardputer_zero::app::InputKey::Tab) shift_tab = key.shift && !key.control; + if (key.key == trailmate::cardputer_zero::app::InputKey::Enter) control_enter = key.control && !key.shift; + } + assert(shift_tab && control_enter); + } close(file); unlink(path); } diff --git a/apps/linux_cardputer_zero/tests/linux_geocaching_page_test.cpp b/apps/linux_cardputer_zero/tests/linux_geocaching_page_test.cpp index d04664df..3ab198c6 100644 --- a/apps/linux_cardputer_zero/tests/linux_geocaching_page_test.cpp +++ b/apps/linux_cardputer_zero/tests/linux_geocaching_page_test.cpp @@ -116,6 +116,13 @@ int main() fields(lv_screen_active(), text_fields); assert(text_fields.size() == 5 && !std::strcmp(lv_textarea_get_text(text_fields[0]), "UI draft")); assert(description == lv_textarea_get_text(text_fields[1])); + key(Key::Home); // Escape must leave an unchanged saved draft without dirtying it. + text_fields.clear(); + fields(lv_screen_active(), text_fields); + assert(text_fields.empty()); + click("UI draft"); + fields(lv_screen_active(), text_fields); + assert(text_fields.size() == 5 && !std::strcmp(lv_textarea_get_text(text_fields[0]), "UI draft")); lv_textarea_set_text(text_fields[0], "Edited UI draft"); click("Save"); assert(label(lv_screen_active(), "Edited UI draft")); diff --git a/apps/linux_cardputer_zero/tests/linux_keyboard_modal_navigation_test.cpp b/apps/linux_cardputer_zero/tests/linux_keyboard_modal_navigation_test.cpp new file mode 100644 index 00000000..feb2e9ed --- /dev/null +++ b/apps/linux_cardputer_zero/tests/linux_keyboard_modal_navigation_test.cpp @@ -0,0 +1,184 @@ +#include "app/input_event.h" +#include "ui/app_runtime.h" +#include "ui/menu/menu_layout.h" +#include "ui/shell_ui_runner.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace std::chrono_literals; +using Key = trailmate::cardputer_zero::app::InputKey; +lv_group_t* active_group() +{ + for (auto* input = lv_indev_get_next(nullptr); input; input = lv_indev_get_next(input)) + if (lv_indev_get_type(input) == LV_INDEV_TYPE_KEYPAD) return lv_indev_get_group(input); + return nullptr; +} +lv_obj_t* visible_label(lv_obj_t* object, const char* text) +{ + if (lv_obj_has_flag(object, LV_OBJ_FLAG_HIDDEN)) return nullptr; + if (lv_obj_check_type(object, &lv_label_class) && !std::strcmp(lv_label_get_text(object), text)) return object; + for (uint32_t i = 0; i < lv_obj_get_child_count(object); ++i) + if (auto* found = visible_label(lv_obj_get_child(object, i), text)) return found; + return nullptr; +} +int main(int argc, char** argv) +{ + const auto root = std::filesystem::temp_directory_path() / ("trailmate-keyboard-modal-" + std::to_string(getpid())); + std::filesystem::create_directories(root / "sd/routes"); + std::ofstream(root / "sd/routes/keyboard-route.kml") + << "Keyboard route" + "102.70,25.04,0 102.71,25.05,0" + ""; + setenv("TRAIL_MATE_SETTINGS_ROOT", root.c_str(), 1); + setenv("TRAIL_MATE_SD_ROOT", (root / "sd").c_str(), 1); + setenv("TRAIL_MATE_CACHE_ROOT", (root / "cache").c_str(), 1); + setenv("TRAIL_MATE_RUNTIME_MODE", "local", 1); + setenv("TRAIL_MATE_LORA_DISABLE", "1", 1); + setenv("TRAIL_MATE_GPS_DEVICE", "/dev/trailmate-test-missing", 1); + { + trailmate::cardputer_zero::linux_ui::ShellSession shell; + trailmate::cardputer_zero::linux_ui::CanvasLvglHost host(shell); + assert(shell.begin()); + const auto tick = [&](auto duration) + { + const auto end = std::chrono::steady_clock::now() + duration; + do + { + host.tick(); + std::this_thread::sleep_for(5ms); + } while (std::chrono::steady_clock::now() < end); + }; + const auto key = [&](Key code, char value = 0) + { + shell.enqueueInputs({{code, value ? std::string(1, value) : std::string{}, value}}); + tick(150ms); + }; + const auto click = [&](const char* text) + { + auto* label = visible_label(lv_screen_active(), text); + assert(label); + auto* button = label; + while (button && !lv_obj_get_group(button)) button = lv_obj_get_parent(button); + if (!button || lv_obj_has_state(button, LV_STATE_DISABLED)) std::fprintf(stderr, "Unreachable control: %s\n", text); + assert(button && !lv_obj_has_state(button, LV_STATE_DISABLED)); + for (unsigned steps = 0; steps < 50 && lv_group_get_focused(active_group()) != button; ++steps) key(Key::Tab); + assert(lv_group_get_focused(active_group()) == button); + key(Key::Enter); + }; + const auto require_menu = [&](const char* phase) + { + if (ui_get_active_app() && ui_is_transition_pending()) + { + // App exit is scheduled on LVGL's next timer iteration. + // A costly map frame may use the short key-pump budget first. + std::fprintf(stderr, "%s: awaiting requested app transition\n", phase); + const auto deadline = std::chrono::steady_clock::now() + 2s; + while (ui_get_active_app() && std::chrono::steady_clock::now() < deadline) tick(10ms); + } + if (ui_get_active_app()) + { + std::fprintf(stderr, "%s exit left app=%s\n", phase, ui_get_active_app()->stable_id()); + const auto dump = [&](auto&& self, lv_obj_t* object) -> void + { + if (lv_obj_has_flag(object, LV_OBJ_FLAG_HIDDEN)) return; + if (lv_obj_check_type(object, &lv_label_class)) std::fprintf(stderr, "visible: %s\n", lv_label_get_text(object)); + for (uint32_t i = 0; i < lv_obj_get_child_count(object); ++i) self(self, lv_obj_get_child(object, i)); + }; + dump(dump, lv_screen_active()); + } + assert(!ui_get_active_app()); + }; + tick(3300ms); + auto* initial = lv_group_get_focused(active_group()); + key(Key::Character, 'c'); + assert(lv_group_get_focused(active_group()) != initial); + key(Key::Character, 'z'); + assert(lv_group_get_focused(active_group()) == initial); + key(Key::Character, 'x'); + auto* below = lv_group_get_focused(active_group()); + assert(below != initial && visible_label(below, "Team")); + key(Key::Character, 'f'); + assert(lv_group_get_focused(active_group()) == initial); + key(Key::Character, '7'); + assert(ui_get_active_app() && !std::strcmp(ui_get_active_app()->stable_id(), "contacts")); + tick(400ms); + key(Key::Character, 'm'); + key(Key::Power); + key(Key::Power); + require_menu("Contacts"); + + key(Key::Character, '6'); + assert(ui_get_active_app() && !std::strcmp(ui_get_active_app()->stable_id(), "tracker")); + tick(1600ms); + key(Key::Character, '6'); // Route selection uses the existing page action. + tick(500ms); + key(Key::Character, '7'); + auto* load = lv_group_get_focused(active_group()); + assert(visible_label(load, "Load")); + key(Key::Down); + auto* preview = lv_group_get_focused(active_group()); + assert(preview != load && visible_label(preview, "Preview")); + key(Key::Right); + assert(visible_label(lv_group_get_focused(active_group()), "Delete")); + key(Key::Left); + assert(lv_group_get_focused(active_group()) == preview); + key(Key::Up); + assert(lv_group_get_focused(active_group()) == load); + key(Key::Power); + key(Key::Power); + require_menu("Tracker"); + + key(Key::Character, '5'); + assert(ui_get_active_app() && !std::strcmp(ui_get_active_app()->stable_id(), "map")); + tick(700ms); + if (argc > 1 && !std::strcmp(argv[1], "--shutdown-active-map")) + { + key(Key::Character, '8'); + key(Key::Down); + key(Key::Down); + key(Key::Down); + key(Key::Enter); + assert(visible_label(lv_screen_active(), "Navigation")); + shell.releaseUiBindings(); + assert(!ui_get_active_app() && !ui_is_transition_pending()); + shell.releaseUiBindings(); // The host and session both release bindings. + } + else + { + key(Key::Character, 'm'); + auto* contours = lv_group_get_focused(active_group()); + key(Key::Down); + auto* layer = lv_group_get_focused(active_group()); + assert(layer != contours); + key(Key::Right); // Enter the Map Layer submenu; parent widgets are rebuilt. + assert(visible_label(lv_screen_active(), "Terrain")); + key(Key::Power); // Restore the parent menu and its Map Layer focus. + key(Key::Up); + assert(visible_label(lv_group_get_focused(active_group()), "Contours")); + key(Key::Power); + key(Key::Power); + require_menu("Map"); + + key(Key::Character, '8'); + assert(ui_get_active_app() && !std::strcmp(ui_get_active_app()->stable_id(), "settings")); + tick(400ms); + click("Protocol"); + assert(visible_label(lv_group_get_focused(active_group()), "Meshtastic")); + key(Key::Down); + assert(visible_label(lv_group_get_focused(active_group()), "MeshCore")); + key(Key::Right); + assert(visible_label(lv_group_get_focused(active_group()), "Reticulum")); + key(Key::Power); + key(Key::Power); + require_menu("Settings"); + } + } + std::filesystem::remove_all(root); +} diff --git a/apps/linux_cardputer_zero/tests/linux_team_capability_test.cpp b/apps/linux_cardputer_zero/tests/linux_team_capability_test.cpp new file mode 100644 index 00000000..7331c7ce --- /dev/null +++ b/apps/linux_cardputer_zero/tests/linux_team_capability_test.cpp @@ -0,0 +1,90 @@ +#include "app/input_event.h" +#include "team/usecase/team_service.h" +#include "ui/app_runtime.h" +#include "ui/menu/menu_layout.h" +#include "ui/shell_ui_runner.h" + +#include +#include +#include +#include +#include +#include +#include + +using namespace std::chrono_literals; +using Key = trailmate::cardputer_zero::app::InputKey; + +lv_obj_t* find_label(lv_obj_t* object, const char* text) +{ + if (lv_obj_has_flag(object, LV_OBJ_FLAG_HIDDEN)) return nullptr; + if (lv_obj_check_type(object, &lv_label_class) && !std::strcmp(lv_label_get_text(object), text)) return object; + for (uint32_t i = 0; i < lv_obj_get_child_count(object); ++i) + if (auto* found = find_label(lv_obj_get_child(object, i), text)) return found; + return nullptr; +} + +int main(int argc, char** argv) +{ + assert(argc == 2); + const bool demo = !std::strcmp(argv[1], "demo"); + const auto root = std::filesystem::temp_directory_path() / ("trailmate-team-capability-" + std::to_string(getpid())); + std::filesystem::create_directories(root); + setenv("TRAIL_MATE_SETTINGS_ROOT", root.c_str(), 1); + setenv("TRAIL_MATE_SD_ROOT", (root / "sd").c_str(), 1); + setenv("TRAIL_MATE_CACHE_ROOT", (root / "cache").c_str(), 1); + setenv("TRAIL_MATE_RUNTIME_MODE", argv[1], 1); + unsetenv("TRAIL_MATE_DEMO_WORLD"); + setenv("TRAIL_MATE_LORA_DISABLE", "1", 1); + setenv("TRAIL_MATE_GPS_DEVICE", "/dev/trailmate-test-missing", 1); + { + trailmate::cardputer_zero::linux_ui::ShellSession shell; + trailmate::cardputer_zero::linux_ui::CanvasLvglHost host(shell); + assert(shell.begin()); + assert((shell.facade().getTeamPairing() != nullptr) == demo); + assert(shell.facade().getTeamController() && shell.facade().getTeamService()); + const auto tick = [&](auto duration) + { + const auto end = std::chrono::steady_clock::now() + duration; + do + { + host.tick(); + std::this_thread::sleep_for(5ms); + } while (std::chrono::steady_clock::now() < end); + }; + const auto key = [&](Key code, char value = 0) + { + shell.enqueueInputs({{code, value ? std::string(1, value) : std::string{}, value}}); + tick(200ms); + }; + tick(3300ms); + key(Key::Character, 'x'); + assert(find_label(lv_group_get_focused(menu_g), "Team")); + key(Key::Enter); + assert(ui_get_active_app() && !std::strcmp(ui_get_active_app()->stable_id(), "team")); + auto* create_label = find_label(lv_screen_active(), "Create Team"); + auto* join_label = find_label(lv_screen_active(), "Join Team"); + assert(create_label && join_label); + auto* create = lv_obj_get_parent(create_label); + auto* join = lv_obj_get_parent(join_label); + assert(lv_obj_has_state(create, LV_STATE_DISABLED) == !demo); + assert(lv_obj_has_state(join, LV_STATE_DISABLED) == !demo); + assert((find_label(lv_screen_active(), "Team pairing is unavailable on this device") != nullptr) == !demo); + if (!demo) + { + assert(!shell.facade().getTeamService()->hasKeys()); + // A direct stale/programmatic event must not create local team keys either. + lv_obj_send_event(create, LV_EVENT_CLICKED, nullptr); + lv_obj_send_event(join, LV_EVENT_CLICKED, nullptr); + tick(250ms); + assert(!shell.facade().getTeamService()->hasKeys()); + assert(find_label(lv_screen_active(), "You are not in a team")); + key(Key::Tab); + key(Key::Down); + } + key(Key::Power); + tick(500ms); + assert(!ui_get_active_app()); + } + std::filesystem::remove_all(root); +} diff --git a/cmake/TrailMateLinuxSources.cmake b/cmake/TrailMateLinuxSources.cmake index 8ae813ff..0f0970b5 100644 --- a/cmake/TrailMateLinuxSources.cmake +++ b/cmake/TrailMateLinuxSources.cmake @@ -743,6 +743,7 @@ set(TRAIL_MATE_LINUX_UI_SHELL_SOURCES "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/widgets/toast/toast_widget.cpp" "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/widgets/top_bar.cpp" "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/input/pager_navigation.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/input/cardputer_keyboard.cpp" "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/widgets/ble_pairing_popup.cpp" "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/widgets/top_bar_power_presenter.cpp" ) diff --git a/docs/engineering/linux-paired-mesh-validation-20261108.md b/docs/engineering/linux-paired-mesh-validation-20261108.md index fbdd8e08..f7cce836 100644 --- a/docs/engineering/linux-paired-mesh-validation-20261108.md +++ b/docs/engineering/linux-paired-mesh-validation-20261108.md @@ -198,4 +198,65 @@ is claimed by this record. All started package builds and focused regressions reached terminal success. The final read-only audits reported no CRC, header, timeout, invalid-frame or read errors in either device's current Meshtastic radio counters. Both devices -remain in CN / LongFast with their normal identities and profiles. +remained in CN / LongFast with their normal identities and profiles at the end +of that Meshtastic audit. + +## All/Direct IP failure to physical LoRa delivery (2026-11-09) + +Zero `1.1.0+demo.20261109.11` completed a controlled transport-failure test with +the real CM0. Its original network projection was missing, so the rehearsal +used the production `build_defaults` policy and typed network store. The +temporary configuration retained the five interface slots, selected All and +Direct delivery, enabled integrated LoRa and one loopback TCP test endpoint, +and disabled other IP interfaces for a deterministic failure path. + +The TCP peer replayed a previously captured, real signed CM0 delivery Announce +without changing its keys or signature. It closed immediately after receiving +the real 86-byte LinkRequest. The test did not emulate a successful delivery +or a radio receipt. Zero submitted `all direct fallback 0309` through its real +Compose editor using physical evdev Ctrl+Enter. + +The Zero log records the same local message ID `167620162` throughout: + +- `19:09:43.904Z`: network queue accepted, `ready=1`, `radio_path=0`. +- `19:09:43.957Z`: failed network delivery falls back to LoRa. +- `19:09:45.943Z`: radio link queue accepted, `path=1`, `hops=1`. + +Zero stored one new outgoing row (sequence 41) with status Delivered. CM0 +stored the matching plaintext once (sequence 41, incoming ID `2820774652`) +with `RxOrigin::LoRa` (3). Incoming Reticulum IDs are independently derived; +the retained outgoing ID above belongs to Zero's original submission. + +After normal application exit, the helper verified the complete temporary +projection against the expected test configuration, removed only its owned +network key, and confirmed the original Missing projection and default +delivery preference (2). Zero restarted successfully. No application config +blob, identity, map or historical message record was replaced. + +## Final keyboard and lifecycle rehearsal (2026-11-09) + +Zero `1.1.0+demo.20261109.15` passed the 68-test native Shell/service suite, +including production Local/Mesh and explicit Demo Team capabilities. Production +Linux no longer returns the synthetic pairing service. The physical Team page +shows its unavailable hint and disabled Create/Join controls; Tab, directions +and Escape return to the launcher without creating team state. Existing Team +services and data remain available; real Linux Team pairing is not qualified. + +The independent keyboard audit covered 12 combined navigation/editor paths and +23 normal exits while an activity, editor or modal was still active. Actual Map +shutdown previously reproduced a timer/provider use-after-free. After fixing +the page/provider/LVGL release order, its Valgrind run terminated successfully +with zero invalid-access contexts. Zero also exited from the active Map +Navigation dialog to APPLaunch after a 1.8-second Escape hold; the old process +ended normally and Map runtime destruction occurred while LVGL and its data +providers were alive. Navigation, Route Actions and Help titles remained visible. + +On the same physical build, the online wizard searched `Singapore` through +Photon, returned `Singapore / SG`, displayed an enclosing area of approximately +111.9 by 42.7 km and enabled Next. No new download was started for this check; +the earlier complete online production/install and existing Kunming maps were +preserved. The previous loopback proxy forward had disconnected and the +previous LAN proxy address was unreachable. A separate reconnecting SSH forward +restored access to the user's local Clash for this test. Online access currently +requires that desktop and Clash to remain running; this is an environment +prerequisite, not a permanent LAN-proxy recovery or a new application service. diff --git a/docs/engineering/release-1.1.0.md b/docs/engineering/release-1.1.0.md index 63380b99..279d6d23 100644 --- a/docs/engineering/release-1.1.0.md +++ b/docs/engineering/release-1.1.0.md @@ -54,6 +54,21 @@ Both application packages retain private settings and radio identities. They do not attempt to reconstruct previously corrupted chat databases. Online map creation requires network access; installed maps remain available offline. +Cardputer Zero follows the device's native keyboard conventions. F/Z/X/C act +as directions outside text editors, and 4–8 expose frequent page actions. +Shift+Tab reverses control traversal, Fn+H opens help, and Ctrl+Enter sends a +composed chat message. Fn+K/Fn+N retain Home/End cursor behavior. Hold the +upper-left Esc/Home key for approximately 1.5 seconds to return to APPLaunch. +See the [keyboard action table](../../apps/linux_cardputer_zero/README.md) +for each page. Shared Contacts, Route, Map and Settings focus fixes also apply +to other targets using those pages; the Zero key aliases are target-specific. + +Linux production modes do not currently provide a real Team pairing transport. +Create, Join and Invite are disabled when that service is absent, with an +availability hint. Existing team data and its normal service remain available. +Synthetic Team pairing belongs only to explicitly enabled demonstration mode +and is not evidence of physical pairing support on either Linux device. + On the Recent Activity widget, use A/D or Left/Right to select a conversation and Enter to open it; touch users can tap either populated row. Chat owns the read-state update. Back/Home returns through the message list to the launcher; diff --git a/modules/ui_map_runtime/include/ui_map_runtime/map_poi/annotation_layout.h b/modules/ui_map_runtime/include/ui_map_runtime/map_poi/annotation_layout.h index c5b3f753..b8332184 100644 --- a/modules/ui_map_runtime/include/ui_map_runtime/map_poi/annotation_layout.h +++ b/modules/ui_map_runtime/include/ui_map_runtime/map_poi/annotation_layout.h @@ -6,6 +6,10 @@ namespace ui::map_poi { +// This punctuation must also render with the built-in ASCII map fonts. +inline constexpr char kAnnotationEllipsis[] = "..."; +inline constexpr std::size_t kAnnotationEllipsisBytes = sizeof(kAnnotationEllipsis) - 1; + struct AnnotationCandidate { uint64_t key = 0; diff --git a/modules/ui_map_runtime/src/map_poi/annotation_frame.cpp b/modules/ui_map_runtime/src/map_poi/annotation_frame.cpp index 8981ce48..c705daf5 100644 --- a/modules/ui_map_runtime/src/map_poi/annotation_frame.cpp +++ b/modules/ui_map_runtime/src/map_poi/annotation_frame.cpp @@ -172,8 +172,8 @@ bool AnnotationFrame::finish(const AnnotationLayoutOptions& options, AnnotationM std::size_t bytes = placement.text_bytes; if (placement.ellipsis) { - std::memcpy(item.label.data + bytes, "\xE2\x80\xA6", 3); - bytes += 3; + std::memcpy(item.label.data + bytes, kAnnotationEllipsis, kAnnotationEllipsisBytes); + bytes += kAnnotationEllipsisBytes; } item.label.data[bytes] = '\0'; } diff --git a/modules/ui_shared/include/ui/components/dialog_frame.h b/modules/ui_shared/include/ui/components/dialog_frame.h index 522b298f..e3a6bdf3 100644 --- a/modules/ui_shared/include/ui/components/dialog_frame.h +++ b/modules/ui_shared/include/ui/components/dialog_frame.h @@ -29,4 +29,21 @@ Frame create(lv_obj_t* parent, const Config& config); void fit_content_height(const Frame& frame); // Register X/x on this dialog's controls after the page builds its focus group. void bind_close_shortcuts(const Frame& frame, lv_group_t* group); +// Handle arrows on modal buttons/checkboxes without taking editor or scroller keys. +inline bool handle_focus_key(lv_event_t* event) +{ + if (!event || lv_event_get_code(event) != LV_EVENT_KEY) return false; + const auto key = lv_event_get_key(event); + auto* target = lv_event_get_target_obj(event); + if (!(lv_obj_check_type(target, &lv_button_class) || lv_obj_check_type(target, &lv_checkbox_class)) || + !(key == LV_KEY_UP || key == LV_KEY_DOWN || key == LV_KEY_LEFT || key == LV_KEY_RIGHT)) return false; + auto* group = lv_obj_get_group(target); + if (!group) return false; + lv_event_stop_bubbling(event); + lv_event_stop_processing(event); + if (key == LV_KEY_UP || key == LV_KEY_LEFT) lv_group_focus_prev(group); + else lv_group_focus_next(group); + if (auto* focused = lv_group_get_focused(group)) lv_obj_scroll_to_view(focused, LV_ANIM_OFF); + return true; +} } // namespace ui::components::dialog_frame diff --git a/modules/ui_shared/include/ui/input/cardputer_keyboard.h b/modules/ui_shared/include/ui/input/cardputer_keyboard.h new file mode 100644 index 00000000..acf33d9e --- /dev/null +++ b/modules/ui_shared/include/ui/input/cardputer_keyboard.h @@ -0,0 +1,15 @@ +#pragma once +#include "lvgl.h" +namespace ui::input::cardputer +{ +struct State +{ + uint32_t translated = 0; + bool consumed = false; + bool calculator_active = false; + bool calculator_controls = false; +}; +// Runs on the UI thread. Remapping is latched through release even if the +// pressed action changes page/focus. Text editors retain their physical keys. +bool handle_key(uint32_t& key, bool pressed, lv_group_t* group, State& state); +} // namespace ui::input::cardputer diff --git a/modules/ui_shared/include/ui/input/keyboard_shortcuts.h b/modules/ui_shared/include/ui/input/keyboard_shortcuts.h new file mode 100644 index 00000000..c66f347f --- /dev/null +++ b/modules/ui_shared/include/ui/input/keyboard_shortcuts.h @@ -0,0 +1,7 @@ +#pragma once +#include +namespace ui::input +{ +inline constexpr std::uint32_t kHelpKey = 0x110001U; +inline constexpr std::uint32_t kControlEnter = 0x11000DU; +} // namespace ui::input diff --git a/modules/ui_shared/include/ui/input/pager_navigation.h b/modules/ui_shared/include/ui/input/pager_navigation.h index d4f02eef..c74c5408 100644 --- a/modules/ui_shared/include/ui/input/pager_navigation.h +++ b/modules/ui_shared/include/ui/input/pager_navigation.h @@ -9,6 +9,7 @@ void prepare_group(lv_group_t* group); bool handle_key(uint32_t key, bool pressed, lv_group_t* group); bool help_open(); bool open_help(lv_obj_t* root, lv_group_t* group); +bool page_owns_focus(lv_group_t* group); #else inline void install_help(lv_obj_t*, lv_obj_t*) { diff --git a/modules/ui_shared/include/ui/screens/calculator/calculator_page_runtime.h b/modules/ui_shared/include/ui/screens/calculator/calculator_page_runtime.h index 7d7bd877..4ea94aa4 100644 --- a/modules/ui_shared/include/ui/screens/calculator/calculator_page_runtime.h +++ b/modules/ui_shared/include/ui/screens/calculator/calculator_page_runtime.h @@ -8,5 +8,8 @@ namespace calculator::ui::runtime void enter(const ::ui::page::Host* host, lv_obj_t* parent); void exit(lv_obj_t* parent); +// Explicit keyboard control mode shares the button's registered action without +// synthesizing the keypad release click that expression mode suppresses. +bool activate_control(lv_obj_t* button); } // namespace calculator::ui::runtime diff --git a/modules/ui_shared/include/ui/screens/team/team_page_lvgl_renderer.h b/modules/ui_shared/include/ui/screens/team/team_page_lvgl_renderer.h index 2cdab295..64ee0b37 100644 --- a/modules/ui_shared/include/ui/screens/team/team_page_lvgl_renderer.h +++ b/modules/ui_shared/include/ui/screens/team/team_page_lvgl_renderer.h @@ -64,6 +64,7 @@ struct TeamPageLvglRendererInput TeamPage page = TeamPage::StatusNotInTeam; TeamPageReadModelInput read_model; uint32_t pairing_peer_id = 0; + bool pairing_available = true; }; class TeamPageLvglRenderer @@ -107,7 +108,8 @@ class TeamPageLvglRenderer const TeamPageReadModelInput& input) const; void renderStatusNotInTeam(TeamPageLvglRendererContext& context, - const TeamPageLvglRendererHandlers& handlers) const; + const TeamPageLvglRendererHandlers& handlers, + bool pairing_available) const; void renderStatusInTeam(TeamPageLvglRendererContext& context, const TeamPageReadModelInput& input, const TeamPageLvglRendererHandlers& handlers) const; diff --git a/modules/ui_shared/src/ui/components/dialog_frame.cpp b/modules/ui_shared/src/ui/components/dialog_frame.cpp index ae826581..df4d95ca 100644 --- a/modules/ui_shared/src/ui/components/dialog_frame.cpp +++ b/modules/ui_shared/src/ui/components/dialog_frame.cpp @@ -11,6 +11,7 @@ namespace { void close_key(lv_event_t* event) { + if (handle_focus_key(event)) return; const auto key = lv_event_get_key(event); auto* close = static_cast(lv_event_get_user_data(event)); if (key != 'x' && key != 'X' && !(key == LV_KEY_ENTER && lv_event_get_target(event) == close)) return; diff --git a/modules/ui_shared/src/ui/input/cardputer_keyboard.cpp b/modules/ui_shared/src/ui/input/cardputer_keyboard.cpp new file mode 100644 index 00000000..6c225591 --- /dev/null +++ b/modules/ui_shared/src/ui/input/cardputer_keyboard.cpp @@ -0,0 +1,177 @@ +#if defined(TRAIL_MATE_CARDPUTER_ZERO_LINUX) +#include "ui/input/cardputer_keyboard.h" +#include "../screens/music/music_state.h" +#include "ui/app_runtime.h" +#include "ui/input/keyboard_shortcuts.h" +#include "ui/input/pager_navigation.h" +#include "ui/menu/menu_dashboard.h" +#include "ui/menu/menu_layout.h" +#include "ui/menu/menu_runtime.h" +#include "ui/screens/calculator/calculator_page_runtime.h" +#include "ui/screens/tracker/tracker_page_input.h" +#include "ui/screens/tracker/tracker_state.h" +#include "ui/ui_common.h" +#include "ui/widgets/ime/ime_widget.h" +#include + +namespace ui::input::cardputer +{ +namespace +{ +bool editor(lv_obj_t* focused) +{ + if (::ui::widgets::ime_owns_text_focus(focused)) return true; + for (auto* object = focused; object; object = lv_obj_get_parent(object)) + if (lv_obj_check_type(object, &lv_textarea_class) || lv_obj_check_type(object, &lv_spinbox_class) || + (lv_obj_check_type(object, &lv_dropdown_class) && lv_dropdown_is_open(object))) return true; + return false; +} +bool click(lv_obj_t* object) +{ + if (!object || !lv_obj_is_valid(object) || lv_obj_has_state(object, LV_STATE_DISABLED)) return false; + if (lv_obj_get_group(object)) lv_group_focus_obj(object); + lv_obj_send_event(object, LV_EVENT_CLICKED, nullptr); + return true; +} +bool tracker_action(uint32_t key) +{ + auto& page = ::tracker::ui::g_tracker_state; + if (page.action_menu_modal || page.del_confirm_modal || page.route_preview_page || page.route_preview_help_modal) return false; + if (key == '4' || key == '5' || key == '6') + { + ::tracker::ui::input::tracker_focus_to_filter(); + click(key == '6' ? page.mode_route_btn : page.mode_record_btn); + ::tracker::ui::input::tracker_focus_to_list(); + if (key == '4') click(page.start_stop_btn); + return true; + } + if (key == '7') + { + ::tracker::ui::input::tracker_focus_to_list(); + return click(page.card_actions); + } + if (key == '8') + { + if (page.filter_panel && !lv_obj_has_flag(page.filter_panel, LV_OBJ_FLAG_HIDDEN)) ::tracker::ui::input::tracker_focus_to_list(); + else ::tracker::ui::input::tracker_focus_to_filter(); + return true; + } + return false; +} +bool music_action(uint32_t key) +{ + auto* page = ::ui::music_page::state(); + if (!page || page->view == ::ui::music_page::View::NewPlaylist || page->view == ::ui::music_page::View::ConfirmDelete || page->menu.is_open()) return false; + if (key == '4') return ::ui::music_page::command(::music::Action::Previous); + if (key == '5') return ::ui::music_page::command(::music::Action::TogglePause); + if (key == '6') return ::ui::music_page::command(::music::Action::Next); + if (key == '7') + { + page->view = ::ui::music_page::View::Library; + ::ui::music_page::browse(::music::Browse::Songs); + return true; + } + if (key == '8') + { + page->menu.open(); + return true; + } + return false; +} +} // namespace +bool handle_key(uint32_t& key, bool pressed, lv_group_t* group, State& state) +{ + if (!pressed) + { + key = state.translated; + return state.consumed; + } + state.translated = key; + state.consumed = false; + if (!group) return false; + if (::ui::menu_runtime::currentScene() == ::ui::menu_runtime::Scene::Sleeping) return false; + auto* focused = group ? lv_group_get_focused(group) : nullptr; + auto* app = ui_get_active_app(); + const char* id = app ? app->stable_id() : ""; + const bool calculator = !std::strcmp(id, "calculator"); + if (!calculator || !state.calculator_active) state.calculator_controls = false; + state.calculator_active = calculator; + if (calculator) + { + if (key == LV_KEY_NEXT || key == LV_KEY_PREV) state.calculator_controls = true; + else if ((key >= '0' && key <= '9') || key == '+' || key == '-' || key == '*' || key == '/' || + key == '=' || key == '.' || key == '(' || key == ')' || key == LV_KEY_BACKSPACE) + state.calculator_controls = false; + if (state.calculator_controls && key == LV_KEY_ENTER) + { + state.consumed = ::calculator::ui::runtime::activate_control(focused); + return state.consumed; + } + } + const bool typing = editor(focused) || (calculator && !state.calculator_controls); + if (typing) return false; + if (key == LV_KEY_HOME || key == LV_KEY_END) + { + const auto count = lv_group_get_obj_count(group); + for (uint32_t step = 0; step < count; ++step) + { + auto* object = lv_group_get_obj_by_index(group, key == LV_KEY_HOME ? step : count - step - 1); + if (!lv_obj_is_visible(object) || lv_obj_has_state(object, LV_STATE_DISABLED)) continue; + lv_group_focus_obj(object); + lv_obj_scroll_to_view(object, LV_ANIM_OFF); + break; + } + state.consumed = true; + return true; + } + const bool launcher = !app && ::ui::menu_runtime::currentScene() == ::ui::menu_runtime::Scene::Menu && + group == menu_g && !::ui::menu::dashboard::isFeedOpen() && !ui_is_overlay_active(); + if (launcher && key == kHelpKey) key = 'h'; + if (launcher && key >= '4' && key <= '8') + { + const char* applications[] = {"chat", "map", "tracker", "contacts", "settings"}; + state.consumed = ::ui::menu_layout::launchAppByStableId(applications[key - '4']); + } + else if (key >= '4' && key <= '8' && !std::strcmp(id, "music")) state.consumed = music_action(key); + else if (key >= '4' && key <= '8' && !std::strcmp(id, "tracker")) + { + auto& page = ::tracker::ui::g_tracker_state; + if (page.route_preview_page && !page.route_preview_help_modal && !page.action_menu_modal && !page.del_confirm_modal) + { + const uint32_t actions[] = {'q', 'e', 'f', 'l', 'h'}; + key = actions[key - '4']; + } + else state.consumed = tracker_action(key); + } + else if (key >= '4' && key <= '8' && !std::strcmp(id, "map") && ::ui::input::pager::page_owns_focus(group)) + { + const uint32_t actions[] = {'q', 'e', 'c', 'l', 'm'}; + key = actions[key - '4']; + } + else if (!calculator || !lv_group_get_editing(group)) + { + if (key == 'f' || key == 'F') key = LV_KEY_UP; + else if (key == 'z' || key == 'Z') key = LV_KEY_LEFT; + else if (key == 'x' || key == 'X') key = LV_KEY_DOWN; + else if (key == 'c' || key == 'C') key = LV_KEY_RIGHT; + } + if (state.consumed) + { + state.translated = key; + return true; + } + const bool arrow = key == LV_KEY_UP || key == LV_KEY_DOWN || key == LV_KEY_LEFT || key == LV_KEY_RIGHT; + const bool button = focused && (lv_obj_check_type(focused, &lv_button_class) || lv_obj_check_type(focused, &lv_checkbox_class)); + if (app && !state.consumed && arrow && button && + (!::ui::input::pager::page_owns_focus(group) || !std::strcmp(id, "music") || (calculator && !lv_group_get_editing(group)))) + { + if (key == LV_KEY_UP || key == LV_KEY_LEFT) lv_group_focus_prev(group); + else lv_group_focus_next(group); + if (auto* next = lv_group_get_focused(group)) lv_obj_scroll_to_view(next, LV_ANIM_OFF); + state.consumed = true; + } + state.translated = key; + return state.consumed; +} +} // namespace ui::input::cardputer +#endif diff --git a/modules/ui_shared/src/ui/input/pager_navigation.cpp b/modules/ui_shared/src/ui/input/pager_navigation.cpp index 3b95c8a3..fecffaae 100644 --- a/modules/ui_shared/src/ui/input/pager_navigation.cpp +++ b/modules/ui_shared/src/ui/input/pager_navigation.cpp @@ -2,6 +2,7 @@ #include "platform/ui/radio_iq_runtime.h" #include "platform/ui/tmap_creation_runtime.h" #include "platform/ui/walkie_runtime.h" +#include "ui/input/keyboard_shortcuts.h" #if defined(ARDUINO_T_LORA_PAGER) || defined(__linux__) #include "platform/memory/psram_ptr.h" #include "platform/ui/psram_ui_lease.h" @@ -27,6 +28,18 @@ struct PageHelp PageHelp* pages = nullptr; uint32_t consumed_key = 0; +void reject_control_text(lv_event_t* event) +{ + const auto* text = static_cast(lv_event_get_param(event)); + if (!text) return; + for (; *text; ++text) + if (*text < 32 && *text != '\n' && *text != '\t') + { + lv_textarea_set_insert_replace(lv_event_get_target_obj(event), ""); + return; + } +} + bool group_exists(lv_group_t* target) { for (uint32_t i = 0; i < lv_group_get_count(); ++i) @@ -108,6 +121,22 @@ void show_help(PageHelp& page, lv_group_t* group) unsigned count = 5; const auto* app = ui_get_active_app(); const char* id = app ? app->stable_id() : ""; +#if defined(TRAIL_MATE_CARDPUTER_ZERO_LINUX) + rows[count++] = {"F/Z/X/C", nullptr, "Directions outside text editing"}; + rows[count++] = {"Shift+Tab", nullptr, "Previous control"}; + rows[count++] = {"Fn+H", nullptr, "Help without leaving the editor"}; + if (!std::strcmp(id, "chat")) rows[count++] = {"Ctrl+Enter", nullptr, "Send from the message editor"}; + if (!std::strcmp(id, "tracker")) + { + rows[count++] = {"4 / 5 / 6", nullptr, "Record / Tracks / Routes"}; + rows[count++] = {"7 / 8", nullptr, "Actions / Menu"}; + } + if (!std::strcmp(id, "music")) + { + rows[count++] = {"4 / 5 / 6", nullptr, "Previous / Play-pause / Next"}; + rows[count++] = {"7 / 8", nullptr, "Library / Menu"}; + } +#endif if (id && !std::strcmp(id, "walkie_talkie")) platform::ui::walkie::set_ptt(false); if (simple_navigation(id)) rows[count++] = {"Up/Down", "W/S", "Previous / next control"}; const char* title = page.title ? lv_label_get_text(page.title) : ""; @@ -285,6 +314,13 @@ void prepare_group(lv_group_t* group) for (unsigned i = 0; i < lv_group_get_obj_count(group); ++i) { auto* object = lv_group_get_obj_by_index(group, i); + if (lv_obj_check_type(object, &lv_textarea_class)) + { + bool guarded = false; + for (uint32_t index = 0; index < lv_obj_get_event_count(object); ++index) + if (lv_event_dsc_get_cb(lv_obj_get_event_dsc(object, index)) == reject_control_text) guarded = true; + if (!guarded) lv_obj_add_event_cb(object, reject_control_text, LV_EVENT_INSERT, nullptr); + } // 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; @@ -298,6 +334,10 @@ void prepare_group(lv_group_t* group) lv_obj_set_style_outline_color(object, color, LV_STATE_FOCUS_KEY); } } +bool page_owns_focus(lv_group_t* group) +{ + return group && page_for(lv_group_get_focused(group)); +} bool handle_key(uint32_t key, bool pressed, lv_group_t* group) { if (!pressed && consumed_key != 0 && consumed_key == key) @@ -309,7 +349,8 @@ bool handle_key(uint32_t key, bool pressed, lv_group_t* group) 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; + const bool explicit_help = key == ::ui::input::kHelpKey; + if (!page || ((!explicit_help) && (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; const bool dropdown_open = lv_obj_check_type(focused, &lv_dropdown_class) && lv_dropdown_is_open(focused); @@ -324,14 +365,14 @@ bool handle_key(uint32_t key, bool pressed, lv_group_t* group) consumed_key = key; return true; } - if (key != 'h' && key != 'H') return false; + if (key != 'h' && key != 'H' && !explicit_help) 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)) + if (native_help(*page, id) && !in_text_editor(focused)) { lv_group_send_data(group, key); // Some focusable containers have no key bubbling route to the diff --git a/modules/ui_shared/src/ui/menu/menu_layout.cpp b/modules/ui_shared/src/ui/menu/menu_layout.cpp index b373850c..2af0086a 100644 --- a/modules/ui_shared/src/ui/menu/menu_layout.cpp +++ b/modules/ui_shared/src/ui/menu/menu_layout.cpp @@ -1093,7 +1093,7 @@ std::string renderBottomHelpText(const char* text) { #if defined(TRAIL_MATE_CARDPUTER_ZERO_LINUX) (void)text; - return ui::menu::dashboard::isFeedOpen() ? "#D6403A V# Menu" : "#D6403A V# Widgets"; + return ui::menu::dashboard::isFeedOpen() ? "#D6403A V# Menu" : "#D6403A 4#Chat #D6403A 5#Map #D6403A 6#Track #D6403A 7#Nodes #D6403A 8#Set"; #else const char* safe_text = text ? text : ""; const size_t prefix_len = std::strlen(kBottomHelpPlainPrefix); diff --git a/modules/ui_shared/src/ui/menu/menu_runtime.cpp b/modules/ui_shared/src/ui/menu/menu_runtime.cpp index ed15b1b1..95250c4c 100644 --- a/modules/ui_shared/src/ui/menu/menu_runtime.cpp +++ b/modules/ui_shared/src/ui/menu/menu_runtime.cpp @@ -252,8 +252,15 @@ void openMenuHelpModal() return; } - ::ui::components::shortcut_help_modal::Row rows[12] = {}; + ::ui::components::shortcut_help_modal::Row rows[20] = {}; std::size_t row_count = 0; +#if defined(TRAIL_MATE_CARDPUTER_ZERO_LINUX) + rows[row_count++] = {"F/Z/X/C", nullptr, "Up / left / down / right"}; + rows[row_count++] = {"4 / 5", nullptr, "Chat / Map"}; + rows[row_count++] = {"6 / 7 / 8", nullptr, "Tracker / Contacts / Settings"}; + rows[row_count++] = {"V", nullptr, "Open or close widgets"}; + rows[row_count++] = {"Fn+H", nullptr, "Context help"}; +#endif if (ui::menu::dashboard::isFeedOpen()) { #if defined(ARDUINO_T_LORA_PAGER) diff --git a/modules/ui_shared/src/ui/screens/calculator/calculator_page_runtime.cpp b/modules/ui_shared/src/ui/screens/calculator/calculator_page_runtime.cpp index 464c2930..75dbdc65 100644 --- a/modules/ui_shared/src/ui/screens/calculator/calculator_page_runtime.cpp +++ b/modules/ui_shared/src/ui/screens/calculator/calculator_page_runtime.cpp @@ -648,6 +648,28 @@ void restoreFocus() namespace calculator::ui::runtime { +bool activate_control(lv_obj_t* button) +{ + if (!s_ui.root || !button || !lv_obj_is_valid(button)) return false; + if (button == s_ui.top_bar.back_btn) + { + requestExit(); + return true; + } + bool owned = false; + for (auto* control : s_ui.function_buttons) owned = owned || control == button; + for (auto* control : s_ui.keypad_buttons) owned = owned || control == button; + if (!owned) return false; + for (uint32_t index = 0; index < lv_obj_get_event_count(button); ++index) + { + auto* descriptor = lv_obj_get_event_dsc(button, index); + if (lv_event_dsc_get_cb(descriptor) != clicked) continue; + applyAction(static_cast(reinterpret_cast(lv_event_dsc_get_user_data(descriptor)))); + return true; + } + return false; +} + void enter(const ::ui::page::Host* host, lv_obj_t* parent) { if (!parent) diff --git a/modules/ui_shared/src/ui/screens/chat/chat_compose_components.cpp b/modules/ui_shared/src/ui/screens/chat/chat_compose_components.cpp index 82b4df1d..38ed155f 100644 --- a/modules/ui_shared/src/ui/screens/chat/chat_compose_components.cpp +++ b/modules/ui_shared/src/ui/screens/chat/chat_compose_components.cpp @@ -1,6 +1,7 @@ #if !defined(ARDUINO_T_WATCH_S3) #include "ui/screens/chat/chat_compose_components.h" +#include "ui/input/keyboard_shortcuts.h" #include "ui/screens/chat/chat_compose_input.h" #include "ui/screens/chat/chat_compose_layout.h" #include "ui/screens/chat/chat_compose_styles.h" @@ -198,6 +199,12 @@ ChatComposeScreen::ChatComposeScreen(lv_obj_t* parent, chat::ConversationId conv init_topbar(); input::bind_textarea_events(impl_->w, this, on_key, on_text_changed); + lv_obj_add_event_cb( + impl_->w.textarea, [](lv_event_t* event) + { + const auto key = lv_event_get_key(event); + if (key == LV_KEY_ESC || key == ::ui::input::kControlEnter) on_key(event); }, + static_cast(LV_EVENT_KEY | LV_EVENT_PREPROCESS), this); input::setup_default_group_focus(impl_->w); syncFocusOrder(); @@ -719,6 +726,14 @@ void ChatComposeScreen::on_key(lv_event_t* e) lv_obj_t* const target = static_cast(lv_event_get_target(e)); lv_indev_t* indev = lv_indev_get_act(); const bool is_encoder = indev && lv_indev_get_type(indev) == LV_INDEV_TYPE_ENCODER; + if (key == ::ui::input::kControlEnter) + { + lv_event_stop_bubbling(e); + lv_event_stop_processing(e); + if (screen->impl_->w.send_btn && !lv_obj_has_state(screen->impl_->w.send_btn, LV_STATE_DISABLED)) + lv_obj_send_event(screen->impl_->w.send_btn, LV_EVENT_CLICKED, nullptr); + return; + } if (key == LV_KEY_ESC) { if (screen->back_cb_) diff --git a/modules/ui_shared/src/ui/screens/chat/chat_compose_layout.cpp b/modules/ui_shared/src/ui/screens/chat/chat_compose_layout.cpp index 2657fa15..aa657a8d 100644 --- a/modules/ui_shared/src/ui/screens/chat/chat_compose_layout.cpp +++ b/modules/ui_shared/src/ui/screens/chat/chat_compose_layout.cpp @@ -108,7 +108,7 @@ void create(lv_obj_t* parent, const Spec& spec, Widgets& w) #if defined(TRAIL_MATE_CARDPUTER_ZERO_LINUX) lv_obj_set_style_pad_all(w.textarea, 4, 0); auto* keyboard_hint = lv_label_create(w.content); - ::ui::i18n::set_label_text(keyboard_hint, "Tab to Send, then Enter"); + ::ui::i18n::set_label_text(keyboard_hint, "Ctrl+Enter Send / Tab controls"); lv_obj_set_style_text_font(keyboard_hint, ::ui::page_profile::resolve_tiny_font(), 0); lv_obj_set_style_text_color(keyboard_hint, lv_color_hex(0x6A5646), 0); #endif diff --git a/modules/ui_shared/src/ui/screens/contacts/contacts_page_components.cpp b/modules/ui_shared/src/ui/screens/contacts/contacts_page_components.cpp index 60ba542a..186882a2 100644 --- a/modules/ui_shared/src/ui/screens/contacts/contacts_page_components.cpp +++ b/modules/ui_shared/src/ui/screens/contacts/contacts_page_components.cpp @@ -3852,9 +3852,12 @@ static void on_card_navigation(lv_event_t* event) else if (key == LV_KEY_UP || key == LV_KEY_LEFT) step_node_card(-1); else if (key == LV_KEY_ESC || key == LV_KEY_BACKSPACE) { - contacts_focus_to_filter(); lv_event_stop_processing(event); lv_event_stop_bubbling(event); + // The filter menu already owns its first-level dismissal. Back + // from the card must leave Contacts, not reopen that same menu. + if (g_contacts_state.top_bar.back_btn) + lv_obj_send_event(g_contacts_state.top_bar.back_btn, LV_EVENT_CLICKED, nullptr); } } else if (lv_event_get_code(event) == LV_EVENT_GESTURE) diff --git a/modules/ui_shared/src/ui/screens/geocaching/geocaching_page_shell.cpp b/modules/ui_shared/src/ui/screens/geocaching/geocaching_page_shell.cpp index f3d16107..6bbe4c6e 100644 --- a/modules/ui_shared/src/ui/screens/geocaching/geocaching_page_shell.cpp +++ b/modules/ui_shared/src/ui/screens/geocaching/geocaching_page_shell.cpp @@ -265,7 +265,10 @@ void keyEvent(lv_event_t* event) void addFocusable(lv_obj_t* object) { if (page->group) lv_group_add_obj(page->group, object); - lv_obj_add_event_cb(object, keyEvent, LV_EVENT_KEY, nullptr); + const auto event = lv_obj_check_type(object, &lv_textarea_class) + ? static_cast(LV_EVENT_KEY | LV_EVENT_PREPROCESS) + : LV_EVENT_KEY; + lv_obj_add_event_cb(object, keyEvent, event, nullptr); } void setEnabled(lv_obj_t* object, bool enabled) { 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 53620121..4d210b2e 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 @@ -3715,9 +3715,16 @@ void open_map_help_modal() }; add_help_row("WASD", nullptr, "Move map"); - add_help_row("Q", "E", "Zoom map"); +#if defined(TRAIL_MATE_CARDPUTER_ZERO_LINUX) + add_help_row("FZXC", nullptr, "Up / left / down / right"); + add_help_row("4/5", "6", "Zoom out / in / current position"); + add_help_row("7/8", nullptr, "Change layer / open Menu"); + add_help_row("Menu", nullptr, "Search for a place"); +#else add_help_row("C", "Pos", "Center current position"); add_help_row("F", nullptr, "Find a place"); +#endif + add_help_row("Q", "E", "Zoom map"); add_help_row("P", nullptr, "Show/hide route photos"); add_help_row("L", nullptr, "Change base layer"); add_help_row("O", "Ctr", "Toggle contour overlay"); @@ -4316,6 +4323,14 @@ bool handle_map_key(uint32_t key, lv_event_t* e) if (app_g && s_map_layer_btn) lv_group_focus_obj(s_map_layer_btn); return true; } + if (app_g && (key == LV_KEY_UP || key == LV_KEY_DOWN || key == LV_KEY_LEFT || key == LV_KEY_RIGHT)) + { + consume_key_event(e); + if (key == LV_KEY_UP || key == LV_KEY_LEFT) lv_group_focus_prev(app_g); + else lv_group_focus_next(app_g); + if (auto* focused = lv_group_get_focused(app_g)) lv_obj_scroll_to_view(focused, LV_ANIM_OFF); + return true; + } return false; } if (!s_location_request && (key == 'm' || key == 'M')) 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 8ececf9e..0a0a04b8 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 @@ -372,6 +372,13 @@ void draw() ui::fonts::apply_content_font(self.editor, "", &lv_font_montserrat_14); lv_obj_add_event_cb(self.editor, playlist_name_changed, LV_EVENT_VALUE_CHANGED, nullptr); lv_obj_add_event_cb(self.editor, editor_ready, LV_EVENT_READY, nullptr); + lv_obj_add_event_cb( + self.editor, [](lv_event_t* event) + { + if (lv_event_get_key(event) != LV_KEY_ESC) return; + lv_event_stop_bubbling(event); + key_event(event); }, + static_cast(LV_EVENT_KEY | LV_EVENT_PREPROCESS), nullptr); ui::widgets::attach_touch_text_editor(self.editor); lv_group_focus_obj(self.editor); self.notice = label(panel, "Keep a short, clear name", 12, 88, w - 24, &lv_font_montserrat_12, true); diff --git a/modules/ui_shared/src/ui/screens/settings/settings_page_components.cpp b/modules/ui_shared/src/ui/screens/settings/settings_page_components.cpp index a05933a8..70df2aa1 100644 --- a/modules/ui_shared/src/ui/screens/settings/settings_page_components.cpp +++ b/modules/ui_shared/src/ui/screens/settings/settings_page_components.cpp @@ -43,6 +43,7 @@ #include "platform/ui/wireless_companion_runtime.h" #include "ui/app_runtime.h" #include "ui/assets/fonts/font_utils.h" +#include "ui/components/dialog_frame.h" #include "ui/localization.h" #include "ui/menu/menu_layout.h" #include "ui/page/page_profile.h" @@ -2036,6 +2037,7 @@ static void on_modal_key(lv_event_t* e) { return; } + if (::ui::components::dialog_frame::handle_focus_key(e)) return; const uint32_t key = lv_event_get_key(e); if (key == LV_KEY_ESC) { diff --git a/modules/ui_shared/src/ui/screens/team/team_page_components.cpp b/modules/ui_shared/src/ui/screens/team/team_page_components.cpp index c7c0f3f3..8c30828b 100644 --- a/modules/ui_shared/src/ui/screens/team/team_page_components.cpp +++ b/modules/ui_shared/src/ui/screens/team/team_page_components.cpp @@ -1447,8 +1447,22 @@ void handle_page_transition(TeamPage next_page) (void)next_page; } +bool require_pairing_service() +{ + if (app::teamFacade().getTeamPairing()) + { + return true; + } + ::ui::feedback::show_notice("Team pairing is unavailable on this device", 2000); + return false; +} + bool start_pairing_command(TeamPagePairingCommandRole role) { + if (!require_pairing_service()) + { + return false; + } auto state = command_state_from_page(); const auto effects = TeamPagePairingCommandAction().startPairing( state, @@ -1468,6 +1482,10 @@ bool start_pairing_command(TeamPagePairingCommandRole role) void handle_create(lv_event_t*) { + if (!require_pairing_service()) + { + return; + } auto command_state = command_state_from_page(); auto key_state = key_event_state_from_page(); TeamPageRandomByteAdapter random; @@ -1683,6 +1701,10 @@ void handle_join_retry(lv_event_t*) void handle_kicked_join(lv_event_t*) { + if (!require_pairing_service()) + { + return; + } reduce_command_state( [](TeamPageCommandReducer& reducer, TeamPageCommandState& state) { @@ -1748,6 +1770,7 @@ void render_page() input.page = team_page_state().page; input.read_model = read_model_input; input.pairing_peer_id = team_page_state().pairing_peer_id; + input.pairing_available = app::teamFacade().getTeamPairing() != nullptr; TeamPageLvglRenderer(now_secs()).render(context, input, handlers, team_page_name_resolver()); diff --git a/modules/ui_shared/src/ui/screens/team/team_page_input.cpp b/modules/ui_shared/src/ui/screens/team/team_page_input.cpp index c0e1723a..3effb2e4 100644 --- a/modules/ui_shared/src/ui/screens/team/team_page_input.cpp +++ b/modules/ui_shared/src/ui/screens/team/team_page_input.cpp @@ -83,7 +83,7 @@ void refresh_team_input(const TeamPageInputContext& context) if (back_button()) { - lv_group_add_obj(s_group, back_button()); + add_if(back_button()); } if (s_context.focusables) diff --git a/modules/ui_shared/src/ui/screens/team/team_page_lvgl_renderer.cpp b/modules/ui_shared/src/ui/screens/team/team_page_lvgl_renderer.cpp index 5e1ba6c1..1f2e1b29 100644 --- a/modules/ui_shared/src/ui/screens/team/team_page_lvgl_renderer.cpp +++ b/modules/ui_shared/src/ui/screens/team/team_page_lvgl_renderer.cpp @@ -66,9 +66,18 @@ TeamPageLvglRenderer::TeamPageLvglRenderer(uint32_t now_s) void TeamPageLvglRenderer::render( TeamPageLvglRendererContext& context, const TeamPageLvglRendererInput& input, - const TeamPageLvglRendererHandlers& handlers, + const TeamPageLvglRendererHandlers& requested_handlers, const ITeamPageLvglNameResolver& names) const { + auto handlers = requested_handlers; + if (!input.pairing_available) + { + handlers.create_team = nullptr; + handlers.join_team = nullptr; + handlers.invite = nullptr; + handlers.join_retry = nullptr; + handlers.kicked_join = nullptr; + } clearContent(context); if (context.body) { @@ -85,7 +94,7 @@ void TeamPageLvglRenderer::render( switch (input.page) { case TeamPage::StatusNotInTeam: - renderStatusNotInTeam(context, handlers); + renderStatusNotInTeam(context, handlers, input.pairing_available); break; case TeamPage::StatusInTeam: renderStatusInTeam(context, input.read_model, handlers); @@ -109,9 +118,15 @@ void TeamPageLvglRenderer::render( renderKickedOut(context, handlers); break; default: - renderStatusNotInTeam(context, handlers); + renderStatusNotInTeam(context, handlers, input.pairing_available); break; } + if (!input.pairing_available && + (input.page == TeamPage::TeamHome || input.page == TeamPage::JoinPending || + input.page == TeamPage::KickedOut)) + { + addLabel(context.body, ::ui::i18n::tr("Team pairing is unavailable on this device"), false, true); + } } void TeamPageLvglRenderer::clearContent( @@ -156,7 +171,7 @@ void TeamPageLvglRenderer::registerFocus( lv_obj_t* obj, bool is_default) const { - if (!obj || !context.focusables) + if (!obj || !context.focusables || lv_obj_has_state(obj, LV_STATE_DISABLED)) { return; } @@ -223,6 +238,10 @@ lv_obj_t* TeamPageLvglRenderer::createFittedButton( { lv_obj_add_event_cb(btn, cb, LV_EVENT_CLICKED, nullptr); } + else + { + lv_obj_add_state(btn, LV_STATE_DISABLED); + } return btn; } @@ -383,13 +402,16 @@ void TeamPageLvglRenderer::renderMemberChipsOrEmpty( void TeamPageLvglRenderer::renderStatusNotInTeam( TeamPageLvglRendererContext& context, - const TeamPageLvglRendererHandlers& handlers) const + const TeamPageLvglRendererHandlers& handlers, + bool pairing_available) const { updateTopBarTitle(context, ::ui::i18n::tr("Team")); addLabel(context.body, ::ui::i18n::tr("You are not in a team"), true, false); addLabel(context.body, ::ui::i18n::tr("- No shared map\n- No team awareness"), false, true); - addLabel(context.body, ::ui::i18n::tr("Keep devices within 5m"), false, false); + addLabel(context.body, + ::ui::i18n::tr(pairing_available ? "Keep devices within 5m" : "Team pairing is unavailable on this device"), + false, false); if (context.action_btns && context.action_btn_count > 0) { diff --git a/modules/ui_shared/src/ui/screens/tracker/tracker_page_components.cpp b/modules/ui_shared/src/ui/screens/tracker/tracker_page_components.cpp index 4bca9a82..8c1d9d8b 100644 --- a/modules/ui_shared/src/ui/screens/tracker/tracker_page_components.cpp +++ b/modules/ui_shared/src/ui/screens/tracker/tracker_page_components.cpp @@ -2387,7 +2387,13 @@ void open_route_preview_help_modal() add_help_row("WASD", nullptr, "Pan map (images hidden)"); add_help_row("Q / -", nullptr, "Zoom out"); add_help_row("E / +", nullptr, "Zoom in"); +#if defined(TRAIL_MATE_CARDPUTER_ZERO_LINUX) + add_help_row("FZXC", nullptr, "Up / left / down / right"); + add_help_row("4/5", "6", "Zoom out / in / fit route"); + add_help_row("7/8", nullptr, "Change layer / Help"); +#else add_help_row("F", nullptr, "Fit whole route"); +#endif add_help_row("R", nullptr, "Load/off route"); add_help_row("L", nullptr, "Change base layer"); add_help_row("V", nullptr, "Show/hide profile"); 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 b133702e..45509b56 100644 --- a/modules/ui_shared/src/ui/widgets/ime/ime_widget.cpp +++ b/modules/ui_shared/src/ui/widgets/ime/ime_widget.cpp @@ -818,28 +818,30 @@ bool ImeWidget::handle_key_code(uint32_t key) { if (key == LV_KEY_BACKSPACE) { - erase_last_utf8_char(committed_text_); - update_text = true; + lv_textarea_delete_char(textarea_); consumed = true; } else if (key == LV_KEY_ENTER) { - committed_text_ += "\n"; - update_text = true; + lv_textarea_add_text(textarea_, "\n"); consumed = true; } else if (key == ' ') { - committed_text_ += " "; - update_text = true; + lv_textarea_add_text(textarea_, " "); consumed = true; } else if (key >= 32 && key <= 126) { - committed_text_.push_back(static_cast(key)); - update_text = true; + const char text[] = {static_cast(key), '\0'}; + lv_textarea_add_text(textarea_, text); consumed = true; } + if (consumed) + { + committed_text_ = lv_textarea_get_text(textarea_); + refresh_textarea_content_font(textarea_); + } } if (consumed && update_text) @@ -861,8 +863,9 @@ bool ImeWidget::handle_text_token(const char* token) return false; } - committed_text_ += token; - sync_textarea(); + lv_textarea_add_text(textarea_, token); + committed_text_ = lv_textarea_get_text(textarea_); + refresh_textarea_content_font(textarea_); refresh_labels(); return true; } @@ -875,6 +878,18 @@ bool ImeWidget::handle_key(lv_event_t* e) } const uint32_t key = lv_event_get_key(e); + if (!pinyin_mode() && lv_event_get_target(e) == textarea_) + { + // The textarea class has already applied this physical key, including + // cursor movement and character constraints. Do not append it again + // through the proxy/touch path or reset the cursor to the end. + committed_text_ = lv_textarea_get_text(textarea_); + refresh_textarea_content_font(textarea_); + refresh_labels(); + const bool text_key = key == LV_KEY_BACKSPACE || key == LV_KEY_ENTER || (key >= 32 && key <= 126); + if (text_key) lv_event_stop_processing(e); + return text_key; + } const bool consumed = handle_key_code(key); if (consumed) { diff --git a/modules/ui_shared/src/ui/widgets/map/poi_overlay.cpp b/modules/ui_shared/src/ui/widgets/map/poi_overlay.cpp index 239555dd..d5229571 100644 --- a/modules/ui_shared/src/ui/widgets/map/poi_overlay.cpp +++ b/modules/ui_shared/src/ui/widgets/map/poi_overlay.cpp @@ -1,5 +1,6 @@ #include "ui/widgets/map/poi_overlay.h" #include "ui/assets/fonts/font_utils.h" +#include "ui_map_runtime/map_poi/annotation_layout.h" #include #include #include @@ -181,8 +182,8 @@ bool PoiOverlay::measure_text(void* view, const char* text, std::size_t bytes, b std::memcpy(prefix, text, bytes); if (ellipsis) { - std::memcpy(prefix + bytes, "\xE2\x80\xA6", 3); - bytes += 3; + std::memcpy(prefix + bytes, ::ui::map_poi::kAnnotationEllipsis, ::ui::map_poi::kAnnotationEllipsisBytes); + bytes += ::ui::map_poi::kAnnotationEllipsisBytes; } prefix[bytes] = '\0'; // A missing glyph must not be laid out using LVGL's placeholder metrics. diff --git a/modules/ui_shared/src/ui/widgets/top_bar.cpp b/modules/ui_shared/src/ui/widgets/top_bar.cpp index c5dda816..42aa2ca6 100644 --- a/modules/ui_shared/src/ui/widgets/top_bar.cpp +++ b/modules/ui_shared/src/ui/widgets/top_bar.cpp @@ -295,8 +295,19 @@ void top_bar_set_title(TopBar& bar, const char* title) // DOT can wrap when height is LV_SIZE_CONTENT. Constrain the label to // exactly one line, including when a fallback font handles a peer name. const auto* font = lv_obj_get_style_text_font(bar.title_label, LV_PART_MAIN); + auto available_height = lv_obj_get_content_height(bar.container); + if (available_height <= 0) + { + // A freshly created dialog has not completed flex layout yet. Its + // fixed declared height is already known; do not pin the title to zero. + available_height = std::max(1, + lv_obj_get_style_height(bar.container, LV_PART_MAIN) - + lv_obj_get_style_pad_top(bar.container, LV_PART_MAIN) - + lv_obj_get_style_pad_bottom(bar.container, LV_PART_MAIN) - + 2 * lv_obj_get_style_border_width(bar.container, LV_PART_MAIN)); + } lv_obj_set_height(bar.title_label, std::min( - lv_font_get_line_height(font), lv_obj_get_content_height(bar.container))); + lv_font_get_line_height(font), available_height)); #endif } diff --git a/platform/linux/common/include/ui/shell_ui_runner.h b/platform/linux/common/include/ui/shell_ui_runner.h index b422215e..0ddfaffd 100644 --- a/platform/linux/common/include/ui/shell_ui_runner.h +++ b/platform/linux/common/include/ui/shell_ui_runner.h @@ -1,4 +1,5 @@ #pragma once +#include "ui/input/cardputer_keyboard.h" #include #include @@ -130,6 +131,7 @@ class CanvasLvglHost core::Canvas canvas_; std::vector frame_buffer_{}; bool dirty_{true}; + ::ui::input::cardputer::State keyboard_profile_{}; }; // --------------------------------------------------------------------------- diff --git a/platform/linux/common/src/app/demo_app.cpp b/platform/linux/common/src/app/demo_app.cpp index 4e36553c..b2d787ca 100644 --- a/platform/linux/common/src/app/demo_app.cpp +++ b/platform/linux/common/src/app/demo_app.cpp @@ -60,6 +60,12 @@ void DemoApp::handleInput(const InputEvent& event) noexcept { case InputKey::TextEdit: break; // Native text edits belong to the focused UI editor. + case InputKey::CursorHome: + status_text_ = "CURSOR HOME REQUESTED"; + break; + case InputKey::CursorEnd: + status_text_ = "CURSOR END REQUESTED"; + break; case InputKey::Character: if (event.text != '\0') { diff --git a/platform/linux/common/src/app/input_event.h b/platform/linux/common/src/app/input_event.h index abbc1045..689cf630 100644 --- a/platform/linux/common/src/app/input_event.h +++ b/platform/linux/common/src/app/input_event.h @@ -27,6 +27,8 @@ enum class InputKey Down, F1, TextEdit, + CursorHome, + CursorEnd, }; struct InputEvent @@ -35,6 +37,9 @@ struct InputEvent std::string label{}; char text{'\0'}; app::TextEdit edit{}; + bool shift = false; + bool control = false; + bool alt = false; }; [[nodiscard]] inline InputEvent makeCharacterInput(char ch, std::string label = {}) diff --git a/platform/linux/common/src/app/linux_app_services.cpp b/platform/linux/common/src/app/linux_app_services.cpp index f9da5bb9..97597bd1 100644 --- a/platform/linux/common/src/app/linux_app_services.cpp +++ b/platform/linux/common/src/app/linux_app_services.cpp @@ -2490,7 +2490,8 @@ const ::chat::IMeshAdapter* LinuxAppServices::getMeshAdapter() const ::team::TeamPairingService* LinuxAppServices::getTeamPairing() { - return &impl().pairing_service; + // This transport synthesizes peers; production Linux has no pairing bearer yet. + return impl().demo_world_enabled ? &impl().pairing_service : nullptr; } ::team::TeamService* LinuxAppServices::teamService() @@ -2653,7 +2654,10 @@ void LinuxAppServices::tickEventRuntime() return; } - impl_->pairing_service.update(); + if (impl_->demo_world_enabled) + { + impl_->pairing_service.update(); + } ::sys::EventBus::publish(new ::sys::Event(::sys::EventType::SystemTick), 0); } diff --git a/platform/linux/common/src/ui/shell_ui_runner.cpp b/platform/linux/common/src/ui/shell_ui_runner.cpp index d30539fe..3ce21079 100644 --- a/platform/linux/common/src/ui/shell_ui_runner.cpp +++ b/platform/linux/common/src/ui/shell_ui_runner.cpp @@ -1,5 +1,7 @@ #include "ui/shell_ui_runner.h" #include "platform/linux/tmap_creation.h" +#include "ui/app_runtime.h" +#include "ui/input/keyboard_shortcuts.h" #include "ui/input/pager_navigation.h" #include @@ -65,11 +67,15 @@ std::chrono::steady_clock::time_point g_lvgl_start_time = clock::now(); case app::InputKey::Backspace: return LV_KEY_BACKSPACE; case app::InputKey::Enter: - return LV_KEY_ENTER; + return event.control ? ::ui::input::kControlEnter : static_cast(LV_KEY_ENTER); case app::InputKey::Tab: - return LV_KEY_NEXT; + return event.shift ? LV_KEY_PREV : LV_KEY_NEXT; case app::InputKey::Home: return LV_KEY_ESC; + case app::InputKey::CursorHome: + return LV_KEY_HOME; + case app::InputKey::CursorEnd: + return LV_KEY_END; case app::InputKey::Next: return LV_KEY_NEXT; case app::InputKey::Power: @@ -176,6 +182,13 @@ ShellSession::~ShellSession() void ShellSession::releaseUiBindings() { impl_->text_input.reset(); + // Pages own LVGL timers and borrow binding-owned providers. Close them + // while both are alive, including direct process exit from a Map modal. + if (auto* active = ui_get_active_app()) + { + ui_detach_app(active); + active->exit(nullptr); + } impl_->voice.reset(); if (impl_->agenda) impl_->agenda->releaseUi(); if (!impl_->geocaching_bound) return; @@ -370,7 +383,15 @@ void CanvasLvglHost::readInputCb(lv_indev_t* indev, lv_indev_data_t* d) { if (!host->shell_.dequeueKeyEvent(&key, &state)) return; auto* group = lv_indev_get_group(indev); - if (state == LV_INDEV_STATE_PRESSED) ::ui::input::pager::prepare_group(group); + if (state == LV_INDEV_STATE_PRESSED) + { + // A shortcut consumed before LVGL dispatch is still user activity. + lv_display_trigger_activity(lv_indev_get_display(indev)); + ::ui::input::pager::prepare_group(group); + } +#if defined(TRAIL_MATE_CARDPUTER_ZERO_LINUX) + if (::ui::input::cardputer::handle_key(key, state == LV_INDEV_STATE_PRESSED, group, host->keyboard_profile_)) continue; +#endif if (!::ui::input::pager::handle_key(key, state == LV_INDEV_STATE_PRESSED, group)) break; } diff --git a/platform/linux/rpi/src/platform/device/evdev_input.cpp b/platform/linux/rpi/src/platform/device/evdev_input.cpp index bee72de3..febef006 100644 --- a/platform/linux/rpi/src/platform/device/evdev_input.cpp +++ b/platform/linux/rpi/src/platform/device/evdev_input.cpp @@ -39,8 +39,8 @@ constexpr auto kKeyMap = std::to_array({ {KEY_DOWN, app::InputKey::Down}, {KEY_LEFT, app::InputKey::Left}, {KEY_RIGHT, app::InputKey::Right}, - {KEY_HOME, app::InputKey::Home}, - {KEY_END, app::InputKey::Next}, + {KEY_HOME, app::InputKey::CursorHome}, + {KEY_END, app::InputKey::CursorEnd}, // Action keys {KEY_ENTER, app::InputKey::Enter}, @@ -49,6 +49,7 @@ constexpr auto kKeyMap = std::to_array({ {KEY_TAB, app::InputKey::Tab}, {KEY_ESC, app::InputKey::Power}, {KEY_F1, app::InputKey::F1}, + {KEY_HELP, app::InputKey::F1}, // Modifiers (mapped as modifiers – input handling may need expansion later) {KEY_LEFTSHIFT, app::InputKey::Shift}, @@ -322,8 +323,12 @@ app::InputEvent makeInputEvent(const KeyMapping& mapping, bool shift_held) label_str = "Right"; break; case app::InputKey::Home: + case app::InputKey::CursorHome: label_str = "Home"; break; + case app::InputKey::CursorEnd: + label_str = "End"; + break; case app::InputKey::Next: label_str = "Next"; break; @@ -427,8 +432,10 @@ struct EvdevInput::Impl bool ctrl_held{false}; bool alt_held{false}; bool escape_held{false}; + bool home_held{false}; bool quit_requested{false}; std::chrono::steady_clock::time_point escape_pressed_at{}; + std::chrono::steady_clock::time_point home_pressed_at{}; bool openDevice(const std::string& path) { @@ -464,6 +471,7 @@ struct EvdevInput::Impl ctrl_held = false; alt_held = false; escape_held = false; + home_held = false; quit_requested = false; } @@ -615,6 +623,7 @@ std::vector EvdevInput::drainInput() { // A lost release must never become an unintended long-press exit. impl_->escape_held = false; + impl_->home_held = false; } // We only emit EV_KEY events. if (raw.type != EV_KEY) @@ -622,14 +631,20 @@ std::vector EvdevInput::drainInput() continue; } - if (raw.code == KEY_ESC) + if (raw.code == KEY_ESC || raw.code == KEY_HOME) { - if (raw.value == 1 && !impl_->escape_held) + auto& held = raw.code == KEY_ESC ? impl_->escape_held : impl_->home_held; + auto& pressed_at = raw.code == KEY_ESC ? impl_->escape_pressed_at : impl_->home_pressed_at; + const auto now = std::chrono::steady_clock::now(); + // Also recognize a completed hold if its release is waiting in + // this batch; releasing the key must not erase the exit request. + if (held && now - pressed_at >= std::chrono::milliseconds(1500)) impl_->quit_requested = true; + if (raw.value == 1 && !held) { - impl_->escape_held = true; - impl_->escape_pressed_at = std::chrono::steady_clock::now(); + held = true; + pressed_at = now; } - else if (raw.value == 0) impl_->escape_held = false; + else if (raw.value == 0) held = false; } // Track modifier state. @@ -660,9 +675,15 @@ std::vector EvdevInput::drainInput() continue; } - events.push_back(makeInputEvent(*mapping, impl_->shift_held)); + auto event = makeInputEvent(*mapping, impl_->shift_held); + event.shift = impl_->shift_held; + event.control = impl_->ctrl_held; + event.alt = impl_->alt_held; + events.push_back(std::move(event)); } - if (impl_->escape_held && std::chrono::steady_clock::now() - impl_->escape_pressed_at >= std::chrono::milliseconds(1500)) + const auto now = std::chrono::steady_clock::now(); + if ((impl_->escape_held && now - impl_->escape_pressed_at >= std::chrono::milliseconds(1500)) || + (impl_->home_held && now - impl_->home_pressed_at >= std::chrono::milliseconds(1500))) impl_->quit_requested = true; #endif diff --git a/platform/linux/rpi/src/platform/device/evdev_input.h b/platform/linux/rpi/src/platform/device/evdev_input.h index 160e4d59..58ae4930 100644 --- a/platform/linux/rpi/src/platform/device/evdev_input.h +++ b/platform/linux/rpi/src/platform/device/evdev_input.h @@ -44,7 +44,7 @@ class EvdevInput /// when no device is open or no events are available. std::vector drainInput(); - /// A long Esc hold requests a clean return to APPLaunch. + /// Holding Home or Esc for 1.5 seconds requests a clean return to APPLaunch. bool quitRequested() const noexcept; /// Returns the device path that was actually opened (for diagnostics). diff --git a/tests/map_poi/test_annotation_frame.cpp b/tests/map_poi/test_annotation_frame.cpp index 2e18f4ec..234aa5e1 100644 --- a/tests/map_poi/test_annotation_frame.cpp +++ b/tests/map_poi/test_annotation_frame.cpp @@ -28,12 +28,34 @@ struct Heap bool measure(void* context, const char*, std::size_t bytes, bool ellipsis, int16_t& width, int16_t& height) { if (context) return false; - width = static_cast(bytes * 6 + (ellipsis ? 6 : 0)); + width = static_cast((bytes + (ellipsis ? kAnnotationEllipsisBytes : 0)) * 6); height = 14; return true; } int main() { + { + Heap heap; + AnnotationFrame frame(Heap::allocate, Heap::release, &heap); + ui::map::MapPoiSnapshot metadata; + metadata.enabled = true; + metadata.width = 240; + metadata.height = 120; + AnnotationLayoutOptions options; + options.width = 240; + options.height = 120; + options.max_text_width = 40; + AnnotationCandidate candidate; + candidate.key = candidate.feature_key = 1; + candidate.name = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + candidate.category = "road"; + candidate.x = candidate.y = 30; + assert(frame.begin(1, metadata)); + frame.add(candidate); + assert(frame.finish(options, measure, nullptr)); + assert(frame.snapshot().item_count == 1); + assert(std::strcmp(frame.snapshot().items[0].label.c_str(), "ABC...") == 0); + } for (int failure = 0; failure < 5; ++failure) { Heap heap; diff --git a/tests/settings_navigation/CMakeLists.txt b/tests/settings_navigation/CMakeLists.txt index 67aa2a85..fea3632c 100644 --- a/tests/settings_navigation/CMakeLists.txt +++ b/tests/settings_navigation/CMakeLists.txt @@ -34,7 +34,7 @@ extract_between("static void settings_back_cb(" "static void bind_dynamic_option extract_between("static lv_obj_t* create_modal_root(" "static void on_text_save_clicked(" LAYOUT) file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/settings_lifecycle_actual.inc" "${MODAL}\n${BACK}\n${LAYOUT}") add_executable(settings_navigation test_navigation.cpp) -target_include_directories(settings_navigation PRIVATE "${CMAKE_CURRENT_BINARY_DIR}") +target_include_directories(settings_navigation PRIVATE "${CMAKE_CURRENT_BINARY_DIR}" "${REPO}/modules/ui_shared/include") target_link_libraries(settings_navigation PRIVATE lvgl) if(MSVC) target_compile_options(settings_navigation PRIVATE /UNDEBUG) diff --git a/tests/settings_navigation/test_navigation.cpp b/tests/settings_navigation/test_navigation.cpp index 8c2ee737..b54cb17c 100644 --- a/tests/settings_navigation/test_navigation.cpp +++ b/tests/settings_navigation/test_navigation.cpp @@ -1,4 +1,5 @@ #include "lvgl.h" +#include "ui/components/dialog_frame.h" #include #include #include diff --git a/tests/tracker_card_view/CMakeLists.txt b/tests/tracker_card_view/CMakeLists.txt index 4d5fbb11..3611d9ff 100644 --- a/tests/tracker_card_view/CMakeLists.txt +++ b/tests/tracker_card_view/CMakeLists.txt @@ -29,11 +29,16 @@ target_compile_definitions(bottom_bar_contract PRIVATE LV_CONF_INCLUDE_SIMPLE) target_link_libraries(bottom_bar_contract PRIVATE ${LVGL_LIBRARY}) add_test(NAME bottom_bar_contract COMMAND bottom_bar_contract) -add_executable(dialog_frame_contract test_dialog_frame.cpp +foreach(dialog_target dialog_frame_contract dialog_frame_cardputer_contract) +add_executable(${dialog_target} test_dialog_frame.cpp ${REPO}/modules/ui_shared/src/ui/components/dialog_frame.cpp ${REPO}/modules/ui_shared/src/ui/components/bottom_bar.cpp ${REPO}/modules/ui_shared/src/ui/widgets/top_bar.cpp) -target_include_directories(dialog_frame_contract PRIVATE ${REPO}/modules/ui_shared/include ${LVGL_DIR} ${REPO}/tests/contacts_card_view) -target_compile_definitions(dialog_frame_contract PRIVATE LV_CONF_INCLUDE_SIMPLE) -target_link_libraries(dialog_frame_contract PRIVATE ${LVGL_LIBRARY}) -add_test(NAME dialog_frame_contract COMMAND dialog_frame_contract) +target_include_directories(${dialog_target} PRIVATE ${REPO}/modules/ui_shared/include ${LVGL_DIR} ${REPO}/tests/contacts_card_view) +target_compile_definitions(${dialog_target} PRIVATE LV_CONF_INCLUDE_SIMPLE) +if(dialog_target STREQUAL "dialog_frame_cardputer_contract") + target_compile_definitions(${dialog_target} PRIVATE TRAIL_MATE_CARDPUTER_ZERO_LINUX=1) +endif() +target_link_libraries(${dialog_target} PRIVATE ${LVGL_LIBRARY}) +add_test(NAME ${dialog_target} COMMAND ${dialog_target}) +endforeach() diff --git a/tests/tracker_card_view/test_dialog_frame.cpp b/tests/tracker_card_view/test_dialog_frame.cpp index 1e824153..c53efabf 100644 --- a/tests/tracker_card_view/test_dialog_frame.cpp +++ b/tests/tracker_card_view/test_dialog_frame.cpp @@ -4,8 +4,15 @@ #include "ui/widgets/top_bar.h" #include #include +#include #include +lv_group_t* app_g = nullptr; +namespace ui::input::pager +{ +bool open_help(lv_obj_t*, lv_group_t*) { return false; } +} // namespace ui::input::pager + // Keep the real LVGL layout, shared frame, TopBar and button code; replace only // app services that are irrelevant to the component's geometry and lifetime. namespace ui::page_profile @@ -87,6 +94,11 @@ int main() ui::components::dialog_frame::bind_close_shortcuts(frame, group); lv_obj_update_layout(screen); auto* header = lv_obj_get_child(frame.panel, 0); + auto* heading = lv_obj_get_child(header, 0); + assert(lv_obj_check_type(heading, &lv_label_class)); + assert(std::strcmp(lv_label_get_text(heading), "Navigation") == 0); + assert(lv_obj_get_height(heading) > 0); + assert(lv_obj_get_width(heading) > 0); assert(lv_obj_get_height(header) == lv_obj_get_height(page_bar.container)); assert(lv_obj_get_width(frame.panel) <= width - 12); assert(lv_obj_get_width(frame.close) == lv_obj_get_height(frame.close)); @@ -96,6 +108,10 @@ int main() lv_obj_get_coords(frame.body, &body); lv_obj_get_coords(frame.close, &close); lv_obj_get_coords(header, &title); + lv_area_t caption{}; + lv_obj_get_coords(heading, &caption); + assert(caption.x1 >= title.x1 && caption.x2 <= title.x2); + assert(caption.y1 >= title.y1 && caption.y2 <= title.y2); assert(close.x2 <= panel.x2 && close.y1 >= title.y1 && close.y2 <= title.y2); assert(body.y1 > title.y2 && body.y2 <= panel.y2); lv_obj_scroll_to_y(frame.body, 120, LV_ANIM_OFF); @@ -113,12 +129,36 @@ int main() lv_group_del(group); } const auto compact = ui::components::dialog_frame::create(screen, {"Route Actions", 200, 180}); + auto* action_group = lv_group_create(); + lv_group_add_obj(action_group, compact.close); for (const auto text : {"Load", "Preview", "Delete"}) { auto* row = ui::components::bottom_bar::add_button(compact.body, text); ui::components::bottom_bar::style_item(row); lv_obj_set_size(row, LV_PCT(100), 28); + lv_group_add_obj(action_group, row); } + ui::components::dialog_frame::bind_close_shortcuts(compact, action_group); + auto* load = lv_obj_get_child(compact.body, 0); + auto* preview = lv_obj_get_child(compact.body, 1); + auto* remove = lv_obj_get_child(compact.body, 2); + lv_group_focus_obj(load); + lv_group_send_data(action_group, LV_KEY_DOWN); + assert(lv_group_get_focused(action_group) == preview); + lv_group_send_data(action_group, LV_KEY_RIGHT); + assert(lv_group_get_focused(action_group) == remove); + lv_group_send_data(action_group, LV_KEY_LEFT); + assert(lv_group_get_focused(action_group) == preview); + lv_group_send_data(action_group, LV_KEY_UP); + assert(lv_group_get_focused(action_group) == load); + lv_obj_add_state(preview, LV_STATE_DISABLED); + lv_group_send_data(action_group, LV_KEY_DOWN); + assert(lv_group_get_focused(action_group) == remove); + lv_group_send_data(action_group, LV_KEY_DOWN); + assert(lv_group_get_focused(action_group) == compact.close); + lv_group_send_data(action_group, LV_KEY_DOWN); + assert(lv_group_get_focused(action_group) == load); + lv_group_del(action_group); ui::components::dialog_frame::fit_content_height(compact); lv_obj_update_layout(screen); assert(lv_obj_get_height(compact.panel) < 140);