Improve Indicator transport touch UI

This commit is contained in:
mikecarper committed 2026-08-31 15:11:11 -07:00
1 parent 1629725d7d
commit 7791f3f8c9
10 files changed
+212 -38

No files matched your search

+35 -15
View File
@@ -86,16 +86,17 @@ static void drawCompanionTransportChoice(DisplayDriver& display,
}
const bool large_transport_text = display.height() >= 96;
display.setTextSize(large_transport_text ? 2 : 1);
display.setTextSize(large_transport_text ? 3 : 1);
const bool show_status_label = height >= 44;
const int label_y = show_status_label
? y + (large_transport_text ? 9 : 12)
? y + (large_transport_text ? 17 : 12)
: y + (height - 8) / 2;
display.drawTextCentered(x + width / 2, label_y, label);
if (show_status_label && (active || selected)) {
display.setTextSize(large_transport_text ? 2 : 1);
display.drawTextCentered(
x + width / 2,
y + height - (large_transport_text ? 23 : 17),
y + height - (large_transport_text ? 25 : 17),
active ? "ACTIVE"
: (large_transport_text ? "NEXT" : "NEXT BOOT"));
}
@@ -375,13 +376,27 @@ public:
if (_page == HomePage::FIRST) {
display.setColor(UIColor::primary_txt);
#ifdef UI_DEDICATED_PAIRING_BLOCK
const bool expanded_home =
mesh::ui::usesExpandedCompanionHomeTypography(
display.width(), display.height(),
display.renderWidth(), display.renderHeight());
sprintf(tmp, expanded_home ? "INBOX %d" : "INBOX: %d",
_task->getPreviewCount());
display.setTextSize(expanded_home ? 3 : 2);
if (expanded_home && display.getTextWidth(tmp) > display.width() - 4) {
display.setTextSize(2);
}
#else
display.setTextSize(2);
sprintf(tmp, "INBOX: %d", _task->getPreviewCount());
#endif
display.drawTextCentered(display.width() / 2, 22, tmp);
#ifdef UI_DEDICATED_PAIRING_BLOCK
const mesh::ui::CompanionHomeLayout layout =
mesh::ui::makeLargeCompanionHomeLayout(display.width(),
display.height());
display.height(),
expanded_home);
// These are distinct repaint regions. In particular, removing or
// replacing a pairing PIN cannot leave old glyphs behind, and long
@@ -390,10 +405,11 @@ public:
mesh::ui::clearDisplayRegion(display, layout.info);
mesh::ui::clearDisplayRegion(display, layout.pairing);
display.setTextSize(1);
display.setTextSize(expanded_home ? 2 : 1);
display.setColor(UIColor::secondary_txt);
mesh::ui::drawTextCenteredEllipsized(
display, layout.info, layout.instruction_y, "tap center: inbox");
display, layout.info, layout.instruction_y,
expanded_home ? "tap inbox" : "tap center: inbox");
#ifdef WIFI_SSID
if (isCompanionWiFiEnabled()) {
@@ -403,6 +419,10 @@ public:
} else {
strcpy(tmp, "WiFi: OFF");
}
display.setTextSize(expanded_home ? 2 : 1);
if (display.getTextWidth(tmp) > layout.info.width) {
display.setTextSize(1);
}
mesh::ui::drawTextCenteredEllipsized(
display, layout.info, layout.network_y, tmp);
#endif
@@ -415,11 +435,11 @@ public:
int pairing_value_size = 2;
char pairing_pin[16];
if (bluetooth_connected) {
pairing_label = "BLUETOOTH";
pairing_label = expanded_home ? "BLE" : "BLUETOOTH";
pairing_value = "CONNECTED";
} else if (mesh::ui::shouldDisplayBluetoothPairingPin(
bluetooth_enabled, bluetooth_connected, bluetooth_pin)) {
pairing_label = "BLUETOOTH PIN";
pairing_label = expanded_home ? "BLE PIN" : "BLUETOOTH PIN";
snprintf(pairing_pin, sizeof(pairing_pin), "%06u",
(unsigned int)bluetooth_pin);
pairing_value = pairing_pin;
@@ -430,7 +450,7 @@ public:
display.setColor(UIColor::title_bkg);
mesh::ui::clearDisplayRegion(display, layout.pairing);
display.setColor(UIColor::title_txt);
display.setTextSize(1);
display.setTextSize(expanded_home ? 2 : 1);
mesh::ui::drawTextCenteredEllipsized(
display, layout.pairing, layout.pairing_label_y, pairing_label);
display.setTextSize(pairing_value_size);
@@ -591,10 +611,10 @@ public:
mesh::ui::makeCompanionTransportSelectorLayout(
display.width(), display.height());
// Size 2 is already a substantial increase. Keep this page independent
// of the additional native-480 text boost so ACTIVE still fits inside
// either half-screen choice; the compact status strip above was drawn
// before this point and is unaffected.
// Keep this page independent of the additional native-480 text boost:
// the size-3 choice names and size-2 status already consume nearly the
// full half-screen width, and should remain equally readable in the
// memory-saving 320 profile.
display.setCompactText(true);
if (layout.show_title) {
display.setColor(UIColor::primary_txt);
@@ -605,14 +625,14 @@ public:
drawCompanionTransportChoice(
display, layout.wifi.x, layout.wifi.y,
layout.wifi.width, layout.wifi.height,
"Wi-Fi", wifi_active, wifi_selected);
"WiFi", wifi_active, wifi_selected);
drawCompanionTransportChoice(
display, layout.bluetooth.x, layout.bluetooth.y,
layout.bluetooth.width, layout.bluetooth.height,
"BLE", !wifi_active, !wifi_selected);
display.setColor(UIColor::secondary_txt);
display.setTextSize(display.height() >= 96 ? 2 : 1);
display.setTextSize(1);
#ifdef HAS_TOUCH
display.drawTextCentered(
display.width() / 2, layout.prompt_y, "tap a box");
+24 -1
View File
@@ -30,6 +30,17 @@ struct CompactCompanionPairingLayout {
int pairing_value_y;
};
// Full-size Indicator builds keep the established 160x160 logical coordinate
// system even when they obtain a native 480x480 render buffer. Expose that
// distinction to the home page so it can spend the extra pixel density on
// larger, deliberately reflowed text without changing the 320 BLE profile or
// unrelated displays that happen to have a tall viewport.
inline bool usesExpandedCompanionHomeTypography(
int width, int height, int render_width, int render_height) {
return width == 160 && height == 160
&& render_width == 480 && render_height == 480;
}
// The common Companion OLED/TFT viewport is 128x64. Its normal instruction
// and network rows occupy the same lower area needed by the Bluetooth status,
// so an active PIN/connection replaces those rows with this compact block.
@@ -56,7 +67,19 @@ inline CompactCompanionPairingLayout makeCompactCompanionPairingLayout(
// fifths of the display height. The Indicator renders a 160x160 logical UI,
// for which this produces a 48-pixel-tall block centered at y=128.
inline CompanionHomeLayout makeLargeCompanionHomeLayout(int width,
int height) {
int height,
bool expanded = false) {
if (expanded && width == 160 && height == 160) {
return {
{2, 51, 156, 46},
{8, 100, 144, 58},
52,
76,
102,
126,
};
}
// Ordinary one-line status needs almost the full logical width when the
// Indicator's recovered mono font is active. The PIN block gets the larger
// inset so it remains visibly distinct from the page background.
@@ -23,10 +23,10 @@ inline CompanionTransportSelectorLayout makeCompanionTransportSelectorLayout(
const bool tall_display = height >= 96;
const int margin = 2;
const int gap = 4;
const int box_y = tall_display ? 40 : 20;
const int prompt_y = height - (tall_display ? 22 : 11);
int box_height = prompt_y - box_y - 8;
if (box_height > 72) box_height = 72;
const int box_y = tall_display ? 36 : 20;
const int prompt_y = height - (tall_display ? 20 : 11);
int box_height = prompt_y - box_y - (tall_display ? 6 : 8);
if (box_height > 98) box_height = 98;
if (box_height < 20) box_height = 20;
const int box_width = (width - margin * 2 - gap) / 2;
const int bluetooth_x = margin + box_width + gap;
+7 -3
View File
@@ -89,9 +89,13 @@ public:
_active = false;
_release_samples = 0;
// A contact seen only once has no measurable direction. Ignoring it is
// safer than treating the endpoint of a fast swipe as an opposite tap.
if (_touch_samples < 2 || width <= 0 || height <= 0) {
// A contact seen only once has no measurable direction. Keep ignoring it
// on ordinary pages so the endpoint of a fast swipe cannot become an
// opposite tap. An explicit split selector is different: its bounded
// 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)) {
return TouchAction::None;
}
+1 -1
View File
@@ -53,7 +53,7 @@ does not reflect the GoogleTest count -- run the built binary directly
| `test_mqtt_prefs_atomic_store` | `src/helpers/MQTTPrefsAtomicStore.h` | transactional MQTT writes and legacy `/node_prefs` handoff; exact short-write detection; begin/finish/rename failure cleanup; original-file preservation |
| `test_mqtt_prefs_json_import` | `src/helpers/MQTTPrefsJsonImport.h`, `src/helpers/MQTTPrefsSerializer.h` | one-time observer `/mqtt.json` v1 import grammar, strict schema/repair rules, binary-first precedence, future/artifact preservation, display-tail encoding, and atomic commit failure routing |
| `test_display_viewport` | `src/helpers/ui/DisplayViewport.h`, `src/helpers/ui/DisplayFrameSignature.h` | portrait logical-to-physical mapping, span coverage, fitted-width conversion, text scaling, and visible-frame signatures |
| `test_display_driver` | `src/helpers/ui/DisplayDriver.h`, `DisplayTextLayout.h`, `CompanionHomeLayout.h` | UTF-8-safe ellipsis/wrapping; maximum SSID and long setup-address bounds; Indicator home info/pairing separation and 4/5-height PIN placement; compact 128x64 pairing replacement; stale-region clearing |
| `test_display_driver` | `src/helpers/ui/DisplayDriver.h`, `DisplayTextLayout.h`, `CompanionHomeLayout.h` | UTF-8-safe ellipsis/wrapping; maximum SSID and long setup-address bounds; Indicator 320/native-480 home info/pairing separation and enlarged-text placement; compact 128x64 pairing replacement; stale-region clearing |
| `test_indicator_font_stage_v2_protocol` | `src/helpers/IndicatorFontStageV2Protocol.h` | fail-closed STAGEV2 negotiation and legacy fallback; exact cumulative ACK parsing; 512-byte block boundaries, short final blocks, and real font-asset transfer geometry |
| `test_radio_activity_window` | `src/helpers/RadioActivityWindow.h` | rolling minute buckets, derived rates, expiry, warm-up, silence, saturation, and `millis()` rollover |
| `test_observer_dashboard` | `src/helpers/ui/ObserverDashboard.h` | landscape and portrait dashboard layout bounds, compact formatting, graph scaling, row signatures, and partial repaint policy |
+47 -12
View File
@@ -20,8 +20,8 @@ int main() {
if (!layout.show_title) return 1;
if (layout.wifi.x != 2 || layout.wifi.width != 76) return 2;
if (layout.bluetooth.x != 82 || layout.bluetooth.width != 76) return 3;
if (layout.wifi.y != 40 || layout.wifi.height != 72) return 4;
if (layout.title_y != 17 || layout.prompt_y != 138) return 5;
if (layout.wifi.y != 36 || layout.wifi.height != 98) return 4;
if (layout.title_y != 17 || layout.prompt_y != 140) return 5;
const auto small = mesh::ui::makeCompanionTransportSelectorLayout(128, 64);
if (small.show_title) return 6;
@@ -56,15 +56,21 @@ int main() {
)
self.assertEqual(run_result.returncode, 0, run_result.stderr)
logical_glyph_width = 6 * 2
logical_glyph_height = 8 * 2
for text in ("Wi-Fi", "BLE", "ACTIVE", "NEXT"):
self.assertLessEqual(len(text) * logical_glyph_width, 76)
choice_glyph_width = 6 * 3
choice_glyph_height = 8 * 3
for text in ("WiFi", "BLE"):
self.assertLessEqual(len(text) * choice_glyph_width, 76)
status_glyph_width = 6 * 2
status_glyph_height = 8 * 2
for text in ("ACTIVE", "NEXT"):
self.assertLessEqual(len(text) * status_glyph_width, 76)
title_glyph_width = 6 * 2
for text in ("TRANSPORT", "tap a box"):
self.assertLessEqual(len(text) * logical_glyph_width, 160)
self.assertLessEqual(17 + logical_glyph_height, 40)
self.assertLessEqual(89 + logical_glyph_height, 112)
self.assertLessEqual(138 + logical_glyph_height, 160)
self.assertLessEqual(len(text) * title_glyph_width, 160)
self.assertLessEqual(17 + 8 * 2, 36)
self.assertLessEqual(53 + choice_glyph_height, 134)
self.assertLessEqual(109 + status_glyph_height, 134)
self.assertLessEqual(140 + 8, 160)
def test_touch_split_selector_distinguishes_taps_from_swipes(self):
source = r'''
@@ -113,6 +119,35 @@ int main() {
input.update(true, 130, 68, 160, 160, false, &selector);
if (release(input, &selector) != TouchAction::Next) return 6;
// A quick selector tap may occupy only one 25 ms poll. Its bounded target
// remains unambiguous after the normal two-sample release debounce.
input.update(true, 30, 120, 160, 160, false, &selector);
if (release(input, &selector) != TouchAction::SelectLeft) return 7;
input.update(true, 130, 120, 160, 160, false, &selector);
if (release(input, &selector) != TouchAction::SelectRight) return 8;
// The gap, title, and exclusive bottom edge remain inert even for a quick
// contact, while the last pixel inside the taller box is selectable.
input.update(true, 80, 120, 160, 160, false, &selector);
if (release(input, &selector) != TouchAction::None) return 9;
input.update(true, 30, layout.wifi.y - 1, 160, 160, false, &selector);
if (release(input, &selector) != TouchAction::None) return 10;
input.update(true, 30, layout.wifi.y + layout.wifi.height,
160, 160, false, &selector);
if (release(input, &selector) != TouchAction::None) return 11;
input.update(true, 30, layout.wifi.y + layout.wifi.height - 1,
160, 160, false, &selector);
if (release(input, &selector) != TouchAction::SelectLeft) return 12;
// Ordinary pages keep the two-sample protection against a detached swipe
// endpoint being interpreted as a tap.
input.update(true, 30, 70, 160, 160, false, nullptr);
if (release(input, nullptr) != TouchAction::None) return 13;
return 0;
}
'''
@@ -144,11 +179,11 @@ int main() {
source = UI.read_text(encoding="utf-8")
self.assertIn("#ifdef COMPANION_EXCLUSIVE_WIFI_BLE", source)
self.assertIn("TRANSPORT,", source)
self.assertIn('"Wi-Fi", wifi_active, wifi_selected', source)
self.assertIn('"WiFi", wifi_active, wifi_selected', source)
self.assertIn('"BLE", !wifi_active, !wifi_selected', source)
self.assertIn("display.fillRect(x, y, width, height);", source)
self.assertIn('"ACTIVE"', source)
self.assertIn('large_transport_text ? 2 : 1', source)
self.assertIn('large_transport_text ? 3 : 1', source)
self.assertIn("const bool show_status_label = height >= 44;", source)
self.assertIn('active ? "ACTIVE"', source)
self.assertIn('large_transport_text ? "NEXT" : "NEXT BOOT"', source)
@@ -181,6 +181,36 @@ TEST(DisplayDriver, IndicatorHomeSeparatesInfoAndPairingGeometry) {
layout.pairing, layout.pairing_value_y, 24));
}
TEST(DisplayDriver, NativeIndicatorExpandsHomeTypographyRegions) {
EXPECT_TRUE(mesh::ui::usesExpandedCompanionHomeTypography(
160, 160, 480, 480));
EXPECT_FALSE(mesh::ui::usesExpandedCompanionHomeTypography(
160, 160, 320, 320));
EXPECT_FALSE(mesh::ui::usesExpandedCompanionHomeTypography(
128, 64, 480, 480));
const mesh::ui::CompanionHomeLayout layout =
mesh::ui::makeLargeCompanionHomeLayout(160, 160, true);
EXPECT_EQ(2, layout.info.x);
EXPECT_EQ(51, layout.info.y);
EXPECT_EQ(156, layout.info.width);
EXPECT_EQ(46, layout.info.height);
EXPECT_EQ(8, layout.pairing.x);
EXPECT_EQ(100, layout.pairing.y);
EXPECT_EQ(144, layout.pairing.width);
EXPECT_EQ(58, layout.pairing.height);
EXPECT_FALSE(mesh::ui::displayRegionsOverlap(layout.info,
layout.pairing));
EXPECT_TRUE(mesh::ui::displayRegionContainsLine(
layout.info, layout.instruction_y, 20));
EXPECT_TRUE(mesh::ui::displayRegionContainsLine(
layout.info, layout.network_y, 20));
EXPECT_TRUE(mesh::ui::displayRegionContainsLine(
layout.pairing, layout.pairing_label_y, 20));
EXPECT_TRUE(mesh::ui::displayRegionContainsLine(
layout.pairing, layout.pairing_value_y, 30));
}
TEST(DisplayDriver, IndicatorPairingValuesFitAtRenderedTextSizes) {
// Model the fallback font's fixed six-logical-pixel cell. It is the wider
// of the two paths for CONNECTED; the profile test reads the recovered
+27 -2
View File
@@ -103,8 +103,13 @@ class IndicatorDisplayProfileTest(unittest.TestCase):
requested_size * coordinate_scale / native_scale
* profile_scale
)
width = sum(advances[ord(char)] for char in text)
height = max(heights[ord(char)] for char in text)
# Blank glyphs carry no bitmap record in the packed VLW. The
# checked-in font is monospaced with the documented 18px cell, so
# spaces still advance by one complete cell.
width = sum(
18 if char == " " else advances[ord(char)] for char in text
)
height = max(heights[ord(char)] for char in text if char != " ")
return (
int((width * render_scale + coordinate_scale - 1)
// coordinate_scale),
@@ -129,6 +134,26 @@ class IndicatorDisplayProfileTest(unittest.TestCase):
self.assertLessEqual(width, 144)
self.assertLessEqual(height, 30)
# Native 480 reflows the spacious home page instead of globally
# enlarging every dense UI row. Its prominent strings fit at their
# requested sizes, while a two-digit inbox count takes the deliberate
# size-2 fallback.
native_title = dimensions("INBOX 0", 3, 3, 1.2)
native_two_digit_title = dimensions("INBOX 10", 3, 3, 1.2)
native_two_digit_fallback = dimensions("INBOX 10", 2, 3, 1.2)
self.assertLessEqual(native_title[0], 156)
self.assertGreater(native_two_digit_title[0], 156)
self.assertLessEqual(native_two_digit_fallback[0], 156)
for text, region_width in (
("tap inbox", 156),
("WiFi: OFF", 156),
("BLE PIN", 144),
):
width, height = dimensions(text, 2, 3, 1.2)
self.assertLessEqual(width, region_width)
self.assertLessEqual(height, 20)
if __name__ == "__main__":
unittest.main()
@@ -153,6 +153,32 @@ TEST(TouchInput, SplitSelectorMapsTapHalvesToExplicitChoices) {
TouchAction::None);
}
TEST(TouchInput, SplitSelectorAcceptsQuickBoundedTapsOnly) {
TouchInput input(true, true, 70);
const TouchSplitSelector selector{2, 76, 82, 76, 36, 98};
input.update(true, 30, 100, 160, 160, false, &selector);
EXPECT_EQ(input.update(false, -1, -1, 160, 160, false, &selector),
TouchAction::None);
EXPECT_EQ(input.update(false, -1, -1, 160, 160, false, &selector),
TouchAction::SelectLeft);
input.update(true, 130, 100, 160, 160, false, &selector);
EXPECT_EQ(input.update(false, -1, -1, 160, 160, false, &selector),
TouchAction::None);
EXPECT_EQ(input.update(false, -1, -1, 160, 160, false, &selector),
TouchAction::SelectRight);
input.update(true, 80, 100, 160, 160, false, &selector);
EXPECT_EQ(input.update(false, -1, -1, 160, 160, false, &selector),
TouchAction::None);
EXPECT_EQ(input.update(false, -1, -1, 160, 160, false, &selector),
TouchAction::None);
input.update(true, 30, 100, 160, 160);
EXPECT_EQ(release(input, 160, 160), TouchAction::None);
}
TEST(TouchInput, SplitSelectorKeepsSwipesAsNavigation) {
TouchInput input(true, true, 70);
const TouchSplitSelector selector{8, 68, 84, 68, 38, 72};
@@ -16,6 +16,10 @@ wakes it without selecting anything.
side performs Previous or Next; horizontal swipes remain available anywhere.
- A gesture is committed after a stable release. Brief missing touch samples
are ignored so one swipe cannot become a second, opposite endpoint tap.
- The transport page is the exception for a very quick stationary contact:
either tall, outlined WiFi/BLE box can accept one sampled point after the
same stable-release debounce. Taps in the title, center gap, or footer stay
inert, and a moving contact is still handled as a swipe first.
## Reading messages
@@ -111,6 +115,13 @@ Bluetooth controller and host memory. BLE mode does not start infrastructure
WiFi, WebConfig, MQTT, OTA networking, or TCP Companion services. USB remains
available in both modes for recovery.
The Indicator transport page presents WiFi and BLE as full-height side-by-side
choices. Tapping either choice saves it and reboots only when it differs from
the active mode. Its size-3 choice labels render at the same physical size in
the 320 and 480 profiles. On a retained native 480 canvas, the first home page
also reflows its inbox heading and short status labels at larger sizes; long IP
addresses automatically fall back to the smaller row size instead of clipping.
LoRa remains the primary radio in both LoRa modes. On the ESP-NOW layout,
selecting BLE leaves the primary ESP-NOW WiFi radio and its fixed channel
running; it disables only infrastructure WiFi. Rendering follows the memory