diff --git a/lib/tdeck_ui/Hardware/TDeck/Trackball.cpp b/lib/tdeck_ui/Hardware/TDeck/Trackball.cpp index fcd646b4..c8f8a662 100644 --- a/lib/tdeck_ui/Hardware/TDeck/Trackball.cpp +++ b/lib/tdeck_ui/Hardware/TDeck/Trackball.cpp @@ -2,11 +2,13 @@ // SPDX-License-Identifier: MIT #include "Trackball.h" +#include "TrackballNavigation.h" #ifdef ARDUINO #include #include +#include // Defined in main.cpp; mirrors input diagnostics to Serial and UDP logging. extern "C" void pyxis_log(const char* msg); @@ -16,6 +18,201 @@ using namespace RNS; namespace Hardware { namespace TDeck { +namespace { + +constexpr lv_coord_t TRACKBALL_SCROLL_STEP = 40; + +bool object_is_visible(lv_obj_t* object) { + return object && lv_obj_is_visible(object); +} + +bool object_is_focus_candidate(lv_obj_t* object) { + return object_is_visible(object) && + !lv_obj_has_state(object, LV_STATE_DISABLED); +} + +bool object_is_hidden(lv_obj_t* object) { + for (lv_obj_t* current = object; current; current = lv_obj_get_parent(current)) { + if (lv_obj_has_flag(current, LV_OBJ_FLAG_HIDDEN)) return true; + } + return false; +} + +NavigationRect object_rect(lv_obj_t* object) { + lv_area_t area; + lv_obj_get_coords(object, &area); + return {area.x1, area.y1, lv_area_get_width(&area), lv_area_get_height(&area)}; +} + +bool is_descendant_of(lv_obj_t* object, lv_obj_t* ancestor) { + for (lv_obj_t* current = object; current; current = lv_obj_get_parent(current)) { + if (current == ancestor) return true; + } + return false; +} + +lv_obj_t* common_ancestor(lv_obj_t* first, lv_obj_t* second) { + if (!first) return second; + if (!second) return first; + for (lv_obj_t* candidate = first; candidate; candidate = lv_obj_get_parent(candidate)) { + if (is_descendant_of(second, candidate)) return candidate; + } + return nullptr; +} + +lv_obj_t* group_navigation_root(lv_group_t* group, lv_obj_t* focused) { + lv_obj_t* root = focused; + lv_obj_t** node = static_cast(_lv_ll_get_head(&group->obj_ll)); + while (node) { + lv_obj_t* object = *node; + if (object_is_visible(object)) root = common_ancestor(root, object); + node = static_cast(_lv_ll_get_next(&group->obj_ll, node)); + } + return root; +} + +bool can_scroll(lv_obj_t* object, NavigationDirection direction) { + if (!object || !object_is_visible(object) || + !lv_obj_has_flag(object, LV_OBJ_FLAG_SCROLLABLE)) return false; + const lv_dir_t scroll_dir = lv_obj_get_scroll_dir(object); + switch (direction) { + case NavigationDirection::UP: + return (scroll_dir & LV_DIR_VER) && lv_obj_get_scroll_top(object) > 0; + case NavigationDirection::DOWN: + return (scroll_dir & LV_DIR_VER) && lv_obj_get_scroll_bottom(object) > 0; + case NavigationDirection::LEFT: + return (scroll_dir & LV_DIR_HOR) && lv_obj_get_scroll_left(object) > 0; + case NavigationDirection::RIGHT: + return (scroll_dir & LV_DIR_HOR) && lv_obj_get_scroll_right(object) > 0; + } + return false; +} + +lv_obj_t* scrollable_ancestor(lv_obj_t* object, NavigationDirection direction) { + for (lv_obj_t* current = object; current; current = lv_obj_get_parent(current)) { + if (can_scroll(current, direction)) return current; + } + return nullptr; +} + +void find_scroll_target(lv_obj_t* root, NavigationDirection direction, + lv_coord_t perpendicular_position, + lv_obj_t*& best, int32_t& best_area) { + if (!root || !object_is_visible(root)) return; + if (can_scroll(root, direction)) { + const NavigationRect rect = object_rect(root); + const bool intersects = direction == NavigationDirection::UP || + direction == NavigationDirection::DOWN + ? perpendicular_position >= rect.x && perpendicular_position < rect.x + rect.width + : perpendicular_position >= rect.y && perpendicular_position < rect.y + rect.height; + const int32_t area = rect.width * rect.height; + if (intersects && area < best_area) { + best = root; + best_area = area; + } + } + const uint32_t child_count = lv_obj_get_child_cnt(root); + for (uint32_t i = 0; i < child_count; ++i) { + find_scroll_target(lv_obj_get_child(root, i), direction, + perpendicular_position, best, best_area); + } +} + +void scroll_one_step(lv_obj_t* object, NavigationDirection direction) { + switch (direction) { + case NavigationDirection::UP: + lv_obj_scroll_to_y(object, lv_obj_get_scroll_y(object) - TRACKBALL_SCROLL_STEP, LV_ANIM_ON); + break; + case NavigationDirection::DOWN: + lv_obj_scroll_to_y(object, lv_obj_get_scroll_y(object) + TRACKBALL_SCROLL_STEP, LV_ANIM_ON); + break; + case NavigationDirection::LEFT: + lv_obj_scroll_to_x(object, lv_obj_get_scroll_x(object) - TRACKBALL_SCROLL_STEP, LV_ANIM_ON); + break; + case NavigationDirection::RIGHT: + lv_obj_scroll_to_x(object, lv_obj_get_scroll_x(object) + TRACKBALL_SCROLL_STEP, LV_ANIM_ON); + break; + } +} + +bool navigate_or_scroll(lv_group_t* group, NavigationDirection direction) { + if (!group) return false; + if (group->frozen) return false; + lv_obj_t* focused = lv_group_get_focused(group); + // A clipped focus target remains the spatial anchor while its container + // scrolls. Only a truly hidden/stale target needs insertion-order refocus. + if (!focused || object_is_hidden(focused)) { + lv_obj_t** node = static_cast(_lv_ll_get_head(&group->obj_ll)); + while (node) { + if (object_is_focus_candidate(*node)) { + lv_group_focus_obj(*node); + return true; + } + node = static_cast(_lv_ll_get_next(&group->obj_ll, node)); + } + return false; + } + + const NavigationRect focused_rect = object_rect(focused); + lv_obj_t* navigation_root = group_navigation_root(group, focused); + lv_obj_t* target = nullptr; + int64_t best_score = std::numeric_limits::max(); + lv_obj_t** node = static_cast(_lv_ll_get_head(&group->obj_ll)); + while (node) { + lv_obj_t* object = *node; + if (object != focused) { + const NavigationCandidate candidate{0, object_rect(object), object_is_focus_candidate(object)}; + const int64_t score = directional_candidate_score(focused_rect, candidate, direction); + if (score < best_score) { + best_score = score; + target = object; + } + } + node = static_cast(_lv_ll_get_next(&group->obj_ll, node)); + } + + lv_obj_t* ancestor = scrollable_ancestor(focused, direction); + if (target) { + // Finish scrolling the current list/document before leaving it for a + // control outside that viewport. Targets inside it still navigate. + if (ancestor && !is_descendant_of(target, ancestor)) { + scroll_one_step(ancestor, direction); + } else { + lv_group_focus_obj(target); + } + return true; + } + if (ancestor) { + scroll_one_step(ancestor, direction); + return true; + } + + lv_obj_t* scroll_target = nullptr; + int32_t best_area = INT32_MAX; + const lv_coord_t perpendicular = direction == NavigationDirection::UP || + direction == NavigationDirection::DOWN + ? focused_rect.x + focused_rect.width / 2 + : focused_rect.y + focused_rect.height / 2; + find_scroll_target(navigation_root, direction, perpendicular, scroll_target, best_area); + if (scroll_target) { + scroll_one_step(scroll_target, direction); + return true; + } + return false; +} + +uint32_t direction_key(NavigationDirection direction) { + switch (direction) { + case NavigationDirection::UP: return LV_KEY_UP; + case NavigationDirection::DOWN: return LV_KEY_DOWN; + case NavigationDirection::LEFT: return LV_KEY_LEFT; + case NavigationDirection::RIGHT: return LV_KEY_RIGHT; + } + return 0; +} + +} // namespace + // Static member initialization lv_indev_t* Trackball::_indev = nullptr; volatile int16_t Trackball::_pulse_up = 0; @@ -239,26 +436,40 @@ void Trackball::lvgl_read_cb(lv_indev_drv_t* drv, lv_indev_data_t* data) { uint32_t now = millis(); - // Check thresholds - use NEXT/PREV for group focus navigation - // LVGL groups only support linear navigation with NEXT/PREV + // Resolve physical direction independently on both axes. In navigation + // mode this uses object geometry instead of the group's insertion order; + // in edit mode real arrow keys are delivered to the focused widget. + NavigationDirection direction = NavigationDirection::UP; + bool has_direction = false; if (abs(accum_y) >= Trk::NAV_THRESHOLD && abs(accum_y) >= abs(accum_x)) { if (now - last_key_time >= Trk::KEY_REPEAT_MS) { - pending_key = (accum_y > 0) ? LV_KEY_NEXT : LV_KEY_PREV; + direction = accum_y > 0 ? NavigationDirection::DOWN : NavigationDirection::UP; + has_direction = true; last_key_time = now; } accum_y = 0; } else if (abs(accum_x) >= Trk::NAV_THRESHOLD) { if (now - last_key_time >= Trk::KEY_REPEAT_MS) { - pending_key = (accum_x > 0) ? LV_KEY_NEXT : LV_KEY_PREV; + direction = accum_x > 0 ? NavigationDirection::RIGHT : NavigationDirection::LEFT; + has_direction = true; last_key_time = now; } accum_x = 0; } + if (has_direction) { + lv_group_t* group = _indev ? _indev->group : nullptr; + if (group && lv_group_get_editing(group)) { + pending_key = direction_key(direction); + } else { + navigate_or_scroll(group, direction); + } + } + // If we have a pending key, check if anything visible is focused // If nothing is focused or focused object is hidden, find a visible object if (pending_key != 0) { - lv_group_t* group = lv_group_get_default(); + lv_group_t* group = _indev ? _indev->group : nullptr; if (group) { lv_obj_t* focused = lv_group_get_focused(group); bool need_refocus = !focused; diff --git a/lib/tdeck_ui/Hardware/TDeck/TrackballNavigation.h b/lib/tdeck_ui/Hardware/TDeck/TrackballNavigation.h new file mode 100644 index 00000000..bfb97f4f --- /dev/null +++ b/lib/tdeck_ui/Hardware/TDeck/TrackballNavigation.h @@ -0,0 +1,92 @@ +// Copyright (c) 2024 microReticulum contributors +// SPDX-License-Identifier: MIT + +#pragma once + +#include +#include +#include + +namespace Hardware::TDeck { + +enum class NavigationDirection : uint8_t { UP, DOWN, LEFT, RIGHT }; + +struct NavigationRect { + int32_t x; + int32_t y; + int32_t width; + int32_t height; +}; + +struct NavigationCandidate { + int id; + NavigationRect rect; + bool visible; +}; + +/** + * Select the nearest focus target in the requested physical direction. + * + * Perpendicular distance is weighted so rolling vertically stays in the same + * visual column and rolling horizontally stays in the same row. This avoids + * depending on the order controls happened to be added to an LVGL group. + */ +inline int64_t directional_candidate_score( + const NavigationRect& focused, + const NavigationCandidate& candidate, + NavigationDirection direction) { + if (!candidate.visible) return std::numeric_limits::max(); + const int64_t focused_x = static_cast(focused.x) + focused.width / 2; + const int64_t focused_y = static_cast(focused.y) + focused.height / 2; + const int64_t candidate_x = static_cast(candidate.rect.x) + candidate.rect.width / 2; + const int64_t candidate_y = static_cast(candidate.rect.y) + candidate.rect.height / 2; + const int64_t dx = candidate_x - focused_x; + const int64_t dy = candidate_y - focused_y; + + int64_t primary = 0; + int64_t perpendicular = 0; + switch (direction) { + case NavigationDirection::UP: + if (dy >= 0) return std::numeric_limits::max(); + primary = -dy; + perpendicular = dx < 0 ? -dx : dx; + break; + case NavigationDirection::DOWN: + if (dy <= 0) return std::numeric_limits::max(); + primary = dy; + perpendicular = dx < 0 ? -dx : dx; + break; + case NavigationDirection::LEFT: + if (dx >= 0) return std::numeric_limits::max(); + primary = -dx; + perpendicular = dy < 0 ? -dy : dy; + break; + case NavigationDirection::RIGHT: + if (dx <= 0) return std::numeric_limits::max(); + primary = dx; + perpendicular = dy < 0 ? -dy : dy; + break; + } + return primary + perpendicular * 2; +} + +inline int select_directional_candidate( + const NavigationRect& focused, + const NavigationCandidate* candidates, + std::size_t candidate_count, + NavigationDirection direction) { + int best_id = -1; + int64_t best_score = std::numeric_limits::max(); + + for (std::size_t i = 0; i < candidate_count; ++i) { + const auto& candidate = candidates[i]; + const int64_t score = directional_candidate_score(focused, candidate, direction); + if (score < best_score || (score == best_score && candidate.id < best_id)) { + best_score = score; + best_id = candidate.id; + } + } + return best_id; +} + +} // namespace Hardware::TDeck diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetScreen.cpp b/lib/tdeck_ui/UI/LXMF/NomadNetScreen.cpp index c8cb4d8d..dbe1b398 100644 --- a/lib/tdeck_ui/UI/LXMF/NomadNetScreen.cpp +++ b/lib/tdeck_ui/UI/LXMF/NomadNetScreen.cpp @@ -53,6 +53,7 @@ NomadNetScreen::NomadNetScreen() { _content=lv_obj_create(_screen);lv_obj_set_size(_content,320,150);lv_obj_align(_content,LV_ALIGN_BOTTOM_MID,0,0); lv_obj_set_style_bg_color(_content,Theme::surface(),0);lv_obj_set_style_border_width(_content,0,0);lv_obj_set_style_pad_all(_content,8,0); lv_obj_set_flex_flow(_content,LV_FLEX_FLOW_COLUMN);lv_obj_set_flex_align(_content,LV_FLEX_ALIGN_START,LV_FLEX_ALIGN_START,LV_FLEX_ALIGN_START); + lv_obj_add_flag(_content,LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_scroll_dir(_content,LV_DIR_VER);lv_obj_set_scrollbar_mode(_content,LV_SCROLLBAR_MODE_AUTO); _directory=lv_obj_create(_screen);lv_obj_set_size(_directory,320,206);lv_obj_align(_directory,LV_ALIGN_BOTTOM_MID,0,0); lv_obj_set_style_bg_color(_directory,Theme::surface(),0);lv_obj_set_style_border_width(_directory,0,0);lv_obj_set_style_pad_all(_directory,7,0); diff --git a/tests/native/test_trackball_navigation.cpp b/tests/native/test_trackball_navigation.cpp new file mode 100644 index 00000000..b69af169 --- /dev/null +++ b/tests/native/test_trackball_navigation.cpp @@ -0,0 +1,89 @@ +#include +#include +#include + +#include "../../lib/tdeck_ui/Hardware/TDeck/TrackballNavigation.h" + +using Hardware::TDeck::NavigationCandidate; +using Hardware::TDeck::NavigationDirection; +using Hardware::TDeck::NavigationRect; +using Hardware::TDeck::select_directional_candidate; + +static int failures = 0; + +#define EXPECT_EQ(actual, expected) do { \ + const auto actual_value = (actual); \ + const auto expected_value = (expected); \ + if (actual_value != expected_value) { \ + std::cerr << __func__ << ": expected " << expected_value \ + << ", got " << actual_value << "\n"; \ + ++failures; \ + } \ +} while (0) + +static NavigationCandidate candidate(int id, int x, int y, int width, int height, + bool visible = true) { + return NavigationCandidate{id, NavigationRect{x, y, width, height}, visible}; +} + +static void horizontal_movement_stays_on_the_same_row() { + const NavigationRect focused{10, 10, 30, 20}; + const std::vector candidates{ + candidate(1, 50, 10, 30, 20), + candidate(2, 10, 50, 30, 20), + candidate(3, 100, 50, 30, 20), + }; + + EXPECT_EQ(select_directional_candidate(focused, candidates.data(), candidates.size(), + NavigationDirection::RIGHT), 1); +} + +static void vertical_movement_stays_in_the_same_column() { + const NavigationRect focused{100, 10, 30, 20}; + const std::vector candidates{ + candidate(1, 30, 50, 30, 20), + candidate(2, 100, 60, 30, 20), + candidate(3, 140, 45, 30, 20), + }; + + EXPECT_EQ(select_directional_candidate(focused, candidates.data(), candidates.size(), + NavigationDirection::DOWN), 2); +} + +static void every_direction_is_distinct() { + const NavigationRect focused{100, 100, 20, 20}; + const std::vector candidates{ + candidate(1, 100, 50, 20, 20), + candidate(2, 100, 150, 20, 20), + candidate(3, 50, 100, 20, 20), + candidate(4, 150, 100, 20, 20), + }; + + EXPECT_EQ(select_directional_candidate(focused, candidates.data(), candidates.size(), NavigationDirection::UP), 1); + EXPECT_EQ(select_directional_candidate(focused, candidates.data(), candidates.size(), NavigationDirection::DOWN), 2); + EXPECT_EQ(select_directional_candidate(focused, candidates.data(), candidates.size(), NavigationDirection::LEFT), 3); + EXPECT_EQ(select_directional_candidate(focused, candidates.data(), candidates.size(), NavigationDirection::RIGHT), 4); +} + +static void hidden_and_wrong_direction_candidates_are_ignored() { + const NavigationRect focused{100, 100, 20, 20}; + const std::vector candidates{ + candidate(1, 100, 50, 20, 20), + candidate(2, 100, 150, 20, 20, false), + candidate(3, 100, 90, 20, 20), + }; + + EXPECT_EQ(select_directional_candidate(focused, candidates.data(), candidates.size(), + NavigationDirection::DOWN), -1); +} + +int main() { + horizontal_movement_stays_on_the_same_row(); + vertical_movement_stays_in_the_same_column(); + every_direction_is_distinct(); + hidden_and_wrong_direction_candidates_are_ignored(); + + if (failures != 0) return EXIT_FAILURE; + std::cout << "4 trackball navigation tests passed\n"; + return EXIT_SUCCESS; +} diff --git a/tests/native/test_trackball_navigation.py b/tests/native/test_trackball_navigation.py new file mode 100644 index 00000000..981e3062 --- /dev/null +++ b/tests/native/test_trackball_navigation.py @@ -0,0 +1,68 @@ +"""Compile and execute portable directional trackball navigation regressions.""" + +from pathlib import Path +import shutil +import subprocess + + +ROOT = Path(__file__).resolve().parents[2] + + +def test_trackball_navigation(tmp_path): + cxx = shutil.which("clang++") or shutil.which("g++") + if not cxx: + raise RuntimeError("A C++17 compiler is required for this test") + + binary = tmp_path / "trackball_navigation" + result = subprocess.run( + [ + cxx, + "-std=c++17", + "-Wall", + "-Wextra", + "-Werror", + str(ROOT / "tests/native/test_trackball_navigation.cpp"), + "-o", + str(binary), + ], + cwd=ROOT, + text=True, + capture_output=True, + ) + assert result.returncode == 0, result.stdout + result.stderr + + run = subprocess.run([str(binary)], text=True, capture_output=True) + assert run.returncode == 0, run.stdout + run.stderr + assert "4 trackball navigation tests passed" in run.stdout + + +def test_trackball_and_nomadnet_directional_integration_contract(): + trackball = (ROOT / "lib/tdeck_ui/Hardware/TDeck/Trackball.cpp").read_text() + nomadnet = (ROOT / "lib/tdeck_ui/UI/LXMF/NomadNetScreen.cpp").read_text() + + assert "LV_KEY_NEXT" not in trackball + assert "LV_KEY_PREV" not in trackball + assert "NavigationDirection::UP" in trackball + assert "NavigationDirection::DOWN" in trackball + assert "NavigationDirection::LEFT" in trackball + assert "NavigationDirection::RIGHT" in trackball + assert "navigate_or_scroll" in trackball + # Modal dialogs temporarily move the trackball to an isolated group. The + # callback must navigate that assigned group, never the global default. + assert "_indev ? _indev->group : nullptr" in trackball + # Clipped/off-screen controls are not directional candidates. Scrolling + # reveals them before they can receive focus. + assert "lv_obj_is_visible(object)" in trackball + assert "if (!focused || object_is_hidden(focused))" in trackball + assert "if (!focused || !object_is_visible(focused))" not in trackball + # Generic scroll fallback is scoped to the assigned group's common UI + # subtree, so modal input cannot scroll the screen behind it. + assert "group_navigation_root(group, focused)" in trackball + assert "find_scroll_target(lv_scr_act()" not in trackball + assert "if (group->frozen) return false;" in trackball + assert "!lv_obj_has_state(object, LV_STATE_DISABLED)" in trackball + + # NomadNet's document viewport must remain a bounded vertical scroll target; + # trackball movement reaches it through the generic LVGL navigation helper. + assert "lv_obj_set_scroll_dir(_content,LV_DIR_VER)" in nomadnet + assert "LV_OBJ_FLAG_SCROLLABLE" in nomadnet