mirror of
https://github.com/torlando-tech/pyxis.git
synced 2026-08-21 18:19:48 +00:00
[verified] Fix directional trackball navigation
This commit is contained in:
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user