[verified] Fix directional trackball navigation

This commit is contained in:
torlando-agent[bot]
2026-08-06 01:35:55 +00:00
parent e2daefda50
commit d8aa352cd9
5 changed files with 466 additions and 5 deletions
+216 -5
View File
@@ -2,11 +2,13 @@
// SPDX-License-Identifier: MIT
#include "Trackball.h"
#include "TrackballNavigation.h"
#ifdef ARDUINO
#include <microReticulum/Log.h>
#include <driver/gpio.h>
#include <limits>
// 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_obj_t**>(_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_obj_t**>(_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_obj_t**>(_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_obj_t**>(_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<int64_t>::max();
lv_obj_t** node = static_cast<lv_obj_t**>(_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_obj_t**>(_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;
@@ -0,0 +1,92 @@
// Copyright (c) 2024 microReticulum contributors
// SPDX-License-Identifier: MIT
#pragma once
#include <cstdint>
#include <limits>
#include <cstddef>
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<int64_t>::max();
const int64_t focused_x = static_cast<int64_t>(focused.x) + focused.width / 2;
const int64_t focused_y = static_cast<int64_t>(focused.y) + focused.height / 2;
const int64_t candidate_x = static_cast<int64_t>(candidate.rect.x) + candidate.rect.width / 2;
const int64_t candidate_y = static_cast<int64_t>(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<int64_t>::max();
primary = -dy;
perpendicular = dx < 0 ? -dx : dx;
break;
case NavigationDirection::DOWN:
if (dy <= 0) return std::numeric_limits<int64_t>::max();
primary = dy;
perpendicular = dx < 0 ? -dx : dx;
break;
case NavigationDirection::LEFT:
if (dx >= 0) return std::numeric_limits<int64_t>::max();
primary = -dx;
perpendicular = dy < 0 ? -dy : dy;
break;
case NavigationDirection::RIGHT:
if (dx <= 0) return std::numeric_limits<int64_t>::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<int64_t>::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
+1
View File
@@ -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);
@@ -0,0 +1,89 @@
#include <cstdlib>
#include <iostream>
#include <vector>
#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<NavigationCandidate> 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<NavigationCandidate> 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<NavigationCandidate> 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<NavigationCandidate> 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;
}
+68
View File
@@ -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