Fix touchscreen navigation and add runtime touch diagnostics

This commit is contained in:
mikecarper committed 2026-09-09 17:38:41 -07:00
1 parent 23fe7c8a9d
commit 14796e6573
32 files changed
+1785 -123

No files matched your search

+4
View File
@@ -32,6 +32,8 @@ constexpr ColorVal WARNING_TEXT = rgb565(248, 176, 72);
// semantic slots by their RGB565 value before selecting a palette entry.
constexpr ColorVal POPUP_BACKGROUND = rgb565(20, 48, 70);
constexpr ColorVal ACCENT = rgb565(64, 176, 240);
constexpr ColorVal TOUCH_OUTLINE = rgb565(255, 255, 0);
constexpr ColorVal TOUCH_PRESSED = rgb565(0, 255, 0);
enum IndexedColor : uint8_t {
INDEX_BACKGROUND = 0,
@@ -41,6 +43,8 @@ enum IndexedColor : uint8_t {
INDEX_WARNING_TEXT = 4,
INDEX_POPUP_BACKGROUND = 5,
INDEX_ACCENT = 6,
INDEX_TOUCH_PRESSED = 8,
INDEX_TOUCH_OUTLINE = 11,
};
} // namespace color_theme
@@ -16,10 +16,14 @@ struct CompanionTransportSelectorLayout {
int title_y;
int prompt_y;
bool show_title;
int side_nav_width;
};
inline CompanionTransportSelectorLayout makeCompanionTransportSelectorLayout(
int width, int height) {
int width, int height, int center_zone_percent = 100) {
if (center_zone_percent < 0) center_zone_percent = 0;
if (center_zone_percent > 100) center_zone_percent = 100;
const int side_nav_width = width * ((100 - center_zone_percent) / 2) / 100;
const bool tall_display = height >= 96;
const int margin = 2;
const int gap = 4;
@@ -28,15 +32,17 @@ inline CompanionTransportSelectorLayout makeCompanionTransportSelectorLayout(
int box_height = prompt_y - box_y - (tall_display ? 3 : 8);
if (box_height > 100) box_height = 100;
if (box_height < 20) box_height = 20;
const int box_width = (width - margin * 2 - gap) / 2;
const int bluetooth_x = margin + box_width + gap;
const int available = width - side_nav_width * 2 - margin * 2 - gap;
const int box_width = available > 0 ? available / 2 : 0;
const int bluetooth_x = side_nav_width + margin + box_width + gap;
return {
{margin, box_y, box_width, box_height},
{side_nav_width + margin, box_y, box_width, box_height},
{bluetooth_x, box_y, box_width, box_height},
tall_display ? 14 : 0,
prompt_y,
tall_display,
side_nav_width,
};
}
+28
View File
@@ -0,0 +1,28 @@
#pragma once
#include <stdint.h>
namespace mesh {
namespace ui {
// Map the rotated physical panel to the logical UI, exactly once. Both the
// native canvas and the zoomed fallback fill this same panel; their internal
// render resolution must not change the hit targets. Reject invalid samples
// before division (e.g. -1 / 3 would otherwise become a valid edge tap).
inline bool panelTouchToLogical(int panel_x, int panel_y,
int panel_width, int panel_height,
int logical_width, int logical_height,
int* x, int* y) {
if (x == nullptr || y == nullptr) return false;
*x = *y = -1;
if (panel_width <= 0 || panel_height <= 0
|| logical_width <= 0 || logical_height <= 0
|| panel_x < 0 || panel_x >= panel_width
|| panel_y < 0 || panel_y >= panel_height) return false;
*x = static_cast<int>(static_cast<int64_t>(panel_x) * logical_width / panel_width);
*y = static_cast<int>(static_cast<int64_t>(panel_y) * logical_height / panel_height);
return true;
}
} // namespace ui
} // namespace mesh
+12 -9
View File
@@ -1,6 +1,7 @@
#include "LGFXDisplay.h"
#include "ColorTheme.h"
#include "IndicatorRenderProfile.h"
#include "DisplayTouchCoordinates.h"
#include <lgfx/utility/lgfx_qrcode.h>
#ifndef DISPLAY_ROTATION
@@ -97,7 +98,9 @@ static const uint32_t UI_PALETTE[16] = {
mesh::ui::color_theme::rgb888(mesh::ui::color_theme::POPUP_BACKGROUND),
mesh::ui::color_theme::rgb888(mesh::ui::color_theme::ACCENT),
0xE53935,
0x43A047, 0x1E88E5, 0x8E24AA, 0xFDD835,
mesh::ui::color_theme::rgb888(mesh::ui::color_theme::TOUCH_PRESSED),
0x1E88E5, 0x8E24AA,
mesh::ui::color_theme::rgb888(mesh::ui::color_theme::TOUCH_OUTLINE),
0x6D4C41, 0x00ACC1, 0xF06292, 0xFF00FF,
};
@@ -497,16 +500,14 @@ void LGFXDisplay::endFrame() {
}
bool LGFXDisplay::getTouch(int* x, int* y) {
if (x == nullptr || y == nullptr) return false;
*x = *y = -1;
lgfx::v1::touch_point_t point = {};
if (display->getTouch(&point) == 0) return false;
if (_outputZoom * _coordinateScale != 1.0f) {
*x = point.x / (_outputZoom * _coordinateScale);
*y = point.y / (_outputZoom * _coordinateScale);
} else {
*x = point.x;
*y = point.y;
}
return *x >= 0 && *x < width() && *y >= 0 && *y < height();
// LovyanGFX has already applied panel rotation and touch calibration. Use
// its live output dimensions, not the sprite resolution or build flags.
return mesh::ui::panelTouchToLogical(point.x, point.y,
display->width(), display->height(), width(), height(), x, y);
}
bool LGFXDisplay::installRuntimeFont(uint8_t* data, size_t size) {
@@ -548,6 +549,8 @@ uint32_t LGFXDisplay::renderColor(ColorVal color) const {
if (color == UIColor::warning_txt) return INDEX_WARNING_TEXT;
if (color == UIColor::popup_bkg) return INDEX_POPUP_BACKGROUND;
if (color == UIColor::corp_blue) return INDEX_ACCENT;
if (color == TOUCH_OUTLINE) return INDEX_TOUCH_OUTLINE;
if (color == TOUCH_PRESSED) return INDEX_TOUCH_PRESSED;
return color & 0x0F;
#else
return color;
+2 -3
View File
@@ -1,6 +1,5 @@
#include "MomentaryButton.h"
#define MULTI_CLICK_WINDOW_MS 280
#define BUTTON_DEBOUNCE_MS 25
#if defined(NRF52_PLATFORM) && \
@@ -31,7 +30,7 @@ MomentaryButton::MomentaryButton(int8_t pin, int long_press_millis, bool reverse
_press_active = false;
_click_count = 0;
_last_click_time = 0;
_multi_click_window = multiclick ? MULTI_CLICK_WINDOW_MS : 0;
_multi_click_window = multiclick ? MOMENTARY_BUTTON_MULTI_CLICK_MS : 0;
_pending_click = false;
_candidate_level = prev;
_candidate_since = 0;
@@ -50,7 +49,7 @@ MomentaryButton::MomentaryButton(int8_t pin, int long_press_millis, int analog_t
_press_active = false;
_click_count = 0;
_last_click_time = 0;
_multi_click_window = MULTI_CLICK_WINDOW_MS;
_multi_click_window = MOMENTARY_BUTTON_MULTI_CLICK_MS;
_pending_click = false;
_candidate_level = prev;
_candidate_since = 0;
+4
View File
@@ -9,6 +9,10 @@
#define BUTTON_EVENT_TRIPLE_CLICK 4
#define BUTTON_EVENT_QUADRUPLE_CLICK 5
#ifndef MOMENTARY_BUTTON_MULTI_CLICK_MS
#define MOMENTARY_BUTTON_MULTI_CLICK_MS 280
#endif
#ifndef MOMENTARY_BUTTON_WAKE_HOLD_MS
#define MOMENTARY_BUTTON_WAKE_HOLD_MS 0
#endif
+35
View File
@@ -1,6 +1,7 @@
#pragma once
#include "DisplayDriver.h"
#include "TouchInput.h"
namespace mesh {
namespace ui {
@@ -52,5 +53,39 @@ inline void drawButtonReaderHint(DisplayDriver& text,
}
}
// Enlarge the header exit target without enlarging text. Reserve this same
// height for content, touch detection and diagnostic outlines.
inline int readerTouchHeaderHeight(int content_height) {
return content_height + 8 < 24 ? 24 : content_height + 8;
}
// Touch readers use five equal, full-width hit cells. A 24-unit row is 72
// physical pixels high on both Indicator render profiles; keep the same font.
inline TouchNavigationBar makeReaderTouchBar(DisplayDriver& text, int bottom,
int exit_height = 0) {
TouchNavigationBar bar;
const int line_height = text.textLineHeight();
bar.height = line_height + 4 < 24 ? 24 : line_height + 4;
if (bar.height > bottom) bar.height = bottom;
bar.top = bottom - bar.height;
bar.exit_height = exit_height < 0 ? 0 : exit_height > bar.top ? bar.top : exit_height;
return bar;
}
inline void drawReaderTouchBar(DisplayDriver& text, const TouchNavigationBar& bar) {
static const char* const labels[] = {"4<<", "2<", ">1", ">>3", "X"};
text.setColor(UIColor::window_bkg);
text.fillRect(0, bar.top, text.width(), bar.height);
text.setColor(UIColor::corp_blue);
text.drawRect(0, bar.top, text.width(), 1);
const int line_height = text.textLineHeight();
const int y = bar.top + (bar.height - line_height) / 2;
for (int cell = 0; cell < 5; ++cell) {
const int left = text.width() * cell / 5;
const int right = text.width() * (cell + 1) / 5;
text.drawTextCentered((left + right) / 2, y, labels[cell]);
}
}
} // namespace ui
} // namespace mesh
+118
View File
@@ -0,0 +1,118 @@
#pragma once
#include "ColorTheme.h"
#include "TouchInput.h"
namespace mesh {
namespace ui {
struct TouchDebugArea {
int x, y, width, height;
TouchAction action;
};
struct TouchDebugAreas {
TouchDebugArea areas[10];
int count = 0;
void add(int x, int y, int width, int height, TouchAction action) {
if (width > 0 && height > 0 && count < 10)
areas[count++] = {x, y, width, height, action};
}
};
// Visual (already unmirrored), logical coordinates. The UI supplies the same
// current screen geometry to this overlay and TouchInput::update(). Tests
// compare every pixel in these rectangles with the actual stationary actions.
inline TouchDebugAreas makeTouchDebugAreas(int width, int height,
int center_zone_percent, bool bottom_selector,
const TouchSplitSelector* split, const TouchNavigationBar* reader) {
TouchDebugAreas result;
if (width <= 0 || height <= 0) return result;
if (reader != nullptr) {
result.add(0, 0, width, reader->exit_height, TouchAction::Select);
result.add(0, reader->exit_height, width / 2,
reader->top - reader->exit_height, TouchAction::Previous);
result.add(width / 2, reader->exit_height, width - width / 2,
reader->top - reader->exit_height, TouchAction::Next);
const TouchAction actions[] = {TouchAction::VerticalPrevious,
TouchAction::Previous, TouchAction::Next,
TouchAction::VerticalNext, TouchAction::Select};
for (int cell = 0; cell < 5; ++cell) {
const int left = width * cell / 5;
const int right = width * (cell + 1) / 5;
result.add(left, reader->top, right - left, reader->height, actions[cell]);
}
// Retain truthfulness for layouts with space below the footer as well.
const int below = reader->top + reader->height;
result.add(0, below, width / 2, height - below, TouchAction::Previous);
result.add(width / 2, below, width - width / 2, height - below, TouchAction::Next);
return result;
}
const int body_bottom = bottom_selector ? height * 3 / 4 : height;
if (bottom_selector) {
result.add(0, body_bottom, width / 4, height - body_bottom,
TouchAction::VerticalPrevious);
result.add(width * 3 / 4, body_bottom, width - width * 3 / 4,
height - body_bottom, TouchAction::VerticalNext);
}
if (split != nullptr) {
result.add(0, 0, split->side_nav_width, body_bottom, TouchAction::Previous);
result.add(width - split->side_nav_width, 0, split->side_nav_width,
body_bottom, TouchAction::Next);
const int bottom = split->top_y + split->height < body_bottom
? split->top_y + split->height : body_bottom;
result.add(split->left_x, split->top_y, split->left_width,
bottom - split->top_y, TouchAction::SelectLeft);
result.add(split->right_x, split->top_y, split->right_width,
bottom - split->top_y, TouchAction::SelectRight);
return result;
}
const int side_percent = (100 - center_zone_percent) / 2;
const int left = width * side_percent / 100;
const int right = width - left;
result.add(0, 0, left, body_bottom, TouchAction::Previous);
result.add(left, 0, right - left, body_bottom, TouchAction::Select);
result.add(right, 0, width - right, body_bottom, TouchAction::Next);
return result;
}
inline int touchDebugAreaAt(const TouchDebugAreas& areas, int x, int y) {
for (int i = 0; i < areas.count; ++i) {
const auto& r = areas.areas[i];
if (x >= r.x && x < r.x + r.width && y >= r.y && y < r.y + r.height)
return i;
}
return -1;
}
inline void drawTouchDebugAreas(DisplayDriver& display, const TouchDebugAreas& areas,
int pressed_x = -1, int pressed_y = -1) {
const int pressed = touchDebugAreaAt(areas, pressed_x, pressed_y);
for (int i = 0; i < areas.count; ++i) {
display.setColor(i == pressed ? color_theme::TOUCH_PRESSED : color_theme::TOUCH_OUTLINE);
const auto& area = areas.areas[i];
const int left = area.x < 0 ? 0 : area.x;
const int top = area.y < 0 ? 0 : area.y;
const int right = area.x + area.width < display.width()
? area.x + area.width - 1 : display.width() - 1;
const int bottom = area.y + area.height < display.height()
? area.y + area.height - 1 : display.height() - 1;
if (left > right || top > bottom) continue;
// One logical pixel per dot, two pixels of gap. Use the normal drawing
// path so native 480px and scaled 320px canvases show identical bounds.
for (int x = left; x <= right; x += 3) {
display.fillRect(x, top, 1, 1);
display.fillRect(x, bottom, 1, 1);
}
for (int y = top; y <= bottom; y += 3) {
display.fillRect(left, y, 1, 1);
display.fillRect(right, y, 1, 1);
}
display.fillRect(right, bottom, 1, 1);
}
}
} // namespace ui
} // namespace mesh
+71 -3
View File
@@ -23,6 +23,16 @@ struct TouchSplitSelector {
int right_width;
int top_y;
int height;
// Full-height page-navigation strips, zero for legacy selector layouts.
int side_nav_width;
};
// Reader footer has five controls (<<, <, >, >>, X); the top header exits.
// All bounds are logical UI units supplied by the screen that draws them.
struct TouchNavigationBar {
int top = 0;
int height = 0;
int exit_height = 0;
};
// Converts one-finger touch samples into actions understood by the
@@ -31,6 +41,7 @@ struct TouchSplitSelector {
class TouchInput {
bool _active = false;
bool _reverse_swipes;
bool _reverse_vertical_swipes;
bool _separate_vertical_swipes;
bool _mirror_tap_x;
uint8_t _center_zone_percent;
@@ -50,17 +61,27 @@ class TouchInput {
TouchAction verticalSwipeAction(bool negative_direction) const {
if (!_separate_vertical_swipes) return swipeAction(negative_direction);
const bool next = negative_direction != _reverse_swipes;
const bool next = negative_direction != _reverse_vertical_swipes;
return next ? TouchAction::VerticalNext
: TouchAction::VerticalPrevious;
}
public:
int centerZonePercent() const { return _center_zone_percent; }
bool hasSeparateVerticalSwipes() const { return _separate_vertical_swipes; }
explicit TouchInput(bool reverse_swipes = false,
bool separate_vertical_swipes = false,
uint8_t center_zone_percent = 34,
bool mirror_tap_x = false)
: TouchInput(reverse_swipes, separate_vertical_swipes,
center_zone_percent, mirror_tap_x, reverse_swipes) {}
TouchInput(bool reverse_swipes, bool separate_vertical_swipes,
uint8_t center_zone_percent, bool mirror_tap_x,
bool reverse_vertical_swipes)
: _reverse_swipes(reverse_swipes),
_reverse_vertical_swipes(reverse_vertical_swipes),
_separate_vertical_swipes(separate_vertical_swipes),
_mirror_tap_x(mirror_tap_x),
_center_zone_percent(center_zone_percent > 100
@@ -69,7 +90,8 @@ public:
TouchAction update(bool touched, int x, int y, int width, int height,
bool bottom_selector = false,
const TouchSplitSelector* split_selector = nullptr) {
const TouchSplitSelector* split_selector = nullptr,
const TouchNavigationBar* navigation_bar = nullptr) {
if (touched) {
_release_samples = 0;
if (!_active) {
@@ -98,7 +120,7 @@ public:
// boxes are unambiguous stationary targets, and rejecting a quick contact
// makes a normal tap disappear when it lands within one polling interval.
if (_touch_samples == 0 || width <= 0 || height <= 0
|| (_touch_samples < 2 && split_selector == nullptr)) {
|| (_touch_samples < 2 && split_selector == nullptr && navigation_bar == nullptr)) {
return TouchAction::None;
}
@@ -109,10 +131,19 @@ public:
const int horizontal_threshold = width / 8 > 8 ? width / 8 : 8;
const int vertical_threshold = height / 8 > 8 ? height / 8 : 8;
// Explicit header/footer controls are tap targets, not swipe surfaces.
// Gestures begun in the body may cross them without activating a button.
const bool in_reader_chrome = navigation_bar != nullptr
&& (_start_y < navigation_bar->exit_height
|| (_start_y >= navigation_bar->top
&& _start_y < navigation_bar->top + navigation_bar->height));
if (abs_dx >= abs_dy && abs_dx >= horizontal_threshold) {
if (in_reader_chrome) return TouchAction::None;
return swipeAction(dx < 0);
}
if (abs_dy > abs_dx && abs_dy >= vertical_threshold) {
if (in_reader_chrome) return TouchAction::None;
return verticalSwipeAction(dy < 0);
}
@@ -122,6 +153,39 @@ public:
// after movement has been ruled out so it cannot alter swipe detection.
const int tap_x = _mirror_tap_x ? width - 1 - _start_x : _start_x;
// Explicit reader controls take priority over the older, broad bottom
// channel-selector zone. Swipes still win over every stationary target.
if (navigation_bar != nullptr) {
if (tap_x < 0 || tap_x >= width || _start_y < 0 || _start_y >= height)
return TouchAction::None;
if (_start_y < navigation_bar->exit_height)
return _last_y >= 0 && _last_y < navigation_bar->exit_height
? TouchAction::Select : TouchAction::None;
if (_start_y >= navigation_bar->top
&& _start_y < navigation_bar->top + navigation_bar->height) {
if (_last_y < navigation_bar->top
|| _last_y >= navigation_bar->top + navigation_bar->height)
return TouchAction::None;
const int end_x = _mirror_tap_x ? width - 1 - _last_x : _last_x;
if (end_x < 0 || end_x >= width) return TouchAction::None;
const TouchAction actions[] = {TouchAction::VerticalPrevious,
TouchAction::Previous, TouchAction::Next,
TouchAction::VerticalNext, TouchAction::Select};
for (int cell = 0; cell < 5; ++cell) {
const int left = width * cell / 5;
const int right = width * (cell + 1) / 5;
if (tap_x < right)
return end_x >= left && end_x < right ? actions[cell] : TouchAction::None;
}
}
// Missing an arrow must never fall through to the ordinary center tap
// (Select), which closes the reader. Body halves move pages instead.
if (_touch_samples < 2) return TouchAction::None;
return tap_x < width / 2 ? TouchAction::Previous : TouchAction::Next;
}
// A quick single sample is accepted only inside an explicit target.
if (_touch_samples < 2 && split_selector == nullptr) return TouchAction::None;
// Message screens may reserve the otherwise empty ends of their bottom
// status bar as forgiving arrow buttons. Keep the label in the middle
// inert so an imprecise arrow tap cannot accidentally close the screen.
@@ -140,6 +204,10 @@ public:
// selections. Swipe detection above retains priority, so page navigation
// gestures cannot accidentally activate either choice.
if (split_selector != nullptr) {
if (tap_x >= 0 && tap_x < split_selector->side_nav_width)
return TouchAction::Previous;
if (tap_x >= width - split_selector->side_nav_width && tap_x < width)
return TouchAction::Next;
if (_start_y < split_selector->top_y
|| _start_y >= split_selector->top_y + split_selector->height) {
return TouchAction::None;